@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,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 { 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, 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';
@@ -17,7 +19,14 @@ import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js'
17
19
  * - `sync_row_scopes_pk (partition, tbl, var, value, row_id)` — same shape
18
20
  * for `scanRows`, ordered by `row_id`.
19
21
  * Both are the PRIMARY KEY, so they are the clustering/covering index for
20
- * their table. No secondary index is needed for the hot path.
22
+ * their table.
23
+ *
24
+ * `sync_row_scopes_by_row (partition, tbl, row_id)` is the one secondary
25
+ * scope index: `writeRowOn` and `deleteRow` replace a row's scope entries
26
+ * with a `(partition, tbl, row_id)` predicate, and `row_id` is the LAST
27
+ * column of the inverted PRIMARY KEY, so without this index that predicate
28
+ * scans the table's whole scope range on every row written (a bulk import
29
+ * costs one such scan per row).
21
30
  *
22
31
  * Blob reference index (§5.9.4) — parity with the SQLite dialect's
23
32
  * `sync_blob_refs`:
@@ -46,10 +55,13 @@ CREATE TABLE IF NOT EXISTS sync_row_scopes(
46
55
  var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,
47
56
  PRIMARY KEY(partition, tbl, var, value, row_id)
48
57
  );
58
+ CREATE INDEX IF NOT EXISTS sync_row_scopes_by_row
59
+ ON sync_row_scopes(partition, tbl, row_id);
49
60
  CREATE TABLE IF NOT EXISTS sync_commits(
50
61
  partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,
51
62
  client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,
52
63
  actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,
64
+ writer_version BIGINT,
53
65
  PRIMARY KEY(partition, commit_seq)
54
66
  );
55
67
  CREATE INDEX IF NOT EXISTS sync_commits_by_time
@@ -113,6 +125,20 @@ CREATE TABLE IF NOT EXISTS sync_tombstones(
113
125
  commit_seq BIGINT NOT NULL,
114
126
  PRIMARY KEY(partition, tbl, row_id)
115
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
+ );
116
142
  `;
117
143
  async function lockPartitionOn(client, partition) {
118
144
  const locked = await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [partition]);
@@ -124,12 +150,76 @@ async function lockPartitionOn(client, partition) {
124
150
  ON CONFLICT (partition) DO NOTHING`, [partition]);
125
151
  await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [partition]);
126
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
+ }
127
205
  function toBase64(bytes) {
128
206
  return Buffer.from(bytes).toString('base64');
129
207
  }
130
208
  function fromBase64(text) {
131
209
  return new Uint8Array(Buffer.from(text, 'base64'));
132
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
+ }
133
223
  function toStoredReaction(record) {
134
224
  return {
135
225
  idempotencyKey: record.idempotency_key,
@@ -332,15 +422,31 @@ async function getPushResultOn(q, partition, clientId, clientCommitId) {
332
422
  class PostgresTransaction {
333
423
  #client;
334
424
  #partition;
425
+ /** Schema version this transaction writes; rides every appended commit row. */
426
+ #writerVersion;
335
427
  #resolveTable;
336
428
  #open = true;
337
429
  #pushApplySavepoint = false;
338
430
  /** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
339
431
  #resolve;
340
432
  #reject;
341
- constructor(client, partition, resolveTable, resolve, reject) {
342
- this.#client = client;
433
+ constructor(client, partition, writerVersion, resolveTable, resolve, reject) {
434
+ // One pinned connection carries one statement at a time: node-postgres
435
+ // warns about a concurrently issued second query today and rejects it in
436
+ // node-postgres 9. A whole-commit validator that fans independent reads
437
+ // out with Promise.all must not interleave them, so every statement
438
+ // issued through this transaction is chained on one FIFO — the same
439
+ // shape as `SqliteServerStorage.queryAuthoritative`.
440
+ let tail = Promise.resolve();
441
+ this.#client = {
442
+ query: (text, params) => {
443
+ const run = tail.then(() => client.query(text, params));
444
+ tail = run.then(() => undefined, () => undefined);
445
+ return run;
446
+ },
447
+ };
343
448
  this.#partition = partition;
449
+ this.#writerVersion = writerVersion;
344
450
  this.#resolveTable = resolveTable;
345
451
  this.#resolve = resolve;
346
452
  this.#reject = reject;
@@ -417,6 +523,24 @@ class PostgresTransaction {
417
523
  await this.#client.query('SAVEPOINT syncular_push_candidate');
418
524
  this.#pushApplySavepoint = true;
419
525
  }
526
+ async advanceCheckpoint(name, ownerEpoch, watermark, observedRows, nowMs) {
527
+ this.#assertOpen();
528
+ // `RETURNING`: the affected-row check must not depend on a driver's
529
+ // `rowCount` for a statement with no result rows. Bun.sql's canonical
530
+ // adapter derives `rowCount` from the returned rows, so a bare UPDATE
531
+ // reports 0 and the CAS would never advance on a real Bun.sql host.
532
+ const result = await this.#client.query(`UPDATE sync_backfill_checkpoints
533
+ SET watermark=$1, observed_rows=$2, updated_at_ms=$3
534
+ WHERE partition=$4 AND name=$5 AND owner_epoch=$6
535
+ RETURNING name`, [watermark, observedRows, nowMs, this.#partition, name, ownerEpoch]);
536
+ return result.rows.length === 1;
537
+ }
538
+ readServeGate(runningSchemaVersion) {
539
+ this.#assertOpen();
540
+ // The transaction's own client: the gate and the write share one
541
+ // transaction, so a migration cannot interleave between them.
542
+ return readServeGateOn(this.#client, this.#partition, runningSchemaVersion);
543
+ }
420
544
  async commitRejectedPushResult(clientId, clientCommitId, result) {
421
545
  this.#assertOpen();
422
546
  if (!this.#pushApplySavepoint) {
@@ -469,14 +593,15 @@ class PostgresTransaction {
469
593
  SET max_commit_seq = sync_partitions.max_commit_seq + 1
470
594
  RETURNING max_commit_seq
471
595
  )
472
- INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms)
473
- SELECT $1, max_commit_seq, $2, $3, $4, $5 FROM allocated
596
+ INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms, writer_version)
597
+ SELECT $1, max_commit_seq, $2, $3, $4, $5, $6 FROM allocated
474
598
  RETURNING commit_seq`, [
475
599
  p,
476
600
  commit.clientId,
477
601
  commit.clientCommitId,
478
602
  commit.actorId,
479
603
  commit.createdAtMs,
604
+ this.#writerVersion,
480
605
  ]);
481
606
  const commitSeq = asNumber(rows[0]?.commit_seq);
482
607
  for (let idx = 0; idx < commit.changes.length; idx++) {
@@ -593,6 +718,29 @@ export class PostgresServerStorage {
593
718
  for (const statement of statements) {
594
719
  await this.#exec.query(statement);
595
720
  }
721
+ await this.#exec.query('ALTER TABLE sync_commits ADD COLUMN IF NOT EXISTS writer_version BIGINT');
722
+ // Database-side fence: an old binary's INSERT never reaches JS. An absent
723
+ // `sync_writer_fence` row makes the EXISTS false, so a partition with no
724
+ // barrier behaves exactly as before. Sent as separate statements because
725
+ // the dollar-quoted body contains `;`.
726
+ await this.#exec.query(`CREATE OR REPLACE FUNCTION syncular_writer_fence()
727
+ RETURNS trigger AS $$
728
+ BEGIN
729
+ IF EXISTS (
730
+ SELECT 1 FROM sync_writer_fence
731
+ WHERE partition = NEW.partition
732
+ AND (NEW.writer_version IS NULL
733
+ OR NEW.writer_version < required_writer_version)
734
+ ) THEN
735
+ RAISE EXCEPTION 'sync.storage.writer_fence_rejected';
736
+ END IF;
737
+ RETURN NEW;
738
+ END;
739
+ $$ LANGUAGE plpgsql`);
740
+ await this.#exec.query('DROP TRIGGER IF EXISTS sync_commits_writer_fence ON sync_commits');
741
+ await this.#exec.query(`CREATE TRIGGER sync_commits_writer_fence
742
+ BEFORE INSERT ON sync_commits
743
+ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
596
744
  }
597
745
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
598
746
  table(name) {
@@ -602,11 +750,22 @@ export class PostgresServerStorage {
602
750
  }
603
751
  return table;
604
752
  }
605
- async ensureSchema(schema) {
753
+ async ensureSchema(schema, checkpoints) {
606
754
  // Memoized fast path: same instance, same schema version.
607
755
  if (this.#schemaVersion === schema.version)
608
756
  return;
609
757
  await this.migrate();
758
+ // The barrier reads stored state: a checkpoint at this schema version that
759
+ // is not activated and that this caller did not declare means this process
760
+ // must not serve. An omitted or empty declaration set declares nothing and
761
+ // is refused here, never treated as a bypass.
762
+ const declarations = checkpoints ?? [];
763
+ const incomplete = await this.#exec.query(`SELECT partition, name FROM sync_backfill_checkpoints
764
+ WHERE schema_version=$1 AND state<>'activated'`, [schema.version]);
765
+ if (incomplete.rows.some((row) => !declarations.some((declaration) => declaration.partition === row.partition &&
766
+ declaration.name === row.name))) {
767
+ throw new StorageQueryError('sync.storage.checkpoint_incomplete');
768
+ }
610
769
  await this.#exec.query(SCHEMA_META_DDL_POSTGRES);
611
770
  const marker = await this.#exec.query('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1');
612
771
  const stored = marker.rows[0] === undefined
@@ -615,6 +774,60 @@ export class PostgresServerStorage {
615
774
  if (stored !== undefined && stored > schema.version) {
616
775
  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`);
617
776
  }
777
+ if (stored !== undefined && stored === schema.version) {
778
+ // Version equality is not layout equality: a marker written by another
779
+ // build at the same version describes rows the running codec cannot
780
+ // decode. Compare the stored layouts instead of trusting the number.
781
+ const storedLayouts = parseLayouts(typeof marker.rows[0]?.layouts === 'string'
782
+ ? marker.rows[0].layouts
783
+ : undefined);
784
+ const configuredLayouts = parseLayouts(layoutsOf(schema));
785
+ let mismatch;
786
+ for (const [tableName, columns] of Object.entries(configuredLayouts)) {
787
+ const storedTable = storedLayouts[tableName];
788
+ const columnCount = Math.max(columns.length, storedTable?.length ?? 0);
789
+ for (let index = 0; index < columnCount; index++) {
790
+ const expected = columns[index];
791
+ const actual = storedTable?.[index];
792
+ if (expected !== undefined &&
793
+ actual !== undefined &&
794
+ actual.name === expected.name &&
795
+ actual.type === expected.type &&
796
+ actual.nullable === expected.nullable) {
797
+ continue;
798
+ }
799
+ mismatch = `table ${JSON.stringify(tableName)} column ${JSON.stringify(expected?.name ?? actual?.name ?? '')}`;
800
+ break;
801
+ }
802
+ if (mismatch !== undefined)
803
+ break;
804
+ }
805
+ if (mismatch === undefined) {
806
+ for (const tableName of Object.keys(storedLayouts)) {
807
+ if (!(tableName in configuredLayouts)) {
808
+ mismatch = `table ${JSON.stringify(tableName)}`;
809
+ break;
810
+ }
811
+ }
812
+ }
813
+ if (mismatch !== undefined) {
814
+ 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`);
815
+ }
816
+ // The persisted layouts describe the codec's app columns only: read the
817
+ // physical tables so a same-version database missing a
818
+ // storage-internal column is refused at startup instead of failing at
819
+ // the first write. Resolve through `to_regclass`, which follows the
820
+ // session's `search_path` exactly as the unqualified row queries do —
821
+ // `current_schema()` names only the first existing schema, so it would
822
+ // reject a table that resolves later in the path.
823
+ for (const table of schema.tables.values()) {
824
+ const { rows } = await this.#exec.query(`SELECT attribute.attname AS column_name
825
+ FROM pg_attribute attribute
826
+ WHERE attribute.attrelid = to_regclass($1)
827
+ AND attribute.attnum > 0 AND NOT attribute.attisdropped`, [table.name]);
828
+ assertPhysicalColumns(table, new Set(rows.map((r) => r.column_name)));
829
+ }
830
+ }
618
831
  if (stored === undefined || stored < schema.version) {
619
832
  // Introspect existing app tables, apply the migration subset
620
833
  // (CREATE TABLE / ADD COLUMN / rebuild indexes), then rewrite stored
@@ -627,6 +840,17 @@ export class PostgresServerStorage {
627
840
  : undefined);
628
841
  const retiredTables = retiredTableNames(schema, layouts);
629
842
  await this.#exec.transaction(async (client) => {
843
+ // RFC 0007 migration barrier: `LOCK TABLE ... EXCLUSIVE` is the FIRST
844
+ // statement, before any read, DDL, rewrite, marker or checkpoint
845
+ // install. EXCLUSIVE conflicts with the ROW SHARE that every writer's
846
+ // `lockPartitionOn` takes (`SELECT … FOR UPDATE`) and with the ROW
847
+ // EXCLUSIVE of its allocation (`UPDATE sync_partitions` / `INSERT … ON
848
+ // CONFLICT DO UPDATE`), so an unaware old writer blocks at its first
849
+ // statement with no cooperation; plain `SELECT` (ACCESS SHARE) still
850
+ // works. The lock order is acyclic: the migration holds nothing else
851
+ // when it takes this lock, so a writer already inside completes while
852
+ // the migration waits.
853
+ await client.query('LOCK TABLE sync_partitions IN EXCLUSIVE MODE');
630
854
  const existing = new Map();
631
855
  const existingIndexes = new Map();
632
856
  for (const table of schema.tables.values()) {
@@ -680,6 +904,13 @@ export class PostgresServerStorage {
680
904
  await client.query(`INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1, $1, $2)
681
905
  ON CONFLICT (id) DO UPDATE
682
906
  SET schema_version=EXCLUDED.schema_version, layouts=EXCLUDED.layouts`, [schema.version, layoutsOf(schema)]);
907
+ // The host's declared checkpoints install in the schema-bump
908
+ // transaction, so no observer sees the bumped marker without the
909
+ // fence, or the fence without its declaration row.
910
+ const installedAtMs = Date.now();
911
+ for (const declaration of checkpoints ?? []) {
912
+ await installCheckpointOn(client, declaration.partition, declaration.name, declaration.schemaVersion, installedAtMs);
913
+ }
683
914
  });
684
915
  }
685
916
  this.#tables = schema.tables;
@@ -759,7 +990,7 @@ export class PostgresServerStorage {
759
990
  });
760
991
  const done = this.#exec
761
992
  .transaction(async (client) => {
762
- const tx = new PostgresTransaction(client, partition, (name) => this.table(name), resolveScope, rejectScope);
993
+ const tx = new PostgresTransaction(client, partition, this.#schemaVersion ?? 0, (name) => this.table(name), resolveScope, rejectScope);
763
994
  resolveReady(tx);
764
995
  // Hold the transaction open until commit()/rollback() settles `scope`.
765
996
  await scope;
@@ -777,13 +1008,22 @@ export class PostgresServerStorage {
777
1008
  const { rows } = await this.#exec.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1', [partition]);
778
1009
  return rows[0] === undefined ? 0 : asNumber(rows[0].max_commit_seq);
779
1010
  }
780
- async queryAuthoritative(partition, query) {
1011
+ async queryAuthoritative(partition, query, checkpoints) {
781
1012
  if (this.#tables === undefined) {
782
1013
  throw new Error('ensureSchema(schema) must run before registered queries');
783
1014
  }
784
1015
  const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
785
1016
  return this.#exec.transaction(async (client) => {
786
1017
  await client.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
1018
+ // RFC 0007: evaluate the gate inside this pinned snapshot, so a
1019
+ // migration or an epoch rotation cannot land between the gate read and
1020
+ // the query's own read. The registered-query path uses the pinned
1021
+ // snapshot, not a request-entry check.
1022
+ const runningVersion = this.#schemaVersion ?? 0;
1023
+ const gate = await readServeGateOn(client, partition, runningVersion);
1024
+ const refusal = serveGateRefusal(gate, runningVersion, checkpoints);
1025
+ if (refusal !== undefined)
1026
+ throw serveNotReadyError(refusal);
787
1027
  const result = await client.query(postgresPlaceholders(prepared.sql), prepared.params);
788
1028
  const cursor = await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1', [partition]);
789
1029
  return {
@@ -834,6 +1074,102 @@ export class PostgresServerStorage {
834
1074
  const seq = rows[0]?.seq;
835
1075
  return seq === null || seq === undefined ? 0 : asNumber(seq);
836
1076
  }
1077
+ async readCheckpoints(partition) {
1078
+ const { rows } = await this.#exec.query(`SELECT partition, name, schema_version, state, watermark,
1079
+ owner_epoch, observed_rows, updated_at_ms
1080
+ FROM sync_backfill_checkpoints WHERE partition=$1 ORDER BY name`, [partition]);
1081
+ return rows.map(toStoredCheckpoint);
1082
+ }
1083
+ async readServeGate(partition, runningSchemaVersion) {
1084
+ return readServeGateOn(this.#exec, partition, runningSchemaVersion);
1085
+ }
1086
+ async declareCheckpoint(partition, name, schemaVersion, nowMs) {
1087
+ // One transaction: the checkpoint becomes visible and the fence that
1088
+ // protects it is raised together. The fence is raised at declaration, not
1089
+ // at activation, so old writers are rejected for the whole backfill.
1090
+ //
1091
+ // RFC 0007 lock decision: this transaction writes only the checkpoint and
1092
+ // fence rows, no application rows and no schema marker, so it does not
1093
+ // take the migration table lock or a partition lock. The fence it raises is
1094
+ // enforced by the `sync_commits` trigger on every append from the commit
1095
+ // of this transaction onward; a writer that committed before the fence was
1096
+ // visible is pre-barrier by the design's own "from declaration onward"
1097
+ // rule, not a bypass of it.
1098
+ return this.#exec.transaction(async (client) => {
1099
+ await installCheckpointOn(client, partition, name, schemaVersion, nowMs);
1100
+ const { rows } = await client.query(`SELECT partition, name, schema_version, state, watermark,
1101
+ owner_epoch, observed_rows, updated_at_ms
1102
+ FROM sync_backfill_checkpoints WHERE partition=$1 AND name=$2`, [partition, name]);
1103
+ const row = rows[0];
1104
+ if (row === undefined) {
1105
+ throw new StorageQueryError('sync.storage.checkpoint_not_declared');
1106
+ }
1107
+ return toStoredCheckpoint(row);
1108
+ });
1109
+ }
1110
+ async activateCheckpoint(partition, name, ownerEpoch, watermark, sources, nowMs) {
1111
+ return this.#exec.transaction(async (client) => {
1112
+ // The same `SELECT … FOR UPDATE` partition row lock that
1113
+ // `lockPartitionForPush` takes; a bare `BEGIN` would race a push.
1114
+ await lockPartitionOn(client, partition);
1115
+ const fence = await client.query('SELECT required_writer_version FROM sync_writer_fence WHERE partition=$1', [partition]);
1116
+ const checkpoint = await client.query('SELECT schema_version FROM sync_backfill_checkpoints WHERE partition=$1 AND name=$2', [partition, name]);
1117
+ const fenceRow = fence.rows[0];
1118
+ const checkpointRow = checkpoint.rows[0];
1119
+ if (fenceRow === undefined ||
1120
+ checkpointRow === undefined ||
1121
+ asNumber(fenceRow.required_writer_version) <
1122
+ asNumber(checkpointRow.schema_version)) {
1123
+ // The barrier is not installed. Activating would certify a state old
1124
+ // writers can still decay; refuse instead of silently proceeding.
1125
+ throw new StorageQueryError('sync.storage.checkpoint_fence_missing');
1126
+ }
1127
+ const coverage = await hasSourceChangesAboveOn(client, partition, sources, watermark);
1128
+ if (coverage !== 'clean') {
1129
+ return coverage === 'changed' ? 'stale' : 'unverifiable';
1130
+ }
1131
+ const updated = await client.query(`UPDATE sync_backfill_checkpoints
1132
+ SET state='activated', watermark=$1, updated_at_ms=$2
1133
+ WHERE partition=$3 AND name=$4 AND owner_epoch=$5
1134
+ AND state<>'activated'
1135
+ RETURNING partition`, [watermark, nowMs, partition, name, ownerEpoch]);
1136
+ return updated.rows.length === 1 ? 'activated' : 'stale';
1137
+ });
1138
+ }
1139
+ async claimCheckpoint(partition, name, schemaVersion, nowMs) {
1140
+ // The UPDATE's own row lock serializes concurrent claims; owner_epoch+1
1141
+ // is evaluated under it, so two claimers never observe the same epoch.
1142
+ const { rows } = await this.#exec.query(`UPDATE sync_backfill_checkpoints
1143
+ SET owner_epoch=owner_epoch+1,
1144
+ state=CASE WHEN state='declared' THEN 'backfilling' ELSE state END,
1145
+ schema_version=$1, updated_at_ms=$2
1146
+ WHERE partition=$3 AND name=$4 AND state<>'activated'
1147
+ RETURNING partition, name, schema_version, state, watermark,
1148
+ owner_epoch, observed_rows, updated_at_ms`, [schemaVersion, nowMs, partition, name]);
1149
+ if (rows[0] === undefined) {
1150
+ throw new StorageQueryError('sync.storage.checkpoint_not_declared');
1151
+ }
1152
+ return toStoredCheckpoint(rows[0]);
1153
+ }
1154
+ async advanceCheckpoint(partition, name, ownerEpoch, watermark, observedRows, nowMs) {
1155
+ const result = await this.#exec.query(`UPDATE sync_backfill_checkpoints
1156
+ SET watermark=$1, observed_rows=$2, updated_at_ms=$3
1157
+ WHERE partition=$4 AND name=$5 AND owner_epoch=$6
1158
+ RETURNING name`, [watermark, observedRows, nowMs, partition, name, ownerEpoch]);
1159
+ return result.rows.length === 1;
1160
+ }
1161
+ async sourceCoverageSeq(partition, tables) {
1162
+ if (tables.length === 0)
1163
+ return 0;
1164
+ const tableParams = tables.map((_, index) => `$${index + 2}`).join(',');
1165
+ const { rows } = await this.#exec.query(`SELECT max(commit_seq) AS seq FROM sync_changes
1166
+ WHERE partition=$1 AND tbl IN (${tableParams})`, [partition, ...tables]);
1167
+ const seq = rows[0]?.seq;
1168
+ return seq === null || seq === undefined ? 0 : asNumber(seq);
1169
+ }
1170
+ hasSourceChangesAbove(partition, tables, seq) {
1171
+ return hasSourceChangesAboveOn(this.#exec, partition, tables, seq);
1172
+ }
837
1173
  getRow(partition, table, rowId) {
838
1174
  return getRowOn(this.#exec, this.table(table), partition, rowId);
839
1175
  }
package/dist/pull.js CHANGED
@@ -108,7 +108,7 @@ async function signedUrlFields(ctx, limits, segmentId, digest, now) {
108
108
  nowMs: now,
109
109
  });
110
110
  }
111
- function segmentRefFrame(record, extra) {
111
+ function segmentRefFrame(record, scopeDigest, extra) {
112
112
  return {
113
113
  type: 'SEGMENT_REF',
114
114
  segmentId: record.segmentId,
@@ -117,7 +117,11 @@ function segmentRefFrame(record, extra) {
117
117
  byteLength: record.byteLength,
118
118
  rowCount: record.rowCount,
119
119
  asOfCommitSeq: record.asOfCommitSeq,
120
- scopeDigest: record.scopeDigest,
120
+ // The frame reports the digest THIS subscription is authorized under. A
121
+ // stored entry can carry several digests when identical content was
122
+ // published under different scopes (§5.1), and the record's primary
123
+ // digest is not necessarily the caller's (§5.5).
124
+ scopeDigest,
121
125
  ...(record.rowCursor !== null ? { rowCursor: record.rowCursor } : {}),
122
126
  ...(record.nextRowCursor !== null
123
127
  ? { nextRowCursor: record.nextRowCursor }
@@ -156,7 +160,7 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
156
160
  bytes: existing.byteLength,
157
161
  rows: existing.rowCount,
158
162
  });
159
- yield segmentRefFrame(existing, await signedUrlFields(ctx, limits, existing.segmentId, digest, now));
163
+ yield segmentRefFrame(existing, digest, await signedUrlFields(ctx, limits, existing.segmentId, digest, now));
160
164
  return true;
161
165
  }
162
166
  // Eligibility probe (§5.3): image only when the snapshot exceeds one
@@ -253,7 +257,7 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
253
257
  bytes: record.byteLength,
254
258
  rows: record.rowCount,
255
259
  });
256
- yield segmentRefFrame(record, await signedUrlFields(ctx, limits, record.segmentId, digest, clockOf(ctx)()));
260
+ yield segmentRefFrame(record, digest, await signedUrlFields(ctx, limits, record.segmentId, digest, clockOf(ctx)()));
257
261
  return true;
258
262
  }
259
263
  async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace, logEpoch) {
@@ -331,7 +335,7 @@ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCurso
331
335
  bytes: record.byteLength,
332
336
  rows: record.rowCount,
333
337
  });
334
- yield segmentRefFrame(record, await signedUrlFields(ctx, limits, record.segmentId, digest, now));
338
+ yield segmentRefFrame(record, digest, await signedUrlFields(ctx, limits, record.segmentId, digest, now));
335
339
  }
336
340
  if (!hasMore)
337
341
  return { complete: true, rowCursor: null };
package/dist/push.js CHANGED
@@ -24,7 +24,9 @@ import { emitEvent } from './events.js';
24
24
  import { prepareReactions, toNewReactions, } from './reactions.js';
25
25
  import { authorizeWrite, renderScopeValue, storedScopesForRow } from './scopes.js';
26
26
  import { decodeColumnVersions, encodeColumnVersions } from './relational-rows.js';
27
+ import { serveGateRefusal } from './storage.js';
27
28
  import { StorageConstraintError } from './storage-errors.js';
29
+ import { serveNotReadyError } from './readiness.js';
28
30
  import { CommitValidationRejection, toValidateRow, ValidationRejection, } from './validate.js';
29
31
  /**
30
32
  * Extract the blobIds a decoded row references through its `blob_ref`
@@ -864,6 +866,15 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
864
866
  throw new Error('storage transaction does not support serialized push apply, atomic rejection finalization, and configured durable reactions');
865
867
  }
866
868
  await lockPartitionForPush();
869
+ // RFC 0007: evaluate the gate on this transaction's own connection while
870
+ // the partition lock is held, so the migration check and the write share
871
+ // one transaction and no migration can interleave in between.
872
+ const gate = await tx.readServeGate(schema.version);
873
+ const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
874
+ if (refusal !== undefined) {
875
+ await tx.rollback();
876
+ throw serveNotReadyError(refusal);
877
+ }
867
878
  // The optimistic lookup above may have raced another delivery. Re-check
868
879
  // only after acquiring partition serialization and before any operation
869
880
  // read, validation, merge, or staged write. The re-check runs on the
@@ -1015,6 +1026,13 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
1015
1026
  }
1016
1027
  }
1017
1028
  if (ctx.realtime !== undefined && changes.length > 0) {
1029
+ // RFC 0007 fanout decision: the notification carries the durable commit
1030
+ // entry itself (this commit's `changes`), not a projection read or a pull
1031
+ // window, so fanning it out before the request's buffered read-verify can
1032
+ // never serve a mixed result. Each peer's next pull is gated at the
1033
+ // storage seam; a refused mixed push+pull is safe because the push is
1034
+ // durable and replayable under its commit id. This is commit-entry
1035
+ // durability, not gate inheritance.
1018
1036
  await ctx.realtime.notifyCommit(partition, {
1019
1037
  commitSeq,
1020
1038
  createdAtMs,
@@ -7,8 +7,10 @@
7
7
  * a structured failure that cannot be confused with request authentication.
8
8
  */
9
9
  import type { SyncServerConfig } from './context.js';
10
+ import { type SyncError } from './errors.js';
11
+ import type { ServeGateRefusal } from './storage.js';
10
12
  export declare const SYNC_SERVER_READINESS_ERROR_CODE: "sync.schema_not_ready";
11
- export type SyncServerReadinessPhase = 'schema_compile' | 'storage_migration';
13
+ export type SyncServerReadinessPhase = 'schema_compile' | 'storage_migration' | 'backfill_checkpoint';
12
14
  export declare class SyncServerReadinessError extends Error {
13
15
  readonly name = "SyncServerReadinessError";
14
16
  readonly code: "sync.schema_not_ready";
@@ -27,4 +29,9 @@ export declare class SyncServerReadinessError extends Error {
27
29
  * exposes only a stable code, phase, and schema version; operators can inspect
28
30
  * `cause` locally for the table/column or storage diagnostic.
29
31
  */
30
- export declare function ensureSyncServerReady(config: Pick<SyncServerConfig, 'schema' | 'storage'>): Promise<void>;
32
+ export declare function ensureSyncServerReady(config: Pick<SyncServerConfig, 'schema' | 'storage' | 'checkpoints'>): Promise<void>;
33
+ /**
34
+ * RFC 0007: refuse a serve path that observed a closed gate. The projection
35
+ * name travels in structured details, never in the message.
36
+ */
37
+ export declare function serveNotReadyError(refusal: ServeGateRefusal): SyncError;
package/dist/readiness.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { syncError } from './errors.js';
1
2
  import { compileSchema } from './schema.js';
2
3
  export const SYNC_SERVER_READINESS_ERROR_CODE = 'sync.schema_not_ready';
3
4
  export class SyncServerReadinessError extends Error {
@@ -31,13 +32,37 @@ export async function ensureSyncServerReady(config) {
31
32
  });
32
33
  }
33
34
  try {
34
- await config.storage.ensureSchema(compiled);
35
+ await config.storage.ensureSchema(compiled, config.checkpoints);
35
36
  }
36
37
  catch (cause) {
38
+ // RFC 0007: a declared-but-incomplete checkpoint is a barrier refusal, not
39
+ // an ordinary migration failure — hosts distinguish it by `phase` while the
40
+ // client-visible identity stays `sync.schema_not_ready`.
41
+ const phase = cause?.code ===
42
+ 'sync.storage.checkpoint_incomplete'
43
+ ? 'backfill_checkpoint'
44
+ : 'storage_migration';
37
45
  throw new SyncServerReadinessError({
38
- phase: 'storage_migration',
46
+ phase,
39
47
  schemaVersion: config.schema.version,
40
48
  cause,
41
49
  });
42
50
  }
43
51
  }
52
+ /**
53
+ * RFC 0007: refuse a serve path that observed a closed gate. The projection
54
+ * name travels in structured details, never in the message.
55
+ */
56
+ export function serveNotReadyError(refusal) {
57
+ return syncError(SYNC_SERVER_READINESS_ERROR_CODE, 'server schema is not ready for this request', refusal.kind === 'checkpoint'
58
+ ? JSON.stringify({
59
+ partition: refusal.partition,
60
+ projection: refusal.name,
61
+ })
62
+ : refusal.kind === 'epoch'
63
+ ? JSON.stringify({ logEpochChanged: true })
64
+ : JSON.stringify({
65
+ storedSchemaVersion: refusal.stored,
66
+ runningSchemaVersion: refusal.running,
67
+ }));
68
+ }