@syncular/server 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +14 -2
  2. package/dist/admin.d.ts +3 -1
  3. package/dist/admin.js +7 -1
  4. package/dist/blob-handlers.js +1 -1
  5. package/dist/context.d.ts +8 -1
  6. package/dist/d1-storage.d.ts +11 -3
  7. package/dist/d1-storage.js +186 -36
  8. package/dist/errors.js +10 -0
  9. package/dist/handler.js +50 -4
  10. package/dist/operations.d.ts +7 -1
  11. package/dist/operations.js +5 -3
  12. package/dist/postgres-storage.d.ts +12 -4
  13. package/dist/postgres-storage.js +310 -59
  14. package/dist/push.js +31 -7
  15. package/dist/readiness.d.ts +9 -2
  16. package/dist/readiness.js +27 -2
  17. package/dist/realtime.js +20 -1
  18. package/dist/relational-rows.d.ts +31 -12
  19. package/dist/relational-rows.js +121 -26
  20. package/dist/segment-download.js +8 -0
  21. package/dist/sqlite-dialect.d.ts +1 -1
  22. package/dist/sqlite-dialect.js +15 -0
  23. package/dist/sqlite-storage.d.ts +18 -3
  24. package/dist/sqlite-storage.js +327 -82
  25. package/dist/storage-errors.d.ts +5 -3
  26. package/dist/storage-errors.js +13 -2
  27. package/dist/storage-query.d.ts +8 -1
  28. package/dist/storage-query.js +12 -0
  29. package/dist/storage.d.ts +153 -14
  30. package/dist/storage.js +40 -1
  31. package/dist/validate.d.ts +33 -15
  32. package/package.json +2 -2
  33. package/src/admin.ts +13 -1
  34. package/src/blob-handlers.ts +1 -1
  35. package/src/context.ts +8 -0
  36. package/src/d1-storage.ts +282 -37
  37. package/src/errors.ts +10 -0
  38. package/src/handler.ts +62 -4
  39. package/src/operations.ts +32 -14
  40. package/src/postgres-storage.ts +515 -68
  41. package/src/push.ts +31 -1
  42. package/src/readiness.ts +39 -4
  43. package/src/realtime.ts +22 -1
  44. package/src/relational-rows.ts +145 -30
  45. package/src/segment-download.ts +16 -0
  46. package/src/sqlite-dialect.ts +15 -0
  47. package/src/sqlite-storage.ts +491 -96
  48. package/src/storage-errors.ts +33 -3
  49. package/src/storage-query.ts +23 -1
  50. package/src/storage.ts +245 -12
  51. package/src/validate.ts +40 -15
@@ -1,7 +1,7 @@
1
1
  import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
2
2
  import { type PgExecutor } from './pg-executor.js';
3
3
  import type { CompiledSchema, CompiledTable } from './schema.js';
4
- import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
4
+ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCheckpoint, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
5
5
  /**
6
6
  * Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
7
7
  *
@@ -31,7 +31,7 @@ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorI
31
31
  * set, §5.9.5) as an index range, never a scan. `postgres-explain
32
32
  * .test.ts` asserts an `Index` node here too.
33
33
  */
34
- export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT 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 BOOLEAN NOT NULL DEFAULT FALSE,\n last_authenticated_at_ms BIGINT 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 INDEX IF NOT EXISTS sync_row_scopes_by_row\n ON sync_row_scopes(partition, tbl, row_id);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT 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 BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\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 BIGINT 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 JSONB 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 JSONB NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,\n available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,\n last_failure JSONB,\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 BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nALTER TABLE sync_clients\n ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;\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);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\n";
34
+ export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT 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 BOOLEAN NOT NULL DEFAULT FALSE,\n last_authenticated_at_ms BIGINT 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 INDEX IF NOT EXISTS sync_row_scopes_by_row\n ON sync_row_scopes(partition, tbl, row_id);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,\n writer_version BIGINT,\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 BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\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 BIGINT 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 JSONB 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 JSONB NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,\n available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,\n last_failure JSONB,\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 BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nALTER TABLE sync_clients\n ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;\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);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(\n partition TEXT NOT NULL, name TEXT NOT NULL,\n schema_version INTEGER NOT NULL,\n state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),\n watermark BIGINT NOT NULL DEFAULT 0,\n owner_epoch BIGINT NOT NULL DEFAULT 0,\n observed_rows BIGINT NOT NULL DEFAULT 0,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, name)\n);\nCREATE TABLE IF NOT EXISTS sync_writer_fence(\n partition TEXT PRIMARY KEY,\n required_writer_version INTEGER NOT NULL\n);\n";
35
35
  export declare class PostgresServerStorage implements ServerStorage {
36
36
  #private;
37
37
  constructor(exec: PgExecutor);
@@ -39,7 +39,7 @@ export declare class PostgresServerStorage implements ServerStorage {
39
39
  migrate(): Promise<void>;
40
40
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
41
41
  table(name: string): CompiledTable;
42
- ensureSchema(schema: CompiledSchema): Promise<void>;
42
+ ensureSchema(schema: CompiledSchema, checkpoints?: readonly CheckpointDeclaration[]): Promise<void>;
43
43
  touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
44
44
  rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
45
45
  listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
@@ -53,12 +53,20 @@ export declare class PostgresServerStorage implements ServerStorage {
53
53
  */
54
54
  begin(partition: string): Promise<StorageTransaction>;
55
55
  getMaxCommitSeq(partition: string): Promise<number>;
56
- queryAuthoritative(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
56
+ queryAuthoritative(partition: string, query: AuthoritativeQueryRequest, checkpoints?: readonly CheckpointDeclaration[]): Promise<AuthoritativeQueryResult>;
57
57
  getPartitionLogEpoch(partition: string): Promise<string | undefined>;
58
58
  getHorizonSeq(partition: string): Promise<number>;
59
59
  setHorizonSeq(partition: string, seq: number): Promise<void>;
60
60
  pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
61
61
  getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
62
+ readCheckpoints(partition: string): Promise<StoredCheckpoint[]>;
63
+ readServeGate(partition: string, runningSchemaVersion: number): Promise<ServeGate>;
64
+ declareCheckpoint(partition: string, name: string, schemaVersion: number, nowMs: number): Promise<StoredCheckpoint>;
65
+ activateCheckpoint(partition: string, name: string, ownerEpoch: number, watermark: number, sources: readonly string[], nowMs: number): Promise<'activated' | 'stale' | 'unverifiable'>;
66
+ claimCheckpoint(partition: string, name: string, schemaVersion: number, nowMs: number): Promise<StoredCheckpoint>;
67
+ advanceCheckpoint(partition: string, name: string, ownerEpoch: number, watermark: number, observedRows: number, nowMs: number): Promise<boolean>;
68
+ sourceCoverageSeq(partition: string, tables: readonly string[]): Promise<number>;
69
+ hasSourceChangesAbove(partition: string, tables: readonly string[], seq: number): Promise<'clean' | 'changed' | 'unverifiable'>;
62
70
  getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
63
71
  getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
64
72
  claimReactions(partition: string, query: ReactionClaimQuery): Promise<StoredReaction[]>;
@@ -1,9 +1,11 @@
1
1
  import { validateCommitPruneQuery } from './prune.js';
2
2
  import { StorageQueryError } from './storage-errors.js';
3
+ import { serveGateRefusal } from './storage.js';
4
+ import { serveNotReadyError } from './readiness.js';
3
5
  import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
4
6
  import { syncError } from './errors.js';
5
7
  import { asBytes, asNumber, } from './pg-executor.js';
6
- import { assertPhysicalColumns, commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
8
+ import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
7
9
  import { matchesEffective } from './scopes.js';
8
10
  import { isPostgresConstraintError, StorageConstraintError, } from './storage-errors.js';
9
11
  import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
@@ -59,6 +61,7 @@ CREATE TABLE IF NOT EXISTS sync_commits(
59
61
  partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,
60
62
  client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,
61
63
  actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,
64
+ writer_version BIGINT,
62
65
  PRIMARY KEY(partition, commit_seq)
63
66
  );
64
67
  CREATE INDEX IF NOT EXISTS sync_commits_by_time
@@ -122,6 +125,20 @@ CREATE TABLE IF NOT EXISTS sync_tombstones(
122
125
  commit_seq BIGINT NOT NULL,
123
126
  PRIMARY KEY(partition, tbl, row_id)
124
127
  );
128
+ CREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(
129
+ partition TEXT NOT NULL, name TEXT NOT NULL,
130
+ schema_version INTEGER NOT NULL,
131
+ state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),
132
+ watermark BIGINT NOT NULL DEFAULT 0,
133
+ owner_epoch BIGINT NOT NULL DEFAULT 0,
134
+ observed_rows BIGINT NOT NULL DEFAULT 0,
135
+ updated_at_ms BIGINT NOT NULL,
136
+ PRIMARY KEY(partition, name)
137
+ );
138
+ CREATE TABLE IF NOT EXISTS sync_writer_fence(
139
+ partition TEXT PRIMARY KEY,
140
+ required_writer_version INTEGER NOT NULL
141
+ );
125
142
  `;
126
143
  async function lockPartitionOn(client, partition) {
127
144
  const locked = await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [partition]);
@@ -133,12 +150,76 @@ async function lockPartitionOn(client, partition) {
133
150
  ON CONFLICT (partition) DO NOTHING`, [partition]);
134
151
  await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [partition]);
135
152
  }
153
+ /**
154
+ * Insert the `declared` row and raise the fence, on the caller's open
155
+ * transaction. Shared by `declareCheckpoint` and the `ensureSchema`
156
+ * migration so the schema bump and the fence commit together. Never lowers a
157
+ * fence and never re-declares an existing row.
158
+ */
159
+ async function installCheckpointOn(client, partition, name, schemaVersion, nowMs) {
160
+ await client.query(`INSERT INTO sync_backfill_checkpoints(
161
+ partition, name, schema_version, state, watermark,
162
+ owner_epoch, observed_rows, updated_at_ms)
163
+ VALUES ($1,$2,$3,'declared',0,0,0,$4)
164
+ ON CONFLICT (partition, name) DO NOTHING`, [partition, name, schemaVersion, nowMs]);
165
+ await client.query(`INSERT INTO sync_writer_fence(partition, required_writer_version)
166
+ VALUES ($1,$2)
167
+ ON CONFLICT (partition) DO UPDATE SET
168
+ required_writer_version=GREATEST(
169
+ sync_writer_fence.required_writer_version,
170
+ EXCLUDED.required_writer_version)`, [partition, schemaVersion]);
171
+ }
172
+ /** RFC 0007 serve gate on either the pool or a transaction's client. */
173
+ async function readServeGateOn(client, partition, runningSchemaVersion) {
174
+ const marker = await client.query('SELECT schema_version FROM sync_schema_meta WHERE id=1');
175
+ const epoch = await client.query('SELECT log_epoch FROM sync_partition_registry WHERE partition=$1', [partition]);
176
+ const pending = await client.query(`SELECT partition, name FROM sync_backfill_checkpoints
177
+ WHERE schema_version=$1 AND state<>'activated'`, [runningSchemaVersion]);
178
+ return {
179
+ storedSchemaVersion: marker.rows[0] === undefined
180
+ ? 0
181
+ : asNumber(marker.rows[0].schema_version),
182
+ logEpoch: epoch.rows[0]?.log_epoch,
183
+ pending: pending.rows.map((row) => ({
184
+ partition: row.partition,
185
+ name: row.name,
186
+ })),
187
+ };
188
+ }
189
+ async function hasSourceChangesAboveOn(client, partition, tables, seq) {
190
+ // Read the horizon before the window scan: a horizon past `seq` means the
191
+ // history that would answer the question is gone, so an empty scan proves
192
+ // nothing and must not be reported as `clean`.
193
+ const horizon = await client.query('SELECT horizon_seq FROM sync_partitions WHERE partition=$1', [partition]);
194
+ const horizonSeq = horizon.rows[0] === undefined ? 0 : asNumber(horizon.rows[0].horizon_seq);
195
+ if (tables.length === 0)
196
+ return 'clean';
197
+ const tableParams = tables.map((_, index) => `$${index + 2}`).join(',');
198
+ const hit = await client.query(`SELECT 1 AS hit FROM sync_changes
199
+ WHERE partition=$1 AND tbl IN (${tableParams}) AND commit_seq>$${tables.length + 2}
200
+ LIMIT 1`, [partition, ...tables, seq]);
201
+ if (hit.rows[0] !== undefined)
202
+ return 'changed';
203
+ return horizonSeq > seq ? 'unverifiable' : 'clean';
204
+ }
136
205
  function toBase64(bytes) {
137
206
  return Buffer.from(bytes).toString('base64');
138
207
  }
139
208
  function fromBase64(text) {
140
209
  return new Uint8Array(Buffer.from(text, 'base64'));
141
210
  }
211
+ function toStoredCheckpoint(record) {
212
+ return {
213
+ partition: record.partition,
214
+ name: record.name,
215
+ schemaVersion: asNumber(record.schema_version),
216
+ state: record.state,
217
+ watermark: asNumber(record.watermark),
218
+ ownerEpoch: asNumber(record.owner_epoch),
219
+ observedRows: asNumber(record.observed_rows),
220
+ updatedAtMs: asNumber(record.updated_at_ms),
221
+ };
222
+ }
142
223
  function toStoredReaction(record) {
143
224
  return {
144
225
  idempotencyKey: record.idempotency_key,
@@ -338,16 +419,31 @@ async function getPushResultOn(q, partition, clientId, clientCommitId) {
338
419
  throw syncError('sync.idempotency_cache_miss', 'persisted push result unreadable (§6.3)');
339
420
  }
340
421
  }
422
+ /** Run one partition-bound registered query and the cursor read on `client`. */
423
+ async function queryAuthoritativeOn(client, partition, prepared) {
424
+ const result = await client.query(postgresPlaceholders(prepared.sql), prepared.params);
425
+ const cursor = await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1', [partition]);
426
+ return {
427
+ rows: result.rows,
428
+ maxCommitSeq: cursor.rows[0] === undefined
429
+ ? 0
430
+ : asNumber(cursor.rows[0].max_commit_seq),
431
+ };
432
+ }
341
433
  class PostgresTransaction {
342
434
  #client;
343
435
  #partition;
436
+ /** Storage-owned relation rewrite, bound to this transaction's partition. */
437
+ #prepareQuery;
438
+ /** Schema version this transaction writes; rides every appended commit row. */
439
+ #writerVersion;
344
440
  #resolveTable;
345
441
  #open = true;
346
442
  #pushApplySavepoint = false;
347
443
  /** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
348
444
  #resolve;
349
445
  #reject;
350
- constructor(client, partition, resolveTable, resolve, reject) {
446
+ constructor(client, partition, writerVersion, resolveTable, prepareQuery, resolve, reject) {
351
447
  // One pinned connection carries one statement at a time: node-postgres
352
448
  // warns about a concurrently issued second query today and rejects it in
353
449
  // node-postgres 9. A whole-commit validator that fans independent reads
@@ -363,7 +459,9 @@ class PostgresTransaction {
363
459
  },
364
460
  };
365
461
  this.#partition = partition;
462
+ this.#writerVersion = writerVersion;
366
463
  this.#resolveTable = resolveTable;
464
+ this.#prepareQuery = prepareQuery;
367
465
  this.#resolve = resolve;
368
466
  this.#reject = reject;
369
467
  }
@@ -433,12 +531,36 @@ class PostgresTransaction {
433
531
  this.#assertOpen();
434
532
  return scanRowsByIndexOn(this.#client, this.#resolveTable(query.table), this.#partition, query);
435
533
  }
534
+ queryAuthoritative(query) {
535
+ this.#assertOpen();
536
+ // Runs on this transaction's pinned client, so the read observes every
537
+ // staged row and never waits for a second pool connection.
538
+ return queryAuthoritativeOn(this.#client, this.#partition, this.#prepareQuery(query));
539
+ }
436
540
  async lockPartitionForPush() {
437
541
  this.#assertOpen();
438
542
  await lockPartitionOn(this.#client, this.#partition);
439
543
  await this.#client.query('SAVEPOINT syncular_push_candidate');
440
544
  this.#pushApplySavepoint = true;
441
545
  }
546
+ async advanceCheckpoint(name, ownerEpoch, watermark, observedRows, nowMs) {
547
+ this.#assertOpen();
548
+ // `RETURNING`: the affected-row check must not depend on a driver's
549
+ // `rowCount` for a statement with no result rows. Bun.sql's canonical
550
+ // adapter derives `rowCount` from the returned rows, so a bare UPDATE
551
+ // reports 0 and the CAS would never advance on a real Bun.sql host.
552
+ const result = await this.#client.query(`UPDATE sync_backfill_checkpoints
553
+ SET watermark=$1, observed_rows=$2, updated_at_ms=$3
554
+ WHERE partition=$4 AND name=$5 AND owner_epoch=$6
555
+ RETURNING name`, [watermark, observedRows, nowMs, this.#partition, name, ownerEpoch]);
556
+ return result.rows.length === 1;
557
+ }
558
+ readServeGate(runningSchemaVersion) {
559
+ this.#assertOpen();
560
+ // The transaction's own client: the gate and the write share one
561
+ // transaction, so a migration cannot interleave between them.
562
+ return readServeGateOn(this.#client, this.#partition, runningSchemaVersion);
563
+ }
442
564
  async commitRejectedPushResult(clientId, clientCommitId, result) {
443
565
  this.#assertOpen();
444
566
  if (!this.#pushApplySavepoint) {
@@ -491,14 +613,15 @@ class PostgresTransaction {
491
613
  SET max_commit_seq = sync_partitions.max_commit_seq + 1
492
614
  RETURNING max_commit_seq
493
615
  )
494
- INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms)
495
- SELECT $1, max_commit_seq, $2, $3, $4, $5 FROM allocated
616
+ INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms, writer_version)
617
+ SELECT $1, max_commit_seq, $2, $3, $4, $5, $6 FROM allocated
496
618
  RETURNING commit_seq`, [
497
619
  p,
498
620
  commit.clientId,
499
621
  commit.clientCommitId,
500
622
  commit.actorId,
501
623
  commit.createdAtMs,
624
+ this.#writerVersion,
502
625
  ]);
503
626
  const commitSeq = asNumber(rows[0]?.commit_seq);
504
627
  for (let idx = 0; idx < commit.changes.length; idx++) {
@@ -615,6 +738,29 @@ export class PostgresServerStorage {
615
738
  for (const statement of statements) {
616
739
  await this.#exec.query(statement);
617
740
  }
741
+ await this.#exec.query('ALTER TABLE sync_commits ADD COLUMN IF NOT EXISTS writer_version BIGINT');
742
+ // Database-side fence: an old binary's INSERT never reaches JS. An absent
743
+ // `sync_writer_fence` row makes the EXISTS false, so a partition with no
744
+ // barrier behaves exactly as before. Sent as separate statements because
745
+ // the dollar-quoted body contains `;`.
746
+ await this.#exec.query(`CREATE OR REPLACE FUNCTION syncular_writer_fence()
747
+ RETURNS trigger AS $$
748
+ BEGIN
749
+ IF EXISTS (
750
+ SELECT 1 FROM sync_writer_fence
751
+ WHERE partition = NEW.partition
752
+ AND (NEW.writer_version IS NULL
753
+ OR NEW.writer_version < required_writer_version)
754
+ ) THEN
755
+ RAISE EXCEPTION 'sync.storage.writer_fence_rejected';
756
+ END IF;
757
+ RETURN NEW;
758
+ END;
759
+ $$ LANGUAGE plpgsql`);
760
+ await this.#exec.query('DROP TRIGGER IF EXISTS sync_commits_writer_fence ON sync_commits');
761
+ await this.#exec.query(`CREATE TRIGGER sync_commits_writer_fence
762
+ BEFORE INSERT ON sync_commits
763
+ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
618
764
  }
619
765
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
620
766
  table(name) {
@@ -624,11 +770,22 @@ export class PostgresServerStorage {
624
770
  }
625
771
  return table;
626
772
  }
627
- async ensureSchema(schema) {
773
+ async ensureSchema(schema, checkpoints) {
628
774
  // Memoized fast path: same instance, same schema version.
629
775
  if (this.#schemaVersion === schema.version)
630
776
  return;
631
777
  await this.migrate();
778
+ // The barrier reads stored state: a checkpoint at this schema version that
779
+ // is not activated and that this caller did not declare means this process
780
+ // must not serve. An omitted or empty declaration set declares nothing and
781
+ // is refused here, never treated as a bypass.
782
+ const declarations = checkpoints ?? [];
783
+ const incomplete = await this.#exec.query(`SELECT partition, name FROM sync_backfill_checkpoints
784
+ WHERE schema_version=$1 AND state<>'activated'`, [schema.version]);
785
+ if (incomplete.rows.some((row) => !declarations.some((declaration) => declaration.partition === row.partition &&
786
+ declaration.name === row.name))) {
787
+ throw new StorageQueryError('sync.storage.checkpoint_incomplete');
788
+ }
632
789
  await this.#exec.query(SCHEMA_META_DDL_POSTGRES);
633
790
  const marker = await this.#exec.query('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1');
634
791
  const stored = marker.rows[0] === undefined
@@ -638,57 +795,32 @@ export class PostgresServerStorage {
638
795
  throw new Error(`stored schema version ${stored} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`);
639
796
  }
640
797
  if (stored !== undefined && stored === schema.version) {
641
- // Version equality is not layout equality: a marker written by another
642
- // build at the same version describes rows the running codec cannot
643
- // decode. Compare the stored layouts instead of trusting the number.
644
- const storedLayouts = parseLayouts(typeof marker.rows[0]?.layouts === 'string'
798
+ assertStoredLayouts(schema, typeof marker.rows[0]?.layouts === 'string'
645
799
  ? marker.rows[0].layouts
646
800
  : undefined);
647
- const configuredLayouts = parseLayouts(layoutsOf(schema));
648
- let mismatch;
649
- for (const [tableName, columns] of Object.entries(configuredLayouts)) {
650
- const storedTable = storedLayouts[tableName];
651
- const columnCount = Math.max(columns.length, storedTable?.length ?? 0);
652
- for (let index = 0; index < columnCount; index++) {
653
- const expected = columns[index];
654
- const actual = storedTable?.[index];
655
- if (expected !== undefined &&
656
- actual !== undefined &&
657
- actual.name === expected.name &&
658
- actual.type === expected.type &&
659
- actual.nullable === expected.nullable) {
660
- continue;
661
- }
662
- mismatch = `table ${JSON.stringify(tableName)} column ${JSON.stringify(expected?.name ?? actual?.name ?? '')}`;
663
- break;
664
- }
665
- if (mismatch !== undefined)
666
- break;
667
- }
668
- if (mismatch === undefined) {
669
- for (const tableName of Object.keys(storedLayouts)) {
670
- if (!(tableName in configuredLayouts)) {
671
- mismatch = `table ${JSON.stringify(tableName)}`;
672
- break;
673
- }
674
- }
675
- }
676
- if (mismatch !== undefined) {
677
- throw new Error(`stored schema layouts disagree with the configured schema at version ${schema.version} (${mismatch}) — refusing to serve a database whose stored rows the running code cannot decode`);
678
- }
679
- // The persisted layouts describe the codec's app columns only: read the
680
- // physical tables so a same-version database missing a
681
- // storage-internal column is refused at startup instead of failing at
682
- // the first write. Resolve through `to_regclass`, which follows the
683
- // session's `search_path` exactly as the unqualified row queries do —
801
+ // Resolve through `to_regclass`, which follows the session's
802
+ // `search_path` exactly as the unqualified row queries do:
684
803
  // `current_schema()` names only the first existing schema, so it would
685
804
  // reject a table that resolves later in the path.
686
805
  for (const table of schema.tables.values()) {
687
- const { rows } = await this.#exec.query(`SELECT attribute.attname AS column_name
806
+ const { rows } = await this.#exec.query(`SELECT attribute.attname AS name,
807
+ format_type(attribute.atttypid, attribute.atttypmod) AS type,
808
+ attribute.attnotnull AS not_null,
809
+ (SELECT key.position::int
810
+ FROM pg_index pk,
811
+ unnest(pk.indkey::int2[]) WITH ORDINALITY
812
+ AS key(attnum, position)
813
+ WHERE pk.indrelid = attribute.attrelid AND pk.indisprimary
814
+ AND key.attnum = attribute.attnum) AS pk
688
815
  FROM pg_attribute attribute
689
816
  WHERE attribute.attrelid = to_regclass($1)
690
817
  AND attribute.attnum > 0 AND NOT attribute.attisdropped`, [table.name]);
691
- assertPhysicalColumns(table, new Set(rows.map((r) => r.column_name)));
818
+ assertPhysicalColumns(table, rows.map((row) => ({
819
+ name: row.name,
820
+ type: row.type,
821
+ notNull: row.not_null,
822
+ primaryKeyPosition: row.pk ?? 0,
823
+ })), 'postgres');
692
824
  }
693
825
  }
694
826
  if (stored === undefined || stored < schema.version) {
@@ -703,6 +835,17 @@ export class PostgresServerStorage {
703
835
  : undefined);
704
836
  const retiredTables = retiredTableNames(schema, layouts);
705
837
  await this.#exec.transaction(async (client) => {
838
+ // RFC 0007 migration barrier: `LOCK TABLE ... EXCLUSIVE` is the FIRST
839
+ // statement, before any read, DDL, rewrite, marker or checkpoint
840
+ // install. EXCLUSIVE conflicts with the ROW SHARE that every writer's
841
+ // `lockPartitionOn` takes (`SELECT … FOR UPDATE`) and with the ROW
842
+ // EXCLUSIVE of its allocation (`UPDATE sync_partitions` / `INSERT … ON
843
+ // CONFLICT DO UPDATE`), so an unaware old writer blocks at its first
844
+ // statement with no cooperation; plain `SELECT` (ACCESS SHARE) still
845
+ // works. The lock order is acyclic: the migration holds nothing else
846
+ // when it takes this lock, so a writer already inside completes while
847
+ // the migration waits.
848
+ await client.query('LOCK TABLE sync_partitions IN EXCLUSIVE MODE');
706
849
  const existing = new Map();
707
850
  const existingIndexes = new Map();
708
851
  for (const table of schema.tables.values()) {
@@ -756,6 +899,13 @@ export class PostgresServerStorage {
756
899
  await client.query(`INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1, $1, $2)
757
900
  ON CONFLICT (id) DO UPDATE
758
901
  SET schema_version=EXCLUDED.schema_version, layouts=EXCLUDED.layouts`, [schema.version, layoutsOf(schema)]);
902
+ // The host's declared checkpoints install in the schema-bump
903
+ // transaction, so no observer sees the bumped marker without the
904
+ // fence, or the fence without its declaration row.
905
+ const installedAtMs = Date.now();
906
+ for (const declaration of checkpoints ?? []) {
907
+ await installCheckpointOn(client, declaration.partition, declaration.name, declaration.schemaVersion, installedAtMs);
908
+ }
759
909
  });
760
910
  }
761
911
  this.#tables = schema.tables;
@@ -835,7 +985,7 @@ export class PostgresServerStorage {
835
985
  });
836
986
  const done = this.#exec
837
987
  .transaction(async (client) => {
838
- const tx = new PostgresTransaction(client, partition, (name) => this.table(name), resolveScope, rejectScope);
988
+ const tx = new PostgresTransaction(client, partition, this.#schemaVersion ?? 0, (name) => this.table(name), (query) => this.#prepareAuthoritativeQuery(partition, query), resolveScope, rejectScope);
839
989
  resolveReady(tx);
840
990
  // Hold the transaction open until commit()/rollback() settles `scope`.
841
991
  await scope;
@@ -853,21 +1003,26 @@ export class PostgresServerStorage {
853
1003
  const { rows } = await this.#exec.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1', [partition]);
854
1004
  return rows[0] === undefined ? 0 : asNumber(rows[0].max_commit_seq);
855
1005
  }
856
- async queryAuthoritative(partition, query) {
1006
+ #prepareAuthoritativeQuery(partition, query) {
857
1007
  if (this.#tables === undefined) {
858
1008
  throw new Error('ensureSchema(schema) must run before registered queries');
859
1009
  }
860
- const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
1010
+ return bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
1011
+ }
1012
+ async queryAuthoritative(partition, query, checkpoints) {
1013
+ const prepared = this.#prepareAuthoritativeQuery(partition, query);
861
1014
  return this.#exec.transaction(async (client) => {
862
1015
  await client.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
863
- const result = await client.query(postgresPlaceholders(prepared.sql), prepared.params);
864
- const cursor = await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1', [partition]);
865
- return {
866
- rows: result.rows,
867
- maxCommitSeq: cursor.rows[0] === undefined
868
- ? 0
869
- : asNumber(cursor.rows[0].max_commit_seq),
870
- };
1016
+ // RFC 0007: evaluate the gate inside this pinned snapshot, so a
1017
+ // migration or an epoch rotation cannot land between the gate read and
1018
+ // the query's own read. The registered-query path uses the pinned
1019
+ // snapshot, not a request-entry check.
1020
+ const runningVersion = this.#schemaVersion ?? 0;
1021
+ const gate = await readServeGateOn(client, partition, runningVersion);
1022
+ const refusal = serveGateRefusal(gate, runningVersion, checkpoints);
1023
+ if (refusal !== undefined)
1024
+ throw serveNotReadyError(refusal);
1025
+ return queryAuthoritativeOn(client, partition, prepared);
871
1026
  });
872
1027
  }
873
1028
  async getPartitionLogEpoch(partition) {
@@ -910,6 +1065,102 @@ export class PostgresServerStorage {
910
1065
  const seq = rows[0]?.seq;
911
1066
  return seq === null || seq === undefined ? 0 : asNumber(seq);
912
1067
  }
1068
+ async readCheckpoints(partition) {
1069
+ const { rows } = await this.#exec.query(`SELECT partition, name, schema_version, state, watermark,
1070
+ owner_epoch, observed_rows, updated_at_ms
1071
+ FROM sync_backfill_checkpoints WHERE partition=$1 ORDER BY name`, [partition]);
1072
+ return rows.map(toStoredCheckpoint);
1073
+ }
1074
+ async readServeGate(partition, runningSchemaVersion) {
1075
+ return readServeGateOn(this.#exec, partition, runningSchemaVersion);
1076
+ }
1077
+ async declareCheckpoint(partition, name, schemaVersion, nowMs) {
1078
+ // One transaction: the checkpoint becomes visible and the fence that
1079
+ // protects it is raised together. The fence is raised at declaration, not
1080
+ // at activation, so old writers are rejected for the whole backfill.
1081
+ //
1082
+ // RFC 0007 lock decision: this transaction writes only the checkpoint and
1083
+ // fence rows, no application rows and no schema marker, so it does not
1084
+ // take the migration table lock or a partition lock. The fence it raises is
1085
+ // enforced by the `sync_commits` trigger on every append from the commit
1086
+ // of this transaction onward; a writer that committed before the fence was
1087
+ // visible is pre-barrier by the design's own "from declaration onward"
1088
+ // rule, not a bypass of it.
1089
+ return this.#exec.transaction(async (client) => {
1090
+ await installCheckpointOn(client, partition, name, schemaVersion, nowMs);
1091
+ const { rows } = await client.query(`SELECT partition, name, schema_version, state, watermark,
1092
+ owner_epoch, observed_rows, updated_at_ms
1093
+ FROM sync_backfill_checkpoints WHERE partition=$1 AND name=$2`, [partition, name]);
1094
+ const row = rows[0];
1095
+ if (row === undefined) {
1096
+ throw new StorageQueryError('sync.storage.checkpoint_not_declared');
1097
+ }
1098
+ return toStoredCheckpoint(row);
1099
+ });
1100
+ }
1101
+ async activateCheckpoint(partition, name, ownerEpoch, watermark, sources, nowMs) {
1102
+ return this.#exec.transaction(async (client) => {
1103
+ // The same `SELECT … FOR UPDATE` partition row lock that
1104
+ // `lockPartitionForPush` takes; a bare `BEGIN` would race a push.
1105
+ await lockPartitionOn(client, partition);
1106
+ const fence = await client.query('SELECT required_writer_version FROM sync_writer_fence WHERE partition=$1', [partition]);
1107
+ const checkpoint = await client.query('SELECT schema_version FROM sync_backfill_checkpoints WHERE partition=$1 AND name=$2', [partition, name]);
1108
+ const fenceRow = fence.rows[0];
1109
+ const checkpointRow = checkpoint.rows[0];
1110
+ if (fenceRow === undefined ||
1111
+ checkpointRow === undefined ||
1112
+ asNumber(fenceRow.required_writer_version) <
1113
+ asNumber(checkpointRow.schema_version)) {
1114
+ // The barrier is not installed. Activating would certify a state old
1115
+ // writers can still decay; refuse instead of silently proceeding.
1116
+ throw new StorageQueryError('sync.storage.checkpoint_fence_missing');
1117
+ }
1118
+ const coverage = await hasSourceChangesAboveOn(client, partition, sources, watermark);
1119
+ if (coverage !== 'clean') {
1120
+ return coverage === 'changed' ? 'stale' : 'unverifiable';
1121
+ }
1122
+ const updated = await client.query(`UPDATE sync_backfill_checkpoints
1123
+ SET state='activated', watermark=$1, updated_at_ms=$2
1124
+ WHERE partition=$3 AND name=$4 AND owner_epoch=$5
1125
+ AND state<>'activated'
1126
+ RETURNING partition`, [watermark, nowMs, partition, name, ownerEpoch]);
1127
+ return updated.rows.length === 1 ? 'activated' : 'stale';
1128
+ });
1129
+ }
1130
+ async claimCheckpoint(partition, name, schemaVersion, nowMs) {
1131
+ // The UPDATE's own row lock serializes concurrent claims; owner_epoch+1
1132
+ // is evaluated under it, so two claimers never observe the same epoch.
1133
+ const { rows } = await this.#exec.query(`UPDATE sync_backfill_checkpoints
1134
+ SET owner_epoch=owner_epoch+1,
1135
+ state=CASE WHEN state='declared' THEN 'backfilling' ELSE state END,
1136
+ schema_version=$1, updated_at_ms=$2
1137
+ WHERE partition=$3 AND name=$4 AND state<>'activated'
1138
+ RETURNING partition, name, schema_version, state, watermark,
1139
+ owner_epoch, observed_rows, updated_at_ms`, [schemaVersion, nowMs, partition, name]);
1140
+ if (rows[0] === undefined) {
1141
+ throw new StorageQueryError('sync.storage.checkpoint_not_declared');
1142
+ }
1143
+ return toStoredCheckpoint(rows[0]);
1144
+ }
1145
+ async advanceCheckpoint(partition, name, ownerEpoch, watermark, observedRows, nowMs) {
1146
+ const result = await this.#exec.query(`UPDATE sync_backfill_checkpoints
1147
+ SET watermark=$1, observed_rows=$2, updated_at_ms=$3
1148
+ WHERE partition=$4 AND name=$5 AND owner_epoch=$6
1149
+ RETURNING name`, [watermark, observedRows, nowMs, partition, name, ownerEpoch]);
1150
+ return result.rows.length === 1;
1151
+ }
1152
+ async sourceCoverageSeq(partition, tables) {
1153
+ if (tables.length === 0)
1154
+ return 0;
1155
+ const tableParams = tables.map((_, index) => `$${index + 2}`).join(',');
1156
+ const { rows } = await this.#exec.query(`SELECT max(commit_seq) AS seq FROM sync_changes
1157
+ WHERE partition=$1 AND tbl IN (${tableParams})`, [partition, ...tables]);
1158
+ const seq = rows[0]?.seq;
1159
+ return seq === null || seq === undefined ? 0 : asNumber(seq);
1160
+ }
1161
+ hasSourceChangesAbove(partition, tables, seq) {
1162
+ return hasSourceChangesAboveOn(this.#exec, partition, tables, seq);
1163
+ }
913
1164
  getRow(partition, table, rowId) {
914
1165
  return getRowOn(this.#exec, this.table(table), partition, rowId);
915
1166
  }