/** * Relational current-row storage. * * Every synced table is a REAL table in the server database — the app's * columns with proper type affinities, queryable with plain SQL/joins/BI — * plus five `_sync_*` meta columns: * * _sync_partition TEXT partition key (multi-partition servers * share one database; same app PK in two * partitions must coexist) * _sync_row_id TEXT the change rowId (§2.2) — the string * rendering of the app PK. The storage key * stays TEXT so lookup and scan-pagination * semantics are byte-identical to the old * generic store regardless of the PK's type. * _sync_server_version INTEGER §2.2 server version * _sync_scopes TEXT/JSONB the stored-scope map the §3.4 authz read * consumes (also present as real columns; * the map is cheaper to read back) * _sync_payload BLOB/BYTEA the verbatim row-codec bytes — the wire * source of truth. The serve path reads * THIS, never re-encodes from typed columns, * so sync round-trips are byte-identical by * construction. Typed columns are a * queryable projection. * * PRIMARY KEY (_sync_partition, _sync_row_id). * * The typed columns are written from `decodeRow(payload)` in the same * statement as the payload, so projection and payload cannot drift except * through a codec bug — which the round-trip contract tests would catch. * * Dialect notes: * - postgres maps `json` → JSONB (queryable: `->>`, GIN). JSONB normalizes * bytes, which is safe because the wire never reads typed columns. * A json value that fails to parse binds NULL on postgres (the payload * still holds it verbatim); sqlite stores the text as-is. * - D1's JSON transport represents integers as JS doubles; app integer * columns beyond 2^53 lose precision in the PROJECTION only — the * payload is exact, sync is unaffected. * - Migration-added columns (ALTER TABLE ADD COLUMN) are always nullable at * the DB layer: SQLite cannot add a NOT NULL column without a default, and * the row codec — not the DB — is the type authority. Fresh CREATEs carry * NOT NULL per the schema. */ import { type RowColumn, type RowValue } from '@syncular/core'; import type { CompiledSchema, CompiledTable, IndexSchema } from './schema.js'; import type { StoredRow } from './storage.js'; export type RelationalDialect = 'sqlite' | 'postgres'; export declare const SYNC_PARTITION_COLUMN = "_sync_partition"; export declare const SYNC_ROW_ID_COLUMN = "_sync_row_id"; export declare const SYNC_VERSION_COLUMN = "_sync_server_version"; export declare const SYNC_SCOPES_COLUMN = "_sync_scopes"; export declare const SYNC_PAYLOAD_COLUMN = "_sync_payload"; /** SQL-standard identifier quoting (both dialects). */ export declare function quoteIdent(name: string): string; /** §5.3-style type affinities, per dialect. */ export declare function columnSqlType(column: RowColumn, dialect: RelationalDialect): string; /** * The full column list of a relational row table, in INSERT order: * partition, row id, app columns (schema order; only when the table is * materialized), version, scopes, payload. */ export declare function tableColumnNames(table: CompiledTable): string[]; /** CREATE TABLE IF NOT EXISTS for one app table. */ export declare function createTableDdl(table: CompiledTable, dialect: RelationalDialect): string; /** * ALTER TABLE ADD COLUMN statements for schema columns missing from * `existingColumns` (introspected). Added columns are nullable (header note). */ export declare function addColumnDdl(table: CompiledTable, existingColumns: ReadonlySet, dialect: RelationalDialect): string[]; /** * Ownership marker for server-created projection indexes. A version bump * rebuilds declared indexes by dropping every index Syncular owns and * re-creating the declared set; this prefix is what marks an index as * Syncular-owned, so operator-added tuning indexes survive bumps. Portable * identifier validation reserves the `sync_` prefix for the server storage * namespace, which keeps user and operator index names out of it. */ export declare const SYNC_INDEX_PREFIX = "sync_ix_"; /** * Physical name of one declared index: the ownership prefix plus the * declared name. When that would exceed the 63-byte Postgres identifier * limit, the declared name is replaced by its FNV-1a hash so the physical * name stays deterministic, unique, and within the limit. */ export declare function physicalIndexName(declaredName: string): string; /** * CREATE INDEX IF NOT EXISTS for the table's user-declared indexes. These use * the same declared names and columns the client materializes. Cross-table * index-name uniqueness is the user's schema concern, as it is client-side. * Server-side the physical name carries the {@link SYNC_INDEX_PREFIX} * ownership marker. */ export declare function createIndexDdl(table: CompiledTable): string[]; /** Idempotent removal of one Syncular-owned relational projection index. */ export declare function dropIndexDdl(indexName: string): string; /** * Convert one decoded row value to its SQL bind value. * - boolean → 0/1 on sqlite (INTEGER affinity), native on postgres; * - json → the raw string on sqlite; on postgres, NULL if unparseable * (JSONB would reject the INSERT — the payload keeps the verbatim value); * - everything else binds naturally (Uint8Array → BLOB/BYTEA). */ export declare function toSqlValue(column: RowColumn, value: RowValue, dialect: RelationalDialect): unknown; /** * Bind values for an upsert, in `tableColumnNames` order. Decodes the * payload once; the typed columns and the verbatim payload land in one * statement, so they cannot drift. */ export declare function upsertValues(table: CompiledTable, partition: string, row: StoredRow, dialect: RelationalDialect): unknown[]; /** Upsert statement text (bind with `upsertValues`). */ export declare function upsertSql(table: CompiledTable, dialect: RelationalDialect): string; /** * SELECT one stored row. Aliased to the legacy record shape * (`row_id`/`server_version`/`scopes`/`payload`) so the existing * `toStoredRow` converters keep working. Params: [partition, rowId]. */ export declare function selectRowSql(table: CompiledTable, dialect: RelationalDialect): string; export interface IndexRowPageStatement { readonly sql: string; readonly params: readonly unknown[]; } /** * Bounded exact lookup through one declared relational index. Unlike * `scanRowPageSql`, this is a trusted server-host query: it never reads or * creates Syncular scope-index entries and is not reachable from SSP2. */ export declare function indexRowPageStatement(table: CompiledTable, index: IndexSchema, values: readonly RowValue[], partition: string, afterRowId: string | null | undefined, limit: number, dialect: RelationalDialect): IndexRowPageStatement; /** * One-round-trip page scan for `scanRows`: candidates from the inverted * scope index (ordered + LIMITed at the covering `sync_row_scopes` PK — * exactly the old candidate query, so the index-first posture is unchanged) * LEFT JOINed to the row table, so a whole page arrives in ONE statement * instead of one lookup per candidate. That per-candidate lookup was the * cold-bootstrap hot path: a 100k-row snapshot on Postgres paid ~100k * network round-trips (~10 s at 0.1 ms each) before this join. * * LEFT JOIN (not INNER): a candidate whose row vanished (index entry * without a row) must still reach the caller — its `row_id` advances the * keyset cursor — so it comes back with NULL payload rather than * disappearing from the page. * * Bind order: * - postgres: [partition, tbl, var, ...values, afterRowId, limit] * (the join reuses `$1` for the partition); * - sqlite: [partition, tbl, var, ...values, afterRowId, limit, * partition] (positional `?` — the partition binds again for the join). */ export declare function scanRowPageSql(table: CompiledTable, valueCount: number, dialect: RelationalDialect): string; /** * One-round-trip page read for `readCommitWindow` (sync_* tables only; it * lives here beside `scanRowPageSql` because it is the same join-the-index * posture, dialect-parameterized the same way): candidates from the inverted * change-scope index (ordered + LIMITed at the covering `sync_change_scopes` * PK — exactly the old candidate query, so the index-first invariant of * `CommitWindowQuery` is unchanged) LEFT JOINed to the commit metadata and * to the commit's changes for the table, so a whole window page arrives in * ONE statement instead of 1 + 2×candidates round trips. This is the * incremental-pull hot path — it runs on every sync round; before the join a * 500-candidate catch-up window on Postgres paid ~1000 network round trips. * * LEFT JOIN (not INNER): a candidate whose commit vanished (scope-index * entry without a commit/changes row) must still reach the caller — its * `commit_seq` advances the window cursor — so it comes back with NULL * meta/change columns rather than disappearing from the page. * * Rows arrive ordered (commit_seq, idx): oldest-first commits, each commit's * change rows consecutive in `idx` order — the caller groups consecutive * rows and verifies the full multi-variable scope match in JS. * * Bind order: * - postgres: [partition, tbl, var, ...values, afterSeq, throughSeq, * limit] (the joins reuse `$1`/`$2`); * - sqlite: [partition, tbl, var, ...values, afterSeq, throughSeq, * limit, partition, partition, tbl] (positional `?` — partition/tbl bind * again for the joins). */ export declare function commitWindowPageSql(valueCount: number, dialect: RelationalDialect): string; /** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */ export declare function selectRowScopesSql(table: CompiledTable, dialect: RelationalDialect): string; /** DELETE one stored row. Params: [partition, rowId]. */ export declare function deleteRowSql(table: CompiledTable, dialect: RelationalDialect): string; /** * The schema-version marker table gates DDL work. `ensureSchema` compares the * stored version and skips * all introspection/DDL when it matches — one cheap read per storage * instance (relevant for D1's per-request instantiation). * * `layouts` persists each table's column layout (name/type/nullable, the * exact inputs the row codec's byte layout depends on) as of the LAST * applied schema version. The codec is strict — a payload only decodes * under the column list it was encoded with — so a version bump MUST * re-encode stored payloads (append trailing NULLs for added columns); * decoding the old bytes requires the old layout, and this column is where * it lives. */ export declare const SCHEMA_META_DDL_SQLITE = "CREATE TABLE IF NOT EXISTS sync_schema_meta(\n id INTEGER PRIMARY KEY CHECK (id = 1),\n schema_version INTEGER NOT NULL,\n layouts TEXT NOT NULL DEFAULT '{}'\n)"; export declare const SCHEMA_META_DDL_POSTGRES = "CREATE TABLE IF NOT EXISTS sync_schema_meta(\n id INTEGER PRIMARY KEY CHECK (id = 1),\n schema_version BIGINT NOT NULL,\n layouts TEXT NOT NULL DEFAULT '{}'\n)"; /** The codec-relevant subset of a column, persisted per applied version. */ export interface StoredColumnLayout { readonly name: string; readonly type: RowColumn['type']; readonly nullable: boolean; } export type StoredLayouts = Record; /** The layouts JSON persisted alongside the schema version marker. */ export declare function layoutsOf(schema: CompiledSchema): string; export declare function parseLayouts(json: string | null | undefined): StoredLayouts; /** * Tables present in the stored layout but absent from the configured head * schema. A schema-version bump retires their relational current-row tables; * append-only commit history remains governed by the normal retention policy. */ export declare function retiredTableNames(schema: CompiledSchema, storedLayouts: StoredLayouts): string[]; /** Idempotent DDL for one retired relational current-row table. */ export declare function dropTableDdl(tableName: string): string; /** * Enforce the migration subset on a table's column list: the old layout * must be an exact prefix (same name, type, nullability) of the new one, * and appended columns must be nullable (there is no default to backfill). * Returns `true` iff columns were appended. */ export declare function assertAppendOnlyMigration(tableName: string, oldLayout: readonly StoredColumnLayout[], table: CompiledTable): boolean; /** * Re-encode an old-layout payload under the current columns: decode with * the layout it was written under, append NULLs for the added columns, * encode with the current column list. The write path (§3.4 scope-strip, * CRDT merge, conflict serverRow) and the bootstrap serve path both decode * stored payloads under the CURRENT schema, so this migration is a * correctness requirement of the version bump, not an optimization. */ export declare function migratePayload(oldLayout: readonly StoredColumnLayout[], table: CompiledTable, payload: Uint8Array): Uint8Array; /** * Keyset-paged scan of a row table for the migration rewrite. * Params: [afterPartition, afterRowId, limit]. */ export declare function selectRowsForRewriteSql(table: CompiledTable, dialect: RelationalDialect): string; /** * Rewrite one row during a migration: refresh the projection columns (when * materialized) and the payload. Bind with `rewriteValues`. */ export declare function rewriteRowSql(table: CompiledTable, dialect: RelationalDialect): string; /** Bind values for `rewriteRowSql` from the (already migrated) payload. */ export declare function rewriteValues(table: CompiledTable, partition: string, rowId: string, payload: Uint8Array, dialect: RelationalDialect): unknown[]; /** * What the migration rewrite phase must do for one table, derived from the * persisted layouts + the physical columns present before this run. * * - `migrate`: the layout gained columns — every stored payload re-encodes * under the new column list (correctness; see `migratePayload`). * - `backfill`: the table just gained physical projection columns whose * values exist in stored payloads (materialization flipped on) — the * projection refreshes from the payload, no re-encode. */ export declare function rewritePlan(table: CompiledTable, oldLayout: readonly StoredColumnLayout[] | undefined, physicalColumnsBefore: ReadonlySet | undefined): { migrate: boolean; backfill: boolean; }; /** * Every DDL statement to bring a database from the introspected relational * projections to `schema`: CREATE TABLE for absent tables, ADD COLUMN for * missing columns, and rebuild declared secondary indexes. Rebuilding during * a version bump supports DROP INDEX and same-name index replacement without * requiring historical index definitions in the stored column-layout marker. * * The drop set is limited to Syncular-owned indexes: physical names carrying * {@link SYNC_INDEX_PREFIX}, plus bare declared names (databases whose * projection indexes predate the ownership prefix migrate onto the prefixed * scheme through this rule). Operator-added tuning indexes keep their own * names and survive every bump. */ export declare function schemaDdl(schema: CompiledSchema, existingColumnsByTable: ReadonlyMap>, dialect: RelationalDialect, existingIndexesByTable?: ReadonlyMap>): string[];