@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,6 +1,8 @@
1
1
  import { validateCommitPruneQuery } from './prune';
2
2
  import { StorageQueryError } from './storage-errors';
3
3
  import type { CommitPruneQuery, CommitPruneResult } from './storage';
4
+ import { serveGateRefusal } from './storage';
5
+ import { serveNotReadyError } from './readiness';
4
6
  /**
5
7
  * SQLite server storage over the shared synchronous driver.
6
8
  *
@@ -16,6 +18,7 @@ import {
16
18
  import { syncError } from './errors';
17
19
  import {
18
20
  assertPhysicalColumns,
21
+ assertStoredLayouts,
19
22
  commitWindowPageSql,
20
23
  deleteRowSql,
21
24
  deleteSqliteRowScopesSql,
@@ -43,6 +46,7 @@ import { matchesEffective } from './scopes';
43
46
  import {
44
47
  collectCommitWindowPage,
45
48
  deserializePushResult,
49
+ placeholders,
46
50
  SQLITE_DDL,
47
51
  type SqliteCommitWindowRecord,
48
52
  type SqliteRowRecord,
@@ -57,6 +61,7 @@ import type {
57
61
  AuthoritativeQueryRequest,
58
62
  AuthoritativeQueryResult,
59
63
  AuthoritativeQueryValue,
64
+ CheckpointDeclaration,
60
65
  ClientCursorInfo,
61
66
  ClientRecord,
62
67
  ClientSubscription,
@@ -78,8 +83,10 @@ import type {
78
83
  ScopeActivityQuery,
79
84
  ScopeCommitActivity,
80
85
  ServerStorage,
86
+ ServeGate,
81
87
  StorageTransaction,
82
88
  StoredCommit,
89
+ StoredCheckpoint,
83
90
  StoredPushResult,
84
91
  StoredReaction,
85
92
  StoredRow,
@@ -90,6 +97,30 @@ import {
90
97
  } from './storage-errors';
91
98
  import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query';
92
99
 
100
+ interface SqliteCheckpointRecord {
101
+ partition: string;
102
+ name: string;
103
+ schema_version: number;
104
+ state: StoredCheckpoint['state'];
105
+ watermark: number;
106
+ owner_epoch: number;
107
+ observed_rows: number;
108
+ updated_at_ms: number;
109
+ }
110
+
111
+ function toStoredCheckpoint(record: SqliteCheckpointRecord): StoredCheckpoint {
112
+ return {
113
+ partition: record.partition,
114
+ name: record.name,
115
+ schemaVersion: record.schema_version,
116
+ state: record.state,
117
+ watermark: record.watermark,
118
+ ownerEpoch: record.owner_epoch,
119
+ observedRows: record.observed_rows,
120
+ updatedAtMs: record.updated_at_ms,
121
+ };
122
+ }
123
+
93
124
  interface SqliteReactionRecord {
94
125
  partition: string;
95
126
  idempotency_key: string;
@@ -140,6 +171,8 @@ function toStoredReaction(record: SqliteReactionRecord): StoredReaction {
140
171
  class SqliteTransaction implements StorageTransaction {
141
172
  #storage: SqliteServerStorage;
142
173
  #partition: string;
174
+ /** Schema version this transaction writes; rides every appended commit row. */
175
+ #writerVersion: number;
143
176
  #open = true;
144
177
  #pushApplySavepoint = false;
145
178
  readonly #release: () => void;
@@ -147,10 +180,12 @@ class SqliteTransaction implements StorageTransaction {
147
180
  constructor(
148
181
  storage: SqliteServerStorage,
149
182
  partition: string,
183
+ writerVersion: number,
150
184
  release: () => void,
151
185
  ) {
152
186
  this.#storage = storage;
153
187
  this.#partition = partition;
188
+ this.#writerVersion = writerVersion;
154
189
  this.#release = release;
155
190
  storage.db.exec('BEGIN IMMEDIATE');
156
191
  }
@@ -210,6 +245,15 @@ class SqliteTransaction implements StorageTransaction {
210
245
  return this.#storage.scanRowsByIndex(this.#partition, query);
211
246
  }
212
247
 
248
+ async queryAuthoritative(
249
+ query: AuthoritativeQueryRequest,
250
+ ): Promise<AuthoritativeQueryResult> {
251
+ this.#assertOpen();
252
+ // Same shared connection: the query runs inside this transaction's
253
+ // BEGIN IMMEDIATE and observes every row staged so far.
254
+ return this.#storage.runAuthoritativeQuery(this.#partition, query);
255
+ }
256
+
213
257
  async lockPartitionForPush(): Promise<void> {
214
258
  this.#assertOpen();
215
259
  // BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
@@ -217,6 +261,31 @@ class SqliteTransaction implements StorageTransaction {
217
261
  this.#pushApplySavepoint = true;
218
262
  }
219
263
 
264
+ async advanceCheckpoint(
265
+ name: string,
266
+ ownerEpoch: number,
267
+ watermark: number,
268
+ observedRows: number,
269
+ nowMs: number,
270
+ ): Promise<boolean> {
271
+ this.#assertOpen();
272
+ const result = this.#storage.db
273
+ .query(
274
+ `UPDATE sync_backfill_checkpoints
275
+ SET watermark=?, observed_rows=?, updated_at_ms=?
276
+ WHERE partition=? AND name=? AND owner_epoch=?`,
277
+ )
278
+ .run(watermark, observedRows, nowMs, this.#partition, name, ownerEpoch);
279
+ return Number(result.changes) === 1;
280
+ }
281
+
282
+ readServeGate(runningSchemaVersion: number): Promise<ServeGate> {
283
+ this.#assertOpen();
284
+ // Same shared connection: this read runs inside this transaction's
285
+ // BEGIN IMMEDIATE, so the gate and the write share one transaction.
286
+ return this.#storage.readServeGate(this.#partition, runningSchemaVersion);
287
+ }
288
+
220
289
  async commitRejectedPushResult(
221
290
  clientId: string,
222
291
  clientCommitId: string,
@@ -302,7 +371,7 @@ class SqliteTransaction implements StorageTransaction {
302
371
  'UPDATE sync_partitions SET max_commit_seq=? WHERE partition=?',
303
372
  ).run(commitSeq, p);
304
373
  db.query(
305
- 'INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms) VALUES (?,?,?,?,?,?)',
374
+ 'INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms, writer_version) VALUES (?,?,?,?,?,?,?)',
306
375
  ).run(
307
376
  p,
308
377
  commitSeq,
@@ -310,6 +379,7 @@ class SqliteTransaction implements StorageTransaction {
310
379
  commit.clientCommitId,
311
380
  commit.actorId,
312
381
  commit.createdAtMs,
382
+ this.#writerVersion,
313
383
  );
314
384
  commit.changes.forEach((change, idx) => {
315
385
  db.query(
@@ -455,6 +525,32 @@ export class SqliteServerStorage implements ServerStorage {
455
525
  'ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1',
456
526
  );
457
527
  }
528
+ const commitColumns = this.db
529
+ .query<{ name: string }, []>('PRAGMA table_info("sync_commits")')
530
+ .all();
531
+ if (!commitColumns.some((column) => column.name === 'writer_version')) {
532
+ // Nullable by design: an old writer omits the column and the trigger's
533
+ // explicit NULL test rejects it. `ADD COLUMN NOT NULL` is unavailable in
534
+ // SQLite, and a default would hand old writers a passing value.
535
+ this.db.exec(
536
+ 'ALTER TABLE sync_commits ADD COLUMN writer_version INTEGER',
537
+ );
538
+ }
539
+ // The fence is database-side: an old binary's INSERT never reaches JS. An
540
+ // absent `sync_writer_fence` row makes the WHEN clause false, so a
541
+ // partition with no barrier behaves exactly as before.
542
+ this.db.exec(`CREATE TRIGGER IF NOT EXISTS sync_commits_writer_fence
543
+ BEFORE INSERT ON sync_commits
544
+ FOR EACH ROW
545
+ WHEN EXISTS (
546
+ SELECT 1 FROM sync_writer_fence
547
+ WHERE partition = NEW.partition
548
+ AND (NEW.writer_version IS NULL
549
+ OR NEW.writer_version < required_writer_version)
550
+ )
551
+ BEGIN
552
+ SELECT RAISE(ABORT, 'sync.storage.writer_fence_rejected');
553
+ END`);
458
554
  }
459
555
 
460
556
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
@@ -468,9 +564,34 @@ export class SqliteServerStorage implements ServerStorage {
468
564
  return table;
469
565
  }
470
566
 
471
- async ensureSchema(schema: CompiledSchema): Promise<void> {
567
+ async ensureSchema(
568
+ schema: CompiledSchema,
569
+ checkpoints?: readonly CheckpointDeclaration[],
570
+ ): Promise<void> {
472
571
  // Memoized fast path: same instance, same schema version.
473
572
  if (this.#schemaVersion === schema.version) return;
573
+ // The barrier reads stored state: a checkpoint at this schema version that
574
+ // is not activated and that this caller did not declare means this process
575
+ // must not serve. An omitted or empty declaration set declares nothing and
576
+ // is refused here, never treated as a bypass.
577
+ const declarations = checkpoints ?? [];
578
+ const incomplete = this.db
579
+ .query<{ partition: string; name: string }, [number]>(
580
+ `SELECT partition, name FROM sync_backfill_checkpoints
581
+ WHERE schema_version=? AND state<>'activated'`,
582
+ )
583
+ .all(schema.version)
584
+ .filter(
585
+ (row) =>
586
+ !declarations.some(
587
+ (declaration) =>
588
+ declaration.partition === row.partition &&
589
+ declaration.name === row.name,
590
+ ),
591
+ );
592
+ if (incomplete.length > 0) {
593
+ throw new StorageQueryError('sync.storage.checkpoint_incomplete');
594
+ }
474
595
  this.db.exec(SCHEMA_META_DDL_SQLITE);
475
596
  const marker = this.db
476
597
  .query<{ schema_version: number; layouts: string }, []>(
@@ -483,57 +604,25 @@ export class SqliteServerStorage implements ServerStorage {
483
604
  );
484
605
  }
485
606
  if (marker !== null && marker.schema_version === schema.version) {
486
- // Version equality is not layout equality: a marker written by another
487
- // build at the same version describes rows the running codec cannot
488
- // decode. Compare the stored layouts instead of trusting the number.
489
- const storedLayouts = parseLayouts(marker.layouts);
490
- const configuredLayouts = parseLayouts(layoutsOf(schema));
491
- let mismatch: string | undefined;
492
- for (const [tableName, columns] of Object.entries(configuredLayouts)) {
493
- const stored = storedLayouts[tableName];
494
- const columnCount = Math.max(columns.length, stored?.length ?? 0);
495
- for (let index = 0; index < columnCount; index++) {
496
- const expected = columns[index];
497
- const actual = stored?.[index];
498
- if (
499
- expected !== undefined &&
500
- actual !== undefined &&
501
- actual.name === expected.name &&
502
- actual.type === expected.type &&
503
- actual.nullable === expected.nullable
504
- ) {
505
- continue;
506
- }
507
- mismatch = `table ${JSON.stringify(tableName)} column ${JSON.stringify(expected?.name ?? actual?.name ?? '')}`;
508
- break;
509
- }
510
- if (mismatch !== undefined) break;
511
- }
512
- if (mismatch === undefined) {
513
- for (const tableName of Object.keys(storedLayouts)) {
514
- if (!(tableName in configuredLayouts)) {
515
- mismatch = `table ${JSON.stringify(tableName)}`;
516
- break;
517
- }
518
- }
519
- }
520
- if (mismatch !== undefined) {
521
- throw new Error(
522
- `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`,
523
- );
524
- }
525
- // The persisted layouts describe the codec's app columns only: read the
526
- // physical tables so a same-version database missing a
527
- // storage-internal column is refused at startup instead of failing at
528
- // the first write.
607
+ assertStoredLayouts(schema, marker.layouts);
529
608
  for (const table of schema.tables.values()) {
530
609
  const escapedTableName = table.name.replaceAll('"', '""');
531
610
  const columns = this.db
532
- .query<{ name: string }, []>(
533
- `PRAGMA table_info("${escapedTableName}")`,
534
- )
611
+ .query<
612
+ { name: string; type: string; notnull: number; pk: number },
613
+ []
614
+ >(`PRAGMA table_info("${escapedTableName}")`)
535
615
  .all();
536
- assertPhysicalColumns(table, new Set(columns.map((c) => c.name)));
616
+ assertPhysicalColumns(
617
+ table,
618
+ columns.map((column) => ({
619
+ name: column.name,
620
+ type: column.type,
621
+ notNull: column.notnull === 1,
622
+ primaryKeyPosition: column.pk,
623
+ })),
624
+ 'sqlite',
625
+ );
537
626
  }
538
627
  }
539
628
  if (marker === null || marker.schema_version < schema.version) {
@@ -567,41 +656,64 @@ export class SqliteServerStorage implements ServerStorage {
567
656
  );
568
657
  }
569
658
  }
570
- this.db.exec('BEGIN IMMEDIATE');
571
- try {
572
- for (const tableName of retiredTables) {
573
- this.db
574
- .query('DELETE FROM sync_row_scopes WHERE tbl=?')
575
- .run(tableName);
659
+ // The migration rewrites application rows (`#rewriteRows`) with no
660
+ // commit log entry. Serialize it through the same writer queue as
661
+ // pushes and prune (`#serializeWrite`) rather than trusting the
662
+ // migration as privileged; `BEGIN IMMEDIATE` then owns SQLite's writer
663
+ // lock for the whole rewrite.
664
+ await this.#serializeWrite(() => {
665
+ this.db.exec('BEGIN IMMEDIATE');
666
+ try {
667
+ for (const tableName of retiredTables) {
668
+ this.db
669
+ .query('DELETE FROM sync_row_scopes WHERE tbl=?')
670
+ .run(tableName);
671
+ this.db
672
+ .query('DELETE FROM sync_blob_refs WHERE tbl=?')
673
+ .run(tableName);
674
+ this.db.exec(dropTableDdl(tableName));
675
+ }
676
+ for (const statement of schemaDdl(
677
+ schema,
678
+ existing,
679
+ 'sqlite',
680
+ existingIndexes,
681
+ )) {
682
+ this.db.exec(statement);
683
+ }
684
+ for (const table of schema.tables.values()) {
685
+ const oldLayout = layouts[table.name];
686
+ const plan = rewritePlan(
687
+ table,
688
+ oldLayout,
689
+ existing.get(table.name),
690
+ );
691
+ if (!plan.migrate && !plan.backfill) continue;
692
+ this.#rewriteRows(table, plan.migrate ? oldLayout : undefined);
693
+ }
576
694
  this.db
577
- .query('DELETE FROM sync_blob_refs WHERE tbl=?')
578
- .run(tableName);
579
- this.db.exec(dropTableDdl(tableName));
580
- }
581
- for (const statement of schemaDdl(
582
- schema,
583
- existing,
584
- 'sqlite',
585
- existingIndexes,
586
- )) {
587
- this.db.exec(statement);
588
- }
589
- for (const table of schema.tables.values()) {
590
- const oldLayout = layouts[table.name];
591
- const plan = rewritePlan(table, oldLayout, existing.get(table.name));
592
- if (!plan.migrate && !plan.backfill) continue;
593
- this.#rewriteRows(table, plan.migrate ? oldLayout : undefined);
695
+ .query(
696
+ 'INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET schema_version=excluded.schema_version, layouts=excluded.layouts',
697
+ )
698
+ .run(schema.version, layoutsOf(schema));
699
+ // The host's declared checkpoints install in the schema-bump
700
+ // transaction, so no observer sees the bumped marker without the
701
+ // fence, or the fence without its declaration row.
702
+ const installedAtMs = Date.now();
703
+ for (const declaration of checkpoints ?? []) {
704
+ this.#installCheckpoint(
705
+ declaration.partition,
706
+ declaration.name,
707
+ declaration.schemaVersion,
708
+ installedAtMs,
709
+ );
710
+ }
711
+ this.db.exec('COMMIT');
712
+ } catch (error) {
713
+ this.db.exec('ROLLBACK');
714
+ throw error;
594
715
  }
595
- this.db
596
- .query(
597
- 'INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET schema_version=excluded.schema_version, layouts=excluded.layouts',
598
- )
599
- .run(schema.version, layoutsOf(schema));
600
- this.db.exec('COMMIT');
601
- } catch (error) {
602
- this.db.exec('ROLLBACK');
603
- throw error;
604
- }
716
+ });
605
717
  }
606
718
  this.#tables = schema.tables;
607
719
  this.#schemaVersion = schema.version;
@@ -764,7 +876,12 @@ export class SqliteServerStorage implements ServerStorage {
764
876
  });
765
877
  await previous;
766
878
  try {
767
- return new SqliteTransaction(this, partition, release);
879
+ return new SqliteTransaction(
880
+ this,
881
+ partition,
882
+ this.#schemaVersion ?? 0,
883
+ release,
884
+ );
768
885
  } catch (error) {
769
886
  release();
770
887
  throw error;
@@ -806,10 +923,16 @@ export class SqliteServerStorage implements ServerStorage {
806
923
  return row?.max_commit_seq ?? 0;
807
924
  }
808
925
 
809
- async queryAuthoritative(
926
+ /**
927
+ * Internal: rewrite and run one registered query on the shared connection.
928
+ * The caller owns the transaction: `queryAuthoritative` opens a read
929
+ * transaction behind the FIFO; `SqliteTransaction.queryAuthoritative` runs
930
+ * inside the push transaction's `BEGIN IMMEDIATE`.
931
+ */
932
+ runAuthoritativeQuery(
810
933
  partition: string,
811
934
  query: AuthoritativeQueryRequest,
812
- ): Promise<AuthoritativeQueryResult> {
935
+ ): AuthoritativeQueryResult {
813
936
  if (this.#tables === undefined) {
814
937
  throw new Error(
815
938
  'ensureSchema(schema) must run before registered queries',
@@ -824,6 +947,24 @@ export class SqliteServerStorage implements ServerStorage {
824
947
  ),
825
948
  partition,
826
949
  );
950
+ const rows = this.db
951
+ .query<Readonly<Record<string, unknown>>, AuthoritativeQueryValue[]>(
952
+ prepared.sql,
953
+ )
954
+ .all(...prepared.params);
955
+ const cursor = this.db
956
+ .query<{ max_commit_seq: number }, [string]>(
957
+ 'SELECT max_commit_seq FROM sync_partitions WHERE partition=?',
958
+ )
959
+ .get(partition);
960
+ return { rows, maxCommitSeq: cursor?.max_commit_seq ?? 0 };
961
+ }
962
+
963
+ async queryAuthoritative(
964
+ partition: string,
965
+ query: AuthoritativeQueryRequest,
966
+ checkpoints?: readonly CheckpointDeclaration[],
967
+ ): Promise<AuthoritativeQueryResult> {
827
968
  const previous = this.#transactionTail;
828
969
  let release!: () => void;
829
970
  this.#transactionTail = new Promise<void>((resolve) => {
@@ -834,19 +975,17 @@ export class SqliteServerStorage implements ServerStorage {
834
975
  try {
835
976
  this.db.exec('BEGIN');
836
977
  open = true;
837
- const rows = this.db
838
- .query<Readonly<Record<string, unknown>>, AuthoritativeQueryValue[]>(
839
- prepared.sql,
840
- )
841
- .all(...prepared.params);
842
- const cursor = this.db
843
- .query<{ max_commit_seq: number }, [string]>(
844
- 'SELECT max_commit_seq FROM sync_partitions WHERE partition=?',
845
- )
846
- .get(partition);
978
+ // RFC 0007: evaluate the gate on this connection after BEGIN. SQLite is
979
+ // single-writer and the shared connection serializes writers, so the
980
+ // gate read and the query read cannot straddle a migration.
981
+ const runningVersion = this.#schemaVersion ?? 0;
982
+ const gate = await this.readServeGate(partition, runningVersion);
983
+ const refusal = serveGateRefusal(gate, runningVersion, checkpoints);
984
+ if (refusal !== undefined) throw serveNotReadyError(refusal);
985
+ const result = this.runAuthoritativeQuery(partition, query);
847
986
  this.db.exec('COMMIT');
848
987
  open = false;
849
- return { rows, maxCommitSeq: cursor?.max_commit_seq ?? 0 };
988
+ return result;
850
989
  } catch (error) {
851
990
  if (open) this.db.exec('ROLLBACK');
852
991
  throw error;
@@ -949,6 +1088,262 @@ export class SqliteServerStorage implements ServerStorage {
949
1088
  return row?.seq ?? 0;
950
1089
  }
951
1090
 
1091
+ async readServeGate(
1092
+ partition: string,
1093
+ runningSchemaVersion: number,
1094
+ ): Promise<ServeGate> {
1095
+ const marker = this.db
1096
+ .query<{ schema_version: number }, []>(
1097
+ 'SELECT schema_version FROM sync_schema_meta WHERE id=1',
1098
+ )
1099
+ .get();
1100
+ const epoch = this.db
1101
+ .query<{ log_epoch: string }, [string]>(
1102
+ 'SELECT log_epoch FROM sync_partition_registry WHERE partition=?',
1103
+ )
1104
+ .get(partition);
1105
+ const pending = this.db
1106
+ .query<{ partition: string; name: string }, [number]>(
1107
+ `SELECT partition, name FROM sync_backfill_checkpoints
1108
+ WHERE schema_version=? AND state<>'activated'`,
1109
+ )
1110
+ .all(runningSchemaVersion);
1111
+ return {
1112
+ storedSchemaVersion: marker?.schema_version ?? 0,
1113
+ logEpoch: epoch?.log_epoch,
1114
+ pending: pending.map((row) => ({
1115
+ partition: row.partition,
1116
+ name: row.name,
1117
+ })),
1118
+ };
1119
+ }
1120
+
1121
+ async readCheckpoints(partition: string): Promise<StoredCheckpoint[]> {
1122
+ return this.db
1123
+ .query<SqliteCheckpointRecord, [string]>(
1124
+ `SELECT partition, name, schema_version, state, watermark,
1125
+ owner_epoch, observed_rows, updated_at_ms
1126
+ FROM sync_backfill_checkpoints WHERE partition=? ORDER BY name`,
1127
+ )
1128
+ .all(partition)
1129
+ .map(toStoredCheckpoint);
1130
+ }
1131
+
1132
+ /**
1133
+ * Insert the `declared` row and raise the fence, inside the caller's open
1134
+ * transaction. Shared by `declareCheckpoint` and the `ensureSchema`
1135
+ * migration so the schema bump and the fence commit together. Never lowers a
1136
+ * fence and never re-declares an existing row.
1137
+ */
1138
+ #installCheckpoint(
1139
+ partition: string,
1140
+ name: string,
1141
+ schemaVersion: number,
1142
+ nowMs: number,
1143
+ ): void {
1144
+ this.db
1145
+ .query(
1146
+ `INSERT INTO sync_backfill_checkpoints(
1147
+ partition, name, schema_version, state, watermark,
1148
+ owner_epoch, observed_rows, updated_at_ms)
1149
+ VALUES (?,?,?,'declared',0,0,0,?)
1150
+ ON CONFLICT(partition, name) DO NOTHING`,
1151
+ )
1152
+ .run(partition, name, schemaVersion, nowMs);
1153
+ this.db
1154
+ .query(
1155
+ `INSERT INTO sync_writer_fence(partition, required_writer_version)
1156
+ VALUES (?,?)
1157
+ ON CONFLICT(partition) DO UPDATE SET
1158
+ required_writer_version=max(
1159
+ sync_writer_fence.required_writer_version,
1160
+ excluded.required_writer_version)`,
1161
+ )
1162
+ .run(partition, schemaVersion);
1163
+ }
1164
+
1165
+ async declareCheckpoint(
1166
+ partition: string,
1167
+ name: string,
1168
+ schemaVersion: number,
1169
+ nowMs: number,
1170
+ ): Promise<StoredCheckpoint> {
1171
+ // One transaction: the checkpoint becomes visible and the fence that
1172
+ // protects it is raised together. The fence is raised at declaration, not
1173
+ // at activation, so old writers are rejected for the whole backfill.
1174
+ return this.#serializeWrite(() => {
1175
+ this.db.exec('BEGIN IMMEDIATE');
1176
+ try {
1177
+ this.#installCheckpoint(partition, name, schemaVersion, nowMs);
1178
+ const row = this.db
1179
+ .query<SqliteCheckpointRecord, [string, string]>(
1180
+ `SELECT partition, name, schema_version, state, watermark,
1181
+ owner_epoch, observed_rows, updated_at_ms
1182
+ FROM sync_backfill_checkpoints WHERE partition=? AND name=?`,
1183
+ )
1184
+ .get(partition, name);
1185
+ if (row === null)
1186
+ throw new StorageQueryError('sync.storage.checkpoint_not_declared');
1187
+ this.db.exec('COMMIT');
1188
+ return toStoredCheckpoint(row);
1189
+ } catch (error) {
1190
+ this.db.exec('ROLLBACK');
1191
+ throw error;
1192
+ }
1193
+ });
1194
+ }
1195
+
1196
+ async activateCheckpoint(
1197
+ partition: string,
1198
+ name: string,
1199
+ ownerEpoch: number,
1200
+ watermark: number,
1201
+ sources: readonly string[],
1202
+ nowMs: number,
1203
+ ): Promise<'activated' | 'stale' | 'unverifiable'> {
1204
+ return this.#serializeWrite(() => {
1205
+ // `BEGIN IMMEDIATE` is SQLite's partition write lock; the same writer
1206
+ // lock a push takes. Nothing may observe activation without the fence.
1207
+ this.db.exec('BEGIN IMMEDIATE');
1208
+ try {
1209
+ const fence = this.db
1210
+ .query<{ required_writer_version: number }, [string]>(
1211
+ 'SELECT required_writer_version FROM sync_writer_fence WHERE partition=?',
1212
+ )
1213
+ .get(partition);
1214
+ const checkpoint = this.db
1215
+ .query<{ schema_version: number }, [string, string]>(
1216
+ 'SELECT schema_version FROM sync_backfill_checkpoints WHERE partition=? AND name=?',
1217
+ )
1218
+ .get(partition, name);
1219
+ if (
1220
+ fence === null ||
1221
+ checkpoint === null ||
1222
+ fence.required_writer_version < checkpoint.schema_version
1223
+ ) {
1224
+ // The barrier is not installed. Activating would certify a state old
1225
+ // writers can still decay; refuse instead of silently proceeding.
1226
+ throw new StorageQueryError('sync.storage.checkpoint_fence_missing');
1227
+ }
1228
+ const coverage = this.#hasSourceChangesAbove(
1229
+ partition,
1230
+ sources,
1231
+ watermark,
1232
+ );
1233
+ if (coverage !== 'clean') {
1234
+ this.db.exec('ROLLBACK');
1235
+ return coverage === 'changed' ? 'stale' : 'unverifiable';
1236
+ }
1237
+ const updated = this.db
1238
+ .query(
1239
+ `UPDATE sync_backfill_checkpoints
1240
+ SET state='activated', watermark=?, updated_at_ms=?
1241
+ WHERE partition=? AND name=? AND owner_epoch=?
1242
+ AND state<>'activated'`,
1243
+ )
1244
+ .run(watermark, nowMs, partition, name, ownerEpoch);
1245
+ this.db.exec('COMMIT');
1246
+ return Number(updated.changes) === 1 ? 'activated' : 'stale';
1247
+ } catch (error) {
1248
+ this.db.exec('ROLLBACK');
1249
+ throw error;
1250
+ }
1251
+ });
1252
+ }
1253
+
1254
+ async claimCheckpoint(
1255
+ partition: string,
1256
+ name: string,
1257
+ schemaVersion: number,
1258
+ nowMs: number,
1259
+ ): Promise<StoredCheckpoint> {
1260
+ return this.#serializeWrite(() => {
1261
+ const claimed = this.db
1262
+ .query<SqliteCheckpointRecord, (string | number)[]>(
1263
+ `UPDATE sync_backfill_checkpoints
1264
+ SET owner_epoch=owner_epoch+1,
1265
+ state=CASE WHEN state='declared' THEN 'backfilling' ELSE state END,
1266
+ schema_version=?, updated_at_ms=?
1267
+ WHERE partition=? AND name=? AND state<>'activated'
1268
+ RETURNING partition, name, schema_version, state, watermark,
1269
+ owner_epoch, observed_rows, updated_at_ms`,
1270
+ )
1271
+ .get(schemaVersion, nowMs, partition, name);
1272
+ if (claimed === null) {
1273
+ throw new StorageQueryError('sync.storage.checkpoint_not_declared');
1274
+ }
1275
+ return toStoredCheckpoint(claimed);
1276
+ });
1277
+ }
1278
+
1279
+ async advanceCheckpoint(
1280
+ partition: string,
1281
+ name: string,
1282
+ ownerEpoch: number,
1283
+ watermark: number,
1284
+ observedRows: number,
1285
+ nowMs: number,
1286
+ ): Promise<boolean> {
1287
+ return this.#serializeWrite(() => {
1288
+ const result = this.db
1289
+ .query(
1290
+ `UPDATE sync_backfill_checkpoints
1291
+ SET watermark=?, observed_rows=?, updated_at_ms=?
1292
+ WHERE partition=? AND name=? AND owner_epoch=?`,
1293
+ )
1294
+ .run(watermark, observedRows, nowMs, partition, name, ownerEpoch);
1295
+ return Number(result.changes) === 1;
1296
+ });
1297
+ }
1298
+
1299
+ async sourceCoverageSeq(
1300
+ partition: string,
1301
+ tables: readonly string[],
1302
+ ): Promise<number> {
1303
+ if (tables.length === 0) return 0;
1304
+ const row = this.db
1305
+ .query<{ seq: number | null }, (string | number)[]>(
1306
+ `SELECT max(commit_seq) AS seq FROM sync_changes
1307
+ WHERE partition=? AND tbl IN (${placeholders(tables.length)})`,
1308
+ )
1309
+ .get(partition, ...tables);
1310
+ return row?.seq ?? 0;
1311
+ }
1312
+
1313
+ #hasSourceChangesAbove(
1314
+ partition: string,
1315
+ tables: readonly string[],
1316
+ seq: number,
1317
+ ): 'clean' | 'changed' | 'unverifiable' {
1318
+ // Read the horizon before the window scan: a horizon past `seq` means the
1319
+ // history that would answer the question is gone, so an empty scan proves
1320
+ // nothing and must not be reported as `clean`.
1321
+ const horizon =
1322
+ this.db
1323
+ .query<{ horizon_seq: number }, [string]>(
1324
+ 'SELECT horizon_seq FROM sync_partitions WHERE partition=?',
1325
+ )
1326
+ .get(partition)?.horizon_seq ?? 0;
1327
+ if (tables.length === 0) return 'clean';
1328
+ const hit = this.db
1329
+ .query<{ hit: number }, (string | number)[]>(
1330
+ `SELECT 1 AS hit FROM sync_changes
1331
+ WHERE partition=? AND tbl IN (${placeholders(tables.length)})
1332
+ AND commit_seq>? LIMIT 1`,
1333
+ )
1334
+ .get(partition, ...tables, seq);
1335
+ if (hit !== null && hit !== undefined) return 'changed';
1336
+ return horizon > seq ? 'unverifiable' : 'clean';
1337
+ }
1338
+
1339
+ async hasSourceChangesAbove(
1340
+ partition: string,
1341
+ tables: readonly string[],
1342
+ seq: number,
1343
+ ): Promise<'clean' | 'changed' | 'unverifiable'> {
1344
+ return this.#hasSourceChangesAbove(partition, tables, seq);
1345
+ }
1346
+
952
1347
  async getRow(
953
1348
  partition: string,
954
1349
  table: string,