@syncular/server 0.22.0 → 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.
@@ -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
  import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
4
6
  import { syncError } from './errors.js';
5
7
  import { asBytes, asNumber, } from './pg-executor.js';
@@ -59,6 +61,7 @@ CREATE TABLE IF NOT EXISTS sync_commits(
59
61
  partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,
60
62
  client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,
61
63
  actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,
64
+ writer_version BIGINT,
62
65
  PRIMARY KEY(partition, commit_seq)
63
66
  );
64
67
  CREATE INDEX IF NOT EXISTS sync_commits_by_time
@@ -122,6 +125,20 @@ CREATE TABLE IF NOT EXISTS sync_tombstones(
122
125
  commit_seq BIGINT NOT NULL,
123
126
  PRIMARY KEY(partition, tbl, row_id)
124
127
  );
128
+ CREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(
129
+ partition TEXT NOT NULL, name TEXT NOT NULL,
130
+ schema_version INTEGER NOT NULL,
131
+ state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),
132
+ watermark BIGINT NOT NULL DEFAULT 0,
133
+ owner_epoch BIGINT NOT NULL DEFAULT 0,
134
+ observed_rows BIGINT NOT NULL DEFAULT 0,
135
+ updated_at_ms BIGINT NOT NULL,
136
+ PRIMARY KEY(partition, name)
137
+ );
138
+ CREATE TABLE IF NOT EXISTS sync_writer_fence(
139
+ partition TEXT PRIMARY KEY,
140
+ required_writer_version INTEGER NOT NULL
141
+ );
125
142
  `;
126
143
  async function lockPartitionOn(client, partition) {
127
144
  const locked = await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [partition]);
@@ -133,12 +150,76 @@ async function lockPartitionOn(client, partition) {
133
150
  ON CONFLICT (partition) DO NOTHING`, [partition]);
134
151
  await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [partition]);
135
152
  }
153
+ /**
154
+ * Insert the `declared` row and raise the fence, on the caller's open
155
+ * transaction. Shared by `declareCheckpoint` and the `ensureSchema`
156
+ * migration so the schema bump and the fence commit together. Never lowers a
157
+ * fence and never re-declares an existing row.
158
+ */
159
+ async function installCheckpointOn(client, partition, name, schemaVersion, nowMs) {
160
+ await client.query(`INSERT INTO sync_backfill_checkpoints(
161
+ partition, name, schema_version, state, watermark,
162
+ owner_epoch, observed_rows, updated_at_ms)
163
+ VALUES ($1,$2,$3,'declared',0,0,0,$4)
164
+ ON CONFLICT (partition, name) DO NOTHING`, [partition, name, schemaVersion, nowMs]);
165
+ await client.query(`INSERT INTO sync_writer_fence(partition, required_writer_version)
166
+ VALUES ($1,$2)
167
+ ON CONFLICT (partition) DO UPDATE SET
168
+ required_writer_version=GREATEST(
169
+ sync_writer_fence.required_writer_version,
170
+ EXCLUDED.required_writer_version)`, [partition, schemaVersion]);
171
+ }
172
+ /** RFC 0007 serve gate on either the pool or a transaction's client. */
173
+ async function readServeGateOn(client, partition, runningSchemaVersion) {
174
+ const marker = await client.query('SELECT schema_version FROM sync_schema_meta WHERE id=1');
175
+ const epoch = await client.query('SELECT log_epoch FROM sync_partition_registry WHERE partition=$1', [partition]);
176
+ const pending = await client.query(`SELECT partition, name FROM sync_backfill_checkpoints
177
+ WHERE schema_version=$1 AND state<>'activated'`, [runningSchemaVersion]);
178
+ return {
179
+ storedSchemaVersion: marker.rows[0] === undefined
180
+ ? 0
181
+ : asNumber(marker.rows[0].schema_version),
182
+ logEpoch: epoch.rows[0]?.log_epoch,
183
+ pending: pending.rows.map((row) => ({
184
+ partition: row.partition,
185
+ name: row.name,
186
+ })),
187
+ };
188
+ }
189
+ async function hasSourceChangesAboveOn(client, partition, tables, seq) {
190
+ // Read the horizon before the window scan: a horizon past `seq` means the
191
+ // history that would answer the question is gone, so an empty scan proves
192
+ // nothing and must not be reported as `clean`.
193
+ const horizon = await client.query('SELECT horizon_seq FROM sync_partitions WHERE partition=$1', [partition]);
194
+ const horizonSeq = horizon.rows[0] === undefined ? 0 : asNumber(horizon.rows[0].horizon_seq);
195
+ if (tables.length === 0)
196
+ return 'clean';
197
+ const tableParams = tables.map((_, index) => `$${index + 2}`).join(',');
198
+ const hit = await client.query(`SELECT 1 AS hit FROM sync_changes
199
+ WHERE partition=$1 AND tbl IN (${tableParams}) AND commit_seq>$${tables.length + 2}
200
+ LIMIT 1`, [partition, ...tables, seq]);
201
+ if (hit.rows[0] !== undefined)
202
+ return 'changed';
203
+ return horizonSeq > seq ? 'unverifiable' : 'clean';
204
+ }
136
205
  function toBase64(bytes) {
137
206
  return Buffer.from(bytes).toString('base64');
138
207
  }
139
208
  function fromBase64(text) {
140
209
  return new Uint8Array(Buffer.from(text, 'base64'));
141
210
  }
211
+ function toStoredCheckpoint(record) {
212
+ return {
213
+ partition: record.partition,
214
+ name: record.name,
215
+ schemaVersion: asNumber(record.schema_version),
216
+ state: record.state,
217
+ watermark: asNumber(record.watermark),
218
+ ownerEpoch: asNumber(record.owner_epoch),
219
+ observedRows: asNumber(record.observed_rows),
220
+ updatedAtMs: asNumber(record.updated_at_ms),
221
+ };
222
+ }
142
223
  function toStoredReaction(record) {
143
224
  return {
144
225
  idempotencyKey: record.idempotency_key,
@@ -341,13 +422,15 @@ async function getPushResultOn(q, partition, clientId, clientCommitId) {
341
422
  class PostgresTransaction {
342
423
  #client;
343
424
  #partition;
425
+ /** Schema version this transaction writes; rides every appended commit row. */
426
+ #writerVersion;
344
427
  #resolveTable;
345
428
  #open = true;
346
429
  #pushApplySavepoint = false;
347
430
  /** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
348
431
  #resolve;
349
432
  #reject;
350
- constructor(client, partition, resolveTable, resolve, reject) {
433
+ constructor(client, partition, writerVersion, resolveTable, resolve, reject) {
351
434
  // One pinned connection carries one statement at a time: node-postgres
352
435
  // warns about a concurrently issued second query today and rejects it in
353
436
  // node-postgres 9. A whole-commit validator that fans independent reads
@@ -363,6 +446,7 @@ class PostgresTransaction {
363
446
  },
364
447
  };
365
448
  this.#partition = partition;
449
+ this.#writerVersion = writerVersion;
366
450
  this.#resolveTable = resolveTable;
367
451
  this.#resolve = resolve;
368
452
  this.#reject = reject;
@@ -439,6 +523,24 @@ class PostgresTransaction {
439
523
  await this.#client.query('SAVEPOINT syncular_push_candidate');
440
524
  this.#pushApplySavepoint = true;
441
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
+ }
442
544
  async commitRejectedPushResult(clientId, clientCommitId, result) {
443
545
  this.#assertOpen();
444
546
  if (!this.#pushApplySavepoint) {
@@ -491,14 +593,15 @@ class PostgresTransaction {
491
593
  SET max_commit_seq = sync_partitions.max_commit_seq + 1
492
594
  RETURNING max_commit_seq
493
595
  )
494
- INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms)
495
- SELECT $1, max_commit_seq, $2, $3, $4, $5 FROM allocated
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
496
598
  RETURNING commit_seq`, [
497
599
  p,
498
600
  commit.clientId,
499
601
  commit.clientCommitId,
500
602
  commit.actorId,
501
603
  commit.createdAtMs,
604
+ this.#writerVersion,
502
605
  ]);
503
606
  const commitSeq = asNumber(rows[0]?.commit_seq);
504
607
  for (let idx = 0; idx < commit.changes.length; idx++) {
@@ -615,6 +718,29 @@ export class PostgresServerStorage {
615
718
  for (const statement of statements) {
616
719
  await this.#exec.query(statement);
617
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()`);
618
744
  }
619
745
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
620
746
  table(name) {
@@ -624,11 +750,22 @@ export class PostgresServerStorage {
624
750
  }
625
751
  return table;
626
752
  }
627
- async ensureSchema(schema) {
753
+ async ensureSchema(schema, checkpoints) {
628
754
  // Memoized fast path: same instance, same schema version.
629
755
  if (this.#schemaVersion === schema.version)
630
756
  return;
631
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
+ }
632
769
  await this.#exec.query(SCHEMA_META_DDL_POSTGRES);
633
770
  const marker = await this.#exec.query('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1');
634
771
  const stored = marker.rows[0] === undefined
@@ -703,6 +840,17 @@ export class PostgresServerStorage {
703
840
  : undefined);
704
841
  const retiredTables = retiredTableNames(schema, layouts);
705
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');
706
854
  const existing = new Map();
707
855
  const existingIndexes = new Map();
708
856
  for (const table of schema.tables.values()) {
@@ -756,6 +904,13 @@ export class PostgresServerStorage {
756
904
  await client.query(`INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1, $1, $2)
757
905
  ON CONFLICT (id) DO UPDATE
758
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
+ }
759
914
  });
760
915
  }
761
916
  this.#tables = schema.tables;
@@ -835,7 +990,7 @@ export class PostgresServerStorage {
835
990
  });
836
991
  const done = this.#exec
837
992
  .transaction(async (client) => {
838
- 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);
839
994
  resolveReady(tx);
840
995
  // Hold the transaction open until commit()/rollback() settles `scope`.
841
996
  await scope;
@@ -853,13 +1008,22 @@ export class PostgresServerStorage {
853
1008
  const { rows } = await this.#exec.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1', [partition]);
854
1009
  return rows[0] === undefined ? 0 : asNumber(rows[0].max_commit_seq);
855
1010
  }
856
- async queryAuthoritative(partition, query) {
1011
+ async queryAuthoritative(partition, query, checkpoints) {
857
1012
  if (this.#tables === undefined) {
858
1013
  throw new Error('ensureSchema(schema) must run before registered queries');
859
1014
  }
860
1015
  const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
861
1016
  return this.#exec.transaction(async (client) => {
862
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);
863
1027
  const result = await client.query(postgresPlaceholders(prepared.sql), prepared.params);
864
1028
  const cursor = await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1', [partition]);
865
1029
  return {
@@ -910,6 +1074,102 @@ export class PostgresServerStorage {
910
1074
  const seq = rows[0]?.seq;
911
1075
  return seq === null || seq === undefined ? 0 : asNumber(seq);
912
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
+ }
913
1173
  getRow(partition, table, rowId) {
914
1174
  return getRowOn(this.#exec, this.table(table), partition, rowId);
915
1175
  }
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
+ }
package/dist/realtime.js CHANGED
@@ -446,7 +446,26 @@ export class RealtimeSession {
446
446
  // begin its next round as soon as END *arrives* (§8.7 rule 3), so
447
447
  // in-flight must end with the send, not with generator cleanup.
448
448
  const iterator = stream[Symbol.asyncIterator]();
449
- let step = await iterator.next();
449
+ let step;
450
+ try {
451
+ step = await iterator.next();
452
+ }
453
+ catch (error) {
454
+ // The buffered read-verify refusal is thrown on the generator's first
455
+ // `next()`, before any chunk is yielded (the generator builds the whole
456
+ // response, then verifies). Nothing has escaped, so it is delivered as
457
+ // the same §8.4 ERROR frame HTTP returns rather than a silent close. A
458
+ // throw after the first chunk still takes the §8.7 violation path.
459
+ if (error instanceof DecodeError || error instanceof SyncError) {
460
+ const sync = error instanceof SyncError
461
+ ? error
462
+ : syncError(error.code, error.message);
463
+ finishRound();
464
+ await this.#sendRoundChunk(errorResponseBytes(sync, this.wireVersion, this.logEpoch));
465
+ return;
466
+ }
467
+ throw error;
468
+ }
450
469
  while (!step.done) {
451
470
  const chunk = step.value;
452
471
  step = await iterator.next();
@@ -136,6 +136,14 @@ async function downloadSegment(ctx, request) {
136
136
  throw syncError('sync.invalid_subscription', 'invalid requested scopes (§3.2)');
137
137
  }
138
138
  const publication = candidates[candidates.length - 1];
139
+ // RFC 0007: a descriptor minted before this partition declared a backfill
140
+ // checkpoint is not a complete window once the projection becomes
141
+ // checkpointed. SPEC §9 mints segments per schema version, so refuse and
142
+ // let the client re-pull for a fresh descriptor.
143
+ const checkpoints = await ctx.storage.readCheckpoints(ctx.partition);
144
+ if (checkpoints.some((checkpoint) => checkpoint.schemaVersion > publication.schemaVersion)) {
145
+ throw syncError('sync.segment_expired', 'segment predates the partition backfill checkpoint — re-pull (§5.1)');
146
+ }
139
147
  return {
140
148
  // The returned record is projected onto the selected publication, so its
141
149
  // `scopeDigests`/`publications` compatibility view cannot union a grant
@@ -36,7 +36,7 @@ import type { StoredChange, StoredCommit, StoredPushResult, StoredRow } from './
36
36
  * inverted index. Declared here so every SQLite-family storage (and D1,
37
37
  * which applies this same DDL) keeps the same plan shape as Postgres.
38
38
  */
39
- export declare const SQLITE_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq INTEGER NOT NULL DEFAULT 0,\n horizon_seq INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required INTEGER NOT NULL DEFAULT 0,\n last_authenticated_at_ms INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE INDEX IF NOT EXISTS sync_row_scopes_by_row\n ON sync_row_scopes(partition, tbl, row_id);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op INTEGER NOT NULL,\n row_version INTEGER, scopes TEXT NOT NULL, payload BLOB,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result TEXT NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,\n available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,\n last_failure TEXT,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\n";
39
+ export declare const SQLITE_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq INTEGER NOT NULL DEFAULT 0,\n horizon_seq INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required INTEGER NOT NULL DEFAULT 0,\n last_authenticated_at_ms INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE INDEX IF NOT EXISTS sync_row_scopes_by_row\n ON sync_row_scopes(partition, tbl, row_id);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,\n writer_version INTEGER,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op INTEGER NOT NULL,\n row_version INTEGER, scopes TEXT NOT NULL, payload BLOB,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result TEXT NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,\n available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,\n last_failure TEXT,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(\n partition TEXT NOT NULL, name TEXT NOT NULL,\n schema_version INTEGER NOT NULL,\n state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),\n watermark INTEGER NOT NULL DEFAULT 0,\n owner_epoch INTEGER NOT NULL DEFAULT 0,\n observed_rows INTEGER NOT NULL DEFAULT 0,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, name)\n);\nCREATE TABLE IF NOT EXISTS sync_writer_fence(\n partition TEXT PRIMARY KEY,\n required_writer_version INTEGER NOT NULL\n);\n";
40
40
  /** Split the DDL into individual statements (D1 applies them one by one). */
41
41
  export declare function sqliteDdlStatements(): string[];
42
42
  /** `?,?,…` for an `IN (…)` clause of `count` positional parameters. */
@@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS sync_commits(
41
41
  partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,
42
42
  client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,
43
43
  actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,
44
+ writer_version INTEGER,
44
45
  PRIMARY KEY(partition, commit_seq)
45
46
  );
46
47
  CREATE INDEX IF NOT EXISTS sync_commits_by_time
@@ -102,6 +103,20 @@ CREATE TABLE IF NOT EXISTS sync_tombstones(
102
103
  commit_seq INTEGER NOT NULL,
103
104
  PRIMARY KEY(partition, tbl, row_id)
104
105
  );
106
+ CREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(
107
+ partition TEXT NOT NULL, name TEXT NOT NULL,
108
+ schema_version INTEGER NOT NULL,
109
+ state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),
110
+ watermark INTEGER NOT NULL DEFAULT 0,
111
+ owner_epoch INTEGER NOT NULL DEFAULT 0,
112
+ observed_rows INTEGER NOT NULL DEFAULT 0,
113
+ updated_at_ms INTEGER NOT NULL,
114
+ PRIMARY KEY(partition, name)
115
+ );
116
+ CREATE TABLE IF NOT EXISTS sync_writer_fence(
117
+ partition TEXT PRIMARY KEY,
118
+ required_writer_version INTEGER NOT NULL
119
+ );
105
120
  `;
106
121
  /** Split the DDL into individual statements (D1 applies them one by one). */
107
122
  export function sqliteDdlStatements() {
@@ -1,14 +1,14 @@
1
1
  import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
2
2
  import type { CompiledSchema, CompiledTable } from './schema.js';
3
3
  import { type SqliteDatabase } from './sqlite-driver.js';
4
- import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
4
+ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
5
5
  export declare class SqliteServerStorage implements ServerStorage {
6
6
  #private;
7
7
  readonly db: SqliteDatabase;
8
8
  constructor(db?: SqliteDatabase | string);
9
9
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
10
10
  table(name: string): CompiledTable;
11
- ensureSchema(schema: CompiledSchema): Promise<void>;
11
+ ensureSchema(schema: CompiledSchema, checkpoints?: readonly CheckpointDeclaration[]): Promise<void>;
12
12
  touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
13
13
  rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
14
14
  listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
@@ -16,12 +16,20 @@ export declare class SqliteServerStorage implements ServerStorage {
16
16
  /** Internal: write a row + refresh its scope-index entries. */
17
17
  writeRow(partition: string, table: string, row: StoredRow): void;
18
18
  getMaxCommitSeq(partition: string): Promise<number>;
19
- queryAuthoritative(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
19
+ queryAuthoritative(partition: string, query: AuthoritativeQueryRequest, checkpoints?: readonly CheckpointDeclaration[]): Promise<AuthoritativeQueryResult>;
20
20
  getPartitionLogEpoch(partition: string): Promise<string | undefined>;
21
21
  getHorizonSeq(partition: string): Promise<number>;
22
22
  setHorizonSeq(partition: string, seq: number): Promise<void>;
23
23
  pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
24
24
  getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
25
+ readServeGate(partition: string, runningSchemaVersion: number): Promise<ServeGate>;
26
+ readCheckpoints(partition: string): Promise<StoredCheckpoint[]>;
27
+ declareCheckpoint(partition: string, name: string, schemaVersion: number, nowMs: number): Promise<StoredCheckpoint>;
28
+ activateCheckpoint(partition: string, name: string, ownerEpoch: number, watermark: number, sources: readonly string[], nowMs: number): Promise<'activated' | 'stale' | 'unverifiable'>;
29
+ claimCheckpoint(partition: string, name: string, schemaVersion: number, nowMs: number): Promise<StoredCheckpoint>;
30
+ advanceCheckpoint(partition: string, name: string, ownerEpoch: number, watermark: number, observedRows: number, nowMs: number): Promise<boolean>;
31
+ sourceCoverageSeq(partition: string, tables: readonly string[]): Promise<number>;
32
+ hasSourceChangesAbove(partition: string, tables: readonly string[], seq: number): Promise<'clean' | 'changed' | 'unverifiable'>;
25
33
  getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
26
34
  getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
27
35
  claimReactions(partition: string, query: ReactionClaimQuery): Promise<StoredReaction[]>;