@syncular/server 0.21.1 → 0.23.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 (66) hide show
  1. package/README.md +20 -6
  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 +84 -2
  8. package/dist/errors.js +10 -0
  9. package/dist/handler.js +50 -4
  10. package/dist/operations.js +3 -3
  11. package/dist/pg-executor.d.ts +11 -1
  12. package/dist/postgres-storage.d.ts +20 -5
  13. package/dist/postgres-storage.js +345 -9
  14. package/dist/pull.js +9 -5
  15. package/dist/push.js +18 -0
  16. package/dist/readiness.d.ts +9 -2
  17. package/dist/readiness.js +27 -2
  18. package/dist/realtime.d.ts +40 -1
  19. package/dist/realtime.js +116 -19
  20. package/dist/relational-rows.d.ts +20 -0
  21. package/dist/relational-rows.js +29 -0
  22. package/dist/s3-segment-store.d.ts +20 -8
  23. package/dist/s3-segment-store.js +216 -68
  24. package/dist/schema.js +11 -0
  25. package/dist/segment-download.d.ts +1 -1
  26. package/dist/segment-download.js +50 -17
  27. package/dist/segment-store.d.ts +86 -0
  28. package/dist/segment-store.js +156 -17
  29. package/dist/signed-url.d.ts +7 -1
  30. package/dist/signed-url.js +10 -1
  31. package/dist/sqlite-dialect.d.ts +7 -1
  32. package/dist/sqlite-dialect.js +23 -0
  33. package/dist/sqlite-segment-store.js +153 -42
  34. package/dist/sqlite-storage.d.ts +11 -3
  35. package/dist/sqlite-storage.js +346 -34
  36. package/dist/storage-errors.d.ts +1 -1
  37. package/dist/storage-errors.js +4 -0
  38. package/dist/storage.d.ts +152 -2
  39. package/dist/storage.js +40 -1
  40. package/dist/validate.d.ts +20 -1
  41. package/package.json +2 -2
  42. package/src/admin.ts +13 -1
  43. package/src/blob-handlers.ts +1 -1
  44. package/src/context.ts +8 -0
  45. package/src/d1-storage.ts +143 -1
  46. package/src/errors.ts +10 -0
  47. package/src/handler.ts +62 -4
  48. package/src/operations.ts +17 -13
  49. package/src/pg-executor.ts +11 -1
  50. package/src/postgres-storage.ts +520 -5
  51. package/src/pull.ts +9 -1
  52. package/src/push.ts +18 -0
  53. package/src/readiness.ts +39 -4
  54. package/src/realtime.ts +117 -18
  55. package/src/relational-rows.ts +36 -0
  56. package/src/s3-segment-store.ts +275 -84
  57. package/src/schema.ts +15 -0
  58. package/src/segment-download.ts +79 -28
  59. package/src/segment-store.ts +250 -18
  60. package/src/signed-url.ts +12 -2
  61. package/src/sqlite-dialect.ts +23 -0
  62. package/src/sqlite-segment-store.ts +245 -65
  63. package/src/sqlite-storage.ts +494 -36
  64. package/src/storage-errors.ts +13 -1
  65. package/src/storage.ts +243 -1
  66. package/src/validate.ts +20 -1
@@ -1,5 +1,7 @@
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
  /**
4
6
  * SQLite server storage over the shared synchronous driver.
5
7
  *
@@ -10,12 +12,24 @@ import { StorageQueryError } from './storage-errors.js';
10
12
  */
11
13
  import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
12
14
  import { syncError } from './errors.js';
13
- import { commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
15
+ import { assertPhysicalColumns, commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
14
16
  import { matchesEffective } from './scopes.js';
15
- import { collectCommitWindowPage, deserializePushResult, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
17
+ import { collectCommitWindowPage, deserializePushResult, placeholders, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
16
18
  import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
17
19
  import { isSqliteConstraintError, StorageConstraintError, } from './storage-errors.js';
18
20
  import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
21
+ function toStoredCheckpoint(record) {
22
+ return {
23
+ partition: record.partition,
24
+ name: record.name,
25
+ schemaVersion: record.schema_version,
26
+ state: record.state,
27
+ watermark: record.watermark,
28
+ ownerEpoch: record.owner_epoch,
29
+ observedRows: record.observed_rows,
30
+ updatedAtMs: record.updated_at_ms,
31
+ };
32
+ }
19
33
  function toStoredReaction(record) {
20
34
  return {
21
35
  idempotencyKey: record.idempotency_key,
@@ -45,12 +59,15 @@ function toStoredReaction(record) {
45
59
  class SqliteTransaction {
46
60
  #storage;
47
61
  #partition;
62
+ /** Schema version this transaction writes; rides every appended commit row. */
63
+ #writerVersion;
48
64
  #open = true;
49
65
  #pushApplySavepoint = false;
50
66
  #release;
51
- constructor(storage, partition, release) {
67
+ constructor(storage, partition, writerVersion, release) {
52
68
  this.#storage = storage;
53
69
  this.#partition = partition;
70
+ this.#writerVersion = writerVersion;
54
71
  this.#release = release;
55
72
  storage.db.exec('BEGIN IMMEDIATE');
56
73
  }
@@ -95,6 +112,21 @@ class SqliteTransaction {
95
112
  this.#storage.db.exec('SAVEPOINT syncular_push_candidate');
96
113
  this.#pushApplySavepoint = true;
97
114
  }
115
+ async advanceCheckpoint(name, ownerEpoch, watermark, observedRows, nowMs) {
116
+ this.#assertOpen();
117
+ const result = this.#storage.db
118
+ .query(`UPDATE sync_backfill_checkpoints
119
+ SET watermark=?, observed_rows=?, updated_at_ms=?
120
+ WHERE partition=? AND name=? AND owner_epoch=?`)
121
+ .run(watermark, observedRows, nowMs, this.#partition, name, ownerEpoch);
122
+ return Number(result.changes) === 1;
123
+ }
124
+ readServeGate(runningSchemaVersion) {
125
+ this.#assertOpen();
126
+ // Same shared connection: this read runs inside this transaction's
127
+ // BEGIN IMMEDIATE, so the gate and the write share one transaction.
128
+ return this.#storage.readServeGate(this.#partition, runningSchemaVersion);
129
+ }
98
130
  async commitRejectedPushResult(clientId, clientCommitId, result) {
99
131
  this.#assertOpen();
100
132
  if (!this.#pushApplySavepoint) {
@@ -146,7 +178,7 @@ class SqliteTransaction {
146
178
  .get(p);
147
179
  const commitSeq = (row?.max_commit_seq ?? 0) + 1;
148
180
  db.query('UPDATE sync_partitions SET max_commit_seq=? WHERE partition=?').run(commitSeq, p);
149
- db.query('INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms) VALUES (?,?,?,?,?,?)').run(p, commitSeq, commit.clientId, commit.clientCommitId, commit.actorId, commit.createdAtMs);
181
+ db.query('INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms, writer_version) VALUES (?,?,?,?,?,?,?)').run(p, commitSeq, commit.clientId, commit.clientCommitId, commit.actorId, commit.createdAtMs, this.#writerVersion);
150
182
  commit.changes.forEach((change, idx) => {
151
183
  db.query('INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload) VALUES (?,?,?,?,?,?,?,?,?)').run(p, commitSeq, idx, change.table, change.rowId, change.op === 'upsert' ? 1 : 2, change.rowVersion ?? null, JSON.stringify(change.scopes), change.payload ?? null);
152
184
  if (change.op === 'delete') {
@@ -247,6 +279,30 @@ export class SqliteServerStorage {
247
279
  if (!clientColumns.some((column) => column.name === 'wire_version')) {
248
280
  this.db.exec('ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1');
249
281
  }
282
+ const commitColumns = this.db
283
+ .query('PRAGMA table_info("sync_commits")')
284
+ .all();
285
+ if (!commitColumns.some((column) => column.name === 'writer_version')) {
286
+ // Nullable by design: an old writer omits the column and the trigger's
287
+ // explicit NULL test rejects it. `ADD COLUMN NOT NULL` is unavailable in
288
+ // SQLite, and a default would hand old writers a passing value.
289
+ this.db.exec('ALTER TABLE sync_commits ADD COLUMN writer_version INTEGER');
290
+ }
291
+ // The fence is database-side: an old binary's INSERT never reaches JS. An
292
+ // absent `sync_writer_fence` row makes the WHEN clause false, so a
293
+ // partition with no barrier behaves exactly as before.
294
+ this.db.exec(`CREATE TRIGGER IF NOT EXISTS sync_commits_writer_fence
295
+ BEFORE INSERT ON sync_commits
296
+ FOR EACH ROW
297
+ WHEN EXISTS (
298
+ SELECT 1 FROM sync_writer_fence
299
+ WHERE partition = NEW.partition
300
+ AND (NEW.writer_version IS NULL
301
+ OR NEW.writer_version < required_writer_version)
302
+ )
303
+ BEGIN
304
+ SELECT RAISE(ABORT, 'sync.storage.writer_fence_rejected');
305
+ END`);
250
306
  }
251
307
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
252
308
  table(name) {
@@ -256,10 +312,24 @@ export class SqliteServerStorage {
256
312
  }
257
313
  return table;
258
314
  }
259
- async ensureSchema(schema) {
315
+ async ensureSchema(schema, checkpoints) {
260
316
  // Memoized fast path: same instance, same schema version.
261
317
  if (this.#schemaVersion === schema.version)
262
318
  return;
319
+ // The barrier reads stored state: a checkpoint at this schema version that
320
+ // is not activated and that this caller did not declare means this process
321
+ // must not serve. An omitted or empty declaration set declares nothing and
322
+ // is refused here, never treated as a bypass.
323
+ const declarations = checkpoints ?? [];
324
+ const incomplete = this.db
325
+ .query(`SELECT partition, name FROM sync_backfill_checkpoints
326
+ WHERE schema_version=? AND state<>'activated'`)
327
+ .all(schema.version)
328
+ .filter((row) => !declarations.some((declaration) => declaration.partition === row.partition &&
329
+ declaration.name === row.name));
330
+ if (incomplete.length > 0) {
331
+ throw new StorageQueryError('sync.storage.checkpoint_incomplete');
332
+ }
263
333
  this.db.exec(SCHEMA_META_DDL_SQLITE);
264
334
  const marker = this.db
265
335
  .query('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1')
@@ -267,6 +337,55 @@ export class SqliteServerStorage {
267
337
  if (marker !== null && marker.schema_version > schema.version) {
268
338
  throw new Error(`stored schema version ${marker.schema_version} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`);
269
339
  }
340
+ if (marker !== null && marker.schema_version === schema.version) {
341
+ // Version equality is not layout equality: a marker written by another
342
+ // build at the same version describes rows the running codec cannot
343
+ // decode. Compare the stored layouts instead of trusting the number.
344
+ const storedLayouts = parseLayouts(marker.layouts);
345
+ const configuredLayouts = parseLayouts(layoutsOf(schema));
346
+ let mismatch;
347
+ for (const [tableName, columns] of Object.entries(configuredLayouts)) {
348
+ const stored = storedLayouts[tableName];
349
+ const columnCount = Math.max(columns.length, stored?.length ?? 0);
350
+ for (let index = 0; index < columnCount; index++) {
351
+ const expected = columns[index];
352
+ const actual = stored?.[index];
353
+ if (expected !== undefined &&
354
+ actual !== undefined &&
355
+ actual.name === expected.name &&
356
+ actual.type === expected.type &&
357
+ actual.nullable === expected.nullable) {
358
+ continue;
359
+ }
360
+ mismatch = `table ${JSON.stringify(tableName)} column ${JSON.stringify(expected?.name ?? actual?.name ?? '')}`;
361
+ break;
362
+ }
363
+ if (mismatch !== undefined)
364
+ break;
365
+ }
366
+ if (mismatch === undefined) {
367
+ for (const tableName of Object.keys(storedLayouts)) {
368
+ if (!(tableName in configuredLayouts)) {
369
+ mismatch = `table ${JSON.stringify(tableName)}`;
370
+ break;
371
+ }
372
+ }
373
+ }
374
+ if (mismatch !== undefined) {
375
+ 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`);
376
+ }
377
+ // The persisted layouts describe the codec's app columns only: read the
378
+ // physical tables so a same-version database missing a
379
+ // storage-internal column is refused at startup instead of failing at
380
+ // the first write.
381
+ for (const table of schema.tables.values()) {
382
+ const escapedTableName = table.name.replaceAll('"', '""');
383
+ const columns = this.db
384
+ .query(`PRAGMA table_info("${escapedTableName}")`)
385
+ .all();
386
+ assertPhysicalColumns(table, new Set(columns.map((c) => c.name)));
387
+ }
388
+ }
270
389
  if (marker === null || marker.schema_version < schema.version) {
271
390
  // Introspect existing app tables, then apply the migration subset
272
391
  // (CREATE TABLE / ADD COLUMN / rebuild indexes) to reach `schema`, then
@@ -291,36 +410,50 @@ export class SqliteServerStorage {
291
410
  existingIndexes.set(table.name, new Set(indexes.map((index) => index.name)));
292
411
  }
293
412
  }
294
- this.db.exec('BEGIN IMMEDIATE');
295
- try {
296
- for (const tableName of retiredTables) {
297
- this.db
298
- .query('DELETE FROM sync_row_scopes WHERE tbl=?')
299
- .run(tableName);
413
+ // The migration rewrites application rows (`#rewriteRows`) with no
414
+ // commit log entry. Serialize it through the same writer queue as
415
+ // pushes and prune (`#serializeWrite`) rather than trusting the
416
+ // migration as privileged; `BEGIN IMMEDIATE` then owns SQLite's writer
417
+ // lock for the whole rewrite.
418
+ await this.#serializeWrite(() => {
419
+ this.db.exec('BEGIN IMMEDIATE');
420
+ try {
421
+ for (const tableName of retiredTables) {
422
+ this.db
423
+ .query('DELETE FROM sync_row_scopes WHERE tbl=?')
424
+ .run(tableName);
425
+ this.db
426
+ .query('DELETE FROM sync_blob_refs WHERE tbl=?')
427
+ .run(tableName);
428
+ this.db.exec(dropTableDdl(tableName));
429
+ }
430
+ for (const statement of schemaDdl(schema, existing, 'sqlite', existingIndexes)) {
431
+ this.db.exec(statement);
432
+ }
433
+ for (const table of schema.tables.values()) {
434
+ const oldLayout = layouts[table.name];
435
+ const plan = rewritePlan(table, oldLayout, existing.get(table.name));
436
+ if (!plan.migrate && !plan.backfill)
437
+ continue;
438
+ this.#rewriteRows(table, plan.migrate ? oldLayout : undefined);
439
+ }
300
440
  this.db
301
- .query('DELETE FROM sync_blob_refs WHERE tbl=?')
302
- .run(tableName);
303
- this.db.exec(dropTableDdl(tableName));
304
- }
305
- for (const statement of schemaDdl(schema, existing, 'sqlite', existingIndexes)) {
306
- this.db.exec(statement);
441
+ .query('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')
442
+ .run(schema.version, layoutsOf(schema));
443
+ // The host's declared checkpoints install in the schema-bump
444
+ // transaction, so no observer sees the bumped marker without the
445
+ // fence, or the fence without its declaration row.
446
+ const installedAtMs = Date.now();
447
+ for (const declaration of checkpoints ?? []) {
448
+ this.#installCheckpoint(declaration.partition, declaration.name, declaration.schemaVersion, installedAtMs);
449
+ }
450
+ this.db.exec('COMMIT');
307
451
  }
308
- for (const table of schema.tables.values()) {
309
- const oldLayout = layouts[table.name];
310
- const plan = rewritePlan(table, oldLayout, existing.get(table.name));
311
- if (!plan.migrate && !plan.backfill)
312
- continue;
313
- this.#rewriteRows(table, plan.migrate ? oldLayout : undefined);
452
+ catch (error) {
453
+ this.db.exec('ROLLBACK');
454
+ throw error;
314
455
  }
315
- this.db
316
- .query('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')
317
- .run(schema.version, layoutsOf(schema));
318
- this.db.exec('COMMIT');
319
- }
320
- catch (error) {
321
- this.db.exec('ROLLBACK');
322
- throw error;
323
- }
456
+ });
324
457
  }
325
458
  this.#tables = schema.tables;
326
459
  this.#schemaVersion = schema.version;
@@ -436,7 +569,7 @@ export class SqliteServerStorage {
436
569
  });
437
570
  await previous;
438
571
  try {
439
- return new SqliteTransaction(this, partition, release);
572
+ return new SqliteTransaction(this, partition, this.#schemaVersion ?? 0, release);
440
573
  }
441
574
  catch (error) {
442
575
  release();
@@ -464,7 +597,7 @@ export class SqliteServerStorage {
464
597
  .get(partition);
465
598
  return row?.max_commit_seq ?? 0;
466
599
  }
467
- async queryAuthoritative(partition, query) {
600
+ async queryAuthoritative(partition, query, checkpoints) {
468
601
  if (this.#tables === undefined) {
469
602
  throw new Error('ensureSchema(schema) must run before registered queries');
470
603
  }
@@ -479,6 +612,14 @@ export class SqliteServerStorage {
479
612
  try {
480
613
  this.db.exec('BEGIN');
481
614
  open = true;
615
+ // RFC 0007: evaluate the gate on this connection after BEGIN. SQLite is
616
+ // single-writer and the shared connection serializes writers, so the
617
+ // gate read and the query read cannot straddle a migration.
618
+ const runningVersion = this.#schemaVersion ?? 0;
619
+ const gate = await this.readServeGate(partition, runningVersion);
620
+ const refusal = serveGateRefusal(gate, runningVersion, checkpoints);
621
+ if (refusal !== undefined)
622
+ throw serveNotReadyError(refusal);
482
623
  const rows = this.db
483
624
  .query(prepared.sql)
484
625
  .all(...prepared.params);
@@ -567,6 +708,177 @@ export class SqliteServerStorage {
567
708
  .get(partition, createdBeforeMs);
568
709
  return row?.seq ?? 0;
569
710
  }
711
+ async readServeGate(partition, runningSchemaVersion) {
712
+ const marker = this.db
713
+ .query('SELECT schema_version FROM sync_schema_meta WHERE id=1')
714
+ .get();
715
+ const epoch = this.db
716
+ .query('SELECT log_epoch FROM sync_partition_registry WHERE partition=?')
717
+ .get(partition);
718
+ const pending = this.db
719
+ .query(`SELECT partition, name FROM sync_backfill_checkpoints
720
+ WHERE schema_version=? AND state<>'activated'`)
721
+ .all(runningSchemaVersion);
722
+ return {
723
+ storedSchemaVersion: marker?.schema_version ?? 0,
724
+ logEpoch: epoch?.log_epoch,
725
+ pending: pending.map((row) => ({
726
+ partition: row.partition,
727
+ name: row.name,
728
+ })),
729
+ };
730
+ }
731
+ async readCheckpoints(partition) {
732
+ return this.db
733
+ .query(`SELECT partition, name, schema_version, state, watermark,
734
+ owner_epoch, observed_rows, updated_at_ms
735
+ FROM sync_backfill_checkpoints WHERE partition=? ORDER BY name`)
736
+ .all(partition)
737
+ .map(toStoredCheckpoint);
738
+ }
739
+ /**
740
+ * Insert the `declared` row and raise the fence, inside the caller's open
741
+ * transaction. Shared by `declareCheckpoint` and the `ensureSchema`
742
+ * migration so the schema bump and the fence commit together. Never lowers a
743
+ * fence and never re-declares an existing row.
744
+ */
745
+ #installCheckpoint(partition, name, schemaVersion, nowMs) {
746
+ this.db
747
+ .query(`INSERT INTO sync_backfill_checkpoints(
748
+ partition, name, schema_version, state, watermark,
749
+ owner_epoch, observed_rows, updated_at_ms)
750
+ VALUES (?,?,?,'declared',0,0,0,?)
751
+ ON CONFLICT(partition, name) DO NOTHING`)
752
+ .run(partition, name, schemaVersion, nowMs);
753
+ this.db
754
+ .query(`INSERT INTO sync_writer_fence(partition, required_writer_version)
755
+ VALUES (?,?)
756
+ ON CONFLICT(partition) DO UPDATE SET
757
+ required_writer_version=max(
758
+ sync_writer_fence.required_writer_version,
759
+ excluded.required_writer_version)`)
760
+ .run(partition, schemaVersion);
761
+ }
762
+ async declareCheckpoint(partition, name, schemaVersion, nowMs) {
763
+ // One transaction: the checkpoint becomes visible and the fence that
764
+ // protects it is raised together. The fence is raised at declaration, not
765
+ // at activation, so old writers are rejected for the whole backfill.
766
+ return this.#serializeWrite(() => {
767
+ this.db.exec('BEGIN IMMEDIATE');
768
+ try {
769
+ this.#installCheckpoint(partition, name, schemaVersion, nowMs);
770
+ const row = this.db
771
+ .query(`SELECT partition, name, schema_version, state, watermark,
772
+ owner_epoch, observed_rows, updated_at_ms
773
+ FROM sync_backfill_checkpoints WHERE partition=? AND name=?`)
774
+ .get(partition, name);
775
+ if (row === null)
776
+ throw new StorageQueryError('sync.storage.checkpoint_not_declared');
777
+ this.db.exec('COMMIT');
778
+ return toStoredCheckpoint(row);
779
+ }
780
+ catch (error) {
781
+ this.db.exec('ROLLBACK');
782
+ throw error;
783
+ }
784
+ });
785
+ }
786
+ async activateCheckpoint(partition, name, ownerEpoch, watermark, sources, nowMs) {
787
+ return this.#serializeWrite(() => {
788
+ // `BEGIN IMMEDIATE` is SQLite's partition write lock; the same writer
789
+ // lock a push takes. Nothing may observe activation without the fence.
790
+ this.db.exec('BEGIN IMMEDIATE');
791
+ try {
792
+ const fence = this.db
793
+ .query('SELECT required_writer_version FROM sync_writer_fence WHERE partition=?')
794
+ .get(partition);
795
+ const checkpoint = this.db
796
+ .query('SELECT schema_version FROM sync_backfill_checkpoints WHERE partition=? AND name=?')
797
+ .get(partition, name);
798
+ if (fence === null ||
799
+ checkpoint === null ||
800
+ fence.required_writer_version < checkpoint.schema_version) {
801
+ // The barrier is not installed. Activating would certify a state old
802
+ // writers can still decay; refuse instead of silently proceeding.
803
+ throw new StorageQueryError('sync.storage.checkpoint_fence_missing');
804
+ }
805
+ const coverage = this.#hasSourceChangesAbove(partition, sources, watermark);
806
+ if (coverage !== 'clean') {
807
+ this.db.exec('ROLLBACK');
808
+ return coverage === 'changed' ? 'stale' : 'unverifiable';
809
+ }
810
+ const updated = this.db
811
+ .query(`UPDATE sync_backfill_checkpoints
812
+ SET state='activated', watermark=?, updated_at_ms=?
813
+ WHERE partition=? AND name=? AND owner_epoch=?
814
+ AND state<>'activated'`)
815
+ .run(watermark, nowMs, partition, name, ownerEpoch);
816
+ this.db.exec('COMMIT');
817
+ return Number(updated.changes) === 1 ? 'activated' : 'stale';
818
+ }
819
+ catch (error) {
820
+ this.db.exec('ROLLBACK');
821
+ throw error;
822
+ }
823
+ });
824
+ }
825
+ async claimCheckpoint(partition, name, schemaVersion, nowMs) {
826
+ return this.#serializeWrite(() => {
827
+ const claimed = this.db
828
+ .query(`UPDATE sync_backfill_checkpoints
829
+ SET owner_epoch=owner_epoch+1,
830
+ state=CASE WHEN state='declared' THEN 'backfilling' ELSE state END,
831
+ schema_version=?, updated_at_ms=?
832
+ WHERE partition=? AND name=? AND state<>'activated'
833
+ RETURNING partition, name, schema_version, state, watermark,
834
+ owner_epoch, observed_rows, updated_at_ms`)
835
+ .get(schemaVersion, nowMs, partition, name);
836
+ if (claimed === null) {
837
+ throw new StorageQueryError('sync.storage.checkpoint_not_declared');
838
+ }
839
+ return toStoredCheckpoint(claimed);
840
+ });
841
+ }
842
+ async advanceCheckpoint(partition, name, ownerEpoch, watermark, observedRows, nowMs) {
843
+ return this.#serializeWrite(() => {
844
+ const result = this.db
845
+ .query(`UPDATE sync_backfill_checkpoints
846
+ SET watermark=?, observed_rows=?, updated_at_ms=?
847
+ WHERE partition=? AND name=? AND owner_epoch=?`)
848
+ .run(watermark, observedRows, nowMs, partition, name, ownerEpoch);
849
+ return Number(result.changes) === 1;
850
+ });
851
+ }
852
+ async sourceCoverageSeq(partition, tables) {
853
+ if (tables.length === 0)
854
+ return 0;
855
+ const row = this.db
856
+ .query(`SELECT max(commit_seq) AS seq FROM sync_changes
857
+ WHERE partition=? AND tbl IN (${placeholders(tables.length)})`)
858
+ .get(partition, ...tables);
859
+ return row?.seq ?? 0;
860
+ }
861
+ #hasSourceChangesAbove(partition, tables, seq) {
862
+ // Read the horizon before the window scan: a horizon past `seq` means the
863
+ // history that would answer the question is gone, so an empty scan proves
864
+ // nothing and must not be reported as `clean`.
865
+ const horizon = this.db
866
+ .query('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
867
+ .get(partition)?.horizon_seq ?? 0;
868
+ if (tables.length === 0)
869
+ return 'clean';
870
+ const hit = this.db
871
+ .query(`SELECT 1 AS hit FROM sync_changes
872
+ WHERE partition=? AND tbl IN (${placeholders(tables.length)})
873
+ AND commit_seq>? LIMIT 1`)
874
+ .get(partition, ...tables, seq);
875
+ if (hit !== null && hit !== undefined)
876
+ return 'changed';
877
+ return horizon > seq ? 'unverifiable' : 'clean';
878
+ }
879
+ async hasSourceChangesAbove(partition, tables, seq) {
880
+ return this.#hasSourceChangesAbove(partition, tables, seq);
881
+ }
570
882
  async getRow(partition, table, rowId) {
571
883
  const record = this.db
572
884
  .query(selectRowSql(this.table(table), 'sqlite'))
@@ -9,7 +9,7 @@ export declare class StorageConstraintError extends Error {
9
9
  constructor(cause: unknown, opIndex?: number);
10
10
  }
11
11
  /** Stable, privacy-safe failures for trusted server storage queries. */
12
- export type StorageQueryErrorCode = 'sync.storage.schema_migration_pending' | 'sync.storage.schema_migration_conflict' | 'sync.storage.schema_changed' | 'sync.storage.invalid_migration_budget' | 'sync.storage.scan_requires_scope' | 'sync.storage.index_not_found' | 'sync.storage.index_not_materialized' | 'sync.storage.index_value_count_mismatch' | 'sync.storage.invalid_limit' | 'sync.storage.prune_epoch_mismatch' | 'sync.storage.partition_unregistered' | 'sync.storage.invalid_prune_cursor';
12
+ export type StorageQueryErrorCode = 'sync.storage.schema_migration_pending' | 'sync.storage.schema_migration_conflict' | 'sync.storage.schema_changed' | 'sync.storage.invalid_migration_budget' | 'sync.storage.scan_requires_scope' | 'sync.storage.index_not_found' | 'sync.storage.index_not_materialized' | 'sync.storage.index_value_count_mismatch' | 'sync.storage.invalid_limit' | 'sync.storage.prune_epoch_mismatch' | 'sync.storage.partition_unregistered' | 'sync.storage.invalid_prune_cursor' | 'sync.storage.checkpoint_not_declared' | 'sync.storage.checkpoint_unsupported' | 'sync.storage.checkpoint_fence_missing' | 'sync.storage.checkpoint_incomplete';
13
13
  /**
14
14
  * Host-only query error. Messages never include identifiers, values, SQL,
15
15
  * paths, or row data; callers branch on `code`, never message text.
@@ -19,6 +19,10 @@ const STORAGE_QUERY_MESSAGES = {
19
19
  'sync.storage.prune_epoch_mismatch': 'partition log epoch changed; recompute retention inputs',
20
20
  'sync.storage.partition_unregistered': 'pruning requires a registered partition',
21
21
  'sync.storage.invalid_prune_cursor': 'pruning requires a non-negative safe integer cursor and a non-empty log epoch',
22
+ 'sync.storage.checkpoint_not_declared': 'checkpoint claim requires a declared, not yet activated row',
23
+ 'sync.storage.checkpoint_unsupported': 'this storage backend cannot declare backfill checkpoints',
24
+ 'sync.storage.checkpoint_fence_missing': 'checkpoint activation requires the writer fence raised at declaration',
25
+ 'sync.storage.checkpoint_incomplete': 'a declared backfill checkpoint is not activated and this caller did not declare it',
22
26
  'sync.storage.scan_requires_scope': 'scope-indexed row scans require at least one scope variable',
23
27
  'sync.storage.index_not_found': 'trusted row lookup requires a declared relational index',
24
28
  'sync.storage.index_not_materialized': 'trusted row lookup requires a materialized relational table',