/** * Shared SQLite dialect for synchronous `SqliteServerStorage` on Bun or Node * and asynchronous `D1ServerStorage` on Cloudflare Workers. D1 is SQLite: * same DDL, same statement grammar, * same `?` positional placeholders, same `INSERT ... ON CONFLICT` / `INSERT * OR IGNORE` upsert idioms — so the schema and the value (de)serialization are * genuinely common ground and live here. * * What is not shared: statement execution. The server SQLite driver is sync * (`db.query(sql).get(...)`) and D1 is async (`await * db.prepare(sql).bind(...).all()`); a shared execution layer would have to * pick one calling convention and adapt the other, which is uglier than two * thin storage classes that each speak their driver's native shape while * importing the same SQL text and codecs from here. * Both classes run the identical `test/storage-contract.ts`, so the * behavior is held key-for-key regardless. */ import type { ScopeMap } from '@syncular/core'; import type { StoredChange, StoredCommit, StoredPushResult, StoredRow } from './storage.js'; /** * Schema DDL, one statement per `;`-delimited chunk. Native SQLite applies * the whole string via `db.exec(SQLITE_DDL)`; D1 applies each statement * separately (its `prepare`/`batch` API is one statement per call). Types * are SQLite's: `INTEGER`/`TEXT`/`BLOB`. Scopes are stored as JSON `TEXT` * (no JSONB on SQLite), payloads as `BLOB`. * * The inverted scope index carries the ordered column (`commit_seq` / * `row_id`) last in the PRIMARY KEY, so the candidate scan is an index * range that returns already-ordered rows — the same covering-index shape * the Postgres storage documents (§3.1, performance-by- * construction). */ export declare const SQLITE_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq INTEGER NOT NULL DEFAULT 0,\n horizon_seq INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required INTEGER NOT NULL DEFAULT 0,\n last_authenticated_at_ms INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op INTEGER NOT NULL,\n row_version INTEGER, scopes TEXT NOT NULL, payload BLOB,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result TEXT NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,\n available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,\n last_failure TEXT,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\n"; /** Split the DDL into individual statements (D1 applies them one by one). */ export declare function sqliteDdlStatements(): string[]; /** `?,?,…` for an `IN (…)` clause of `count` positional parameters. */ export declare function placeholders(count: number): string; /** Serialize a push result to the JSON `TEXT` stored in `sync_push_results`. */ export declare function serializePushResult(result: StoredPushResult): string; export declare function deserializePushResult(text: string): StoredPushResult; /** Row-record shape the SQLite `SELECT`s in both storages return. */ export interface SqliteRowRecord { row_id: string; server_version: number; scopes: string; payload: Uint8Array; } export interface SqliteChangeRecord { tbl: string; row_id: string; op: number; row_version: number | null; scopes: string; payload: Uint8Array | null; } /** Native SQLite returns `Uint8Array`; D1 returns `ArrayBuffer` for BLOBs. */ export declare function asUint8Array(value: unknown): Uint8Array; export declare function toStoredRow(record: SqliteRowRecord): StoredRow; export declare function toStoredChange(record: SqliteChangeRecord): StoredChange; /** * One result row of `commitWindowPageSql` (candidate LEFT JOIN commit meta * LEFT JOIN changes): meta/change columns are NULL when the joined row * vanished (see the builder's LEFT JOIN contract). `payload` is a BLOB — * Native SQLite hands back `Uint8Array`, D1 `ArrayBuffer`; `toStoredChange` * normalizes via `asUint8Array`. */ export interface SqliteCommitWindowRecord { commit_seq: number; actor_id: string | null; created_at_ms: number | null; tbl: string | null; row_id: string | null; op: number | null; row_version: number | null; scopes: string | null; payload: Uint8Array | null; } /** * Fold one `commitWindowPageSql` page into commits, preserving the exact * semantics of the old per-candidate loop: * * - rows arrive ordered (commit_seq, idx); consecutive rows with the same * `commit_seq` are one candidate commit; * - every candidate advances the cursor (`lastSeq`) — including vanished * commits (NULL meta) and commits whose changes all fail the exact * multi-variable scope verification (`matchesEffective`); * - a commit is emitted only with its matching changes, in `idx` order; * - stops after the commit that accumulates at least `remaining` matching * changes (never splitting a commit). * * `candidateCount` counts the processed candidates so the caller can detect * a short page (window exhausted) exactly as it did with the old candidate * query. */ export declare function collectCommitWindowPage(records: readonly SqliteCommitWindowRecord[], scopeFilter: ScopeMap, remaining: number): { commits: StoredCommit[]; delivered: number; lastSeq: number; candidateCount: number; };