@syncular/server 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +14 -2
  2. package/dist/admin.d.ts +3 -1
  3. package/dist/admin.js +7 -1
  4. package/dist/blob-handlers.js +1 -1
  5. package/dist/context.d.ts +8 -1
  6. package/dist/d1-storage.d.ts +11 -3
  7. package/dist/d1-storage.js +186 -36
  8. package/dist/errors.js +10 -0
  9. package/dist/handler.js +50 -4
  10. package/dist/operations.d.ts +7 -1
  11. package/dist/operations.js +5 -3
  12. package/dist/postgres-storage.d.ts +12 -4
  13. package/dist/postgres-storage.js +310 -59
  14. package/dist/push.js +31 -7
  15. package/dist/readiness.d.ts +9 -2
  16. package/dist/readiness.js +27 -2
  17. package/dist/realtime.js +20 -1
  18. package/dist/relational-rows.d.ts +31 -12
  19. package/dist/relational-rows.js +121 -26
  20. package/dist/segment-download.js +8 -0
  21. package/dist/sqlite-dialect.d.ts +1 -1
  22. package/dist/sqlite-dialect.js +15 -0
  23. package/dist/sqlite-storage.d.ts +18 -3
  24. package/dist/sqlite-storage.js +327 -82
  25. package/dist/storage-errors.d.ts +5 -3
  26. package/dist/storage-errors.js +13 -2
  27. package/dist/storage-query.d.ts +8 -1
  28. package/dist/storage-query.js +12 -0
  29. package/dist/storage.d.ts +153 -14
  30. package/dist/storage.js +40 -1
  31. package/dist/validate.d.ts +33 -15
  32. package/package.json +2 -2
  33. package/src/admin.ts +13 -1
  34. package/src/blob-handlers.ts +1 -1
  35. package/src/context.ts +8 -0
  36. package/src/d1-storage.ts +282 -37
  37. package/src/errors.ts +10 -0
  38. package/src/handler.ts +62 -4
  39. package/src/operations.ts +32 -14
  40. package/src/postgres-storage.ts +515 -68
  41. package/src/push.ts +31 -1
  42. package/src/readiness.ts +39 -4
  43. package/src/realtime.ts +22 -1
  44. package/src/relational-rows.ts +145 -30
  45. package/src/segment-download.ts +16 -0
  46. package/src/sqlite-dialect.ts +15 -0
  47. package/src/sqlite-storage.ts +491 -96
  48. package/src/storage-errors.ts +33 -3
  49. package/src/storage-query.ts +23 -1
  50. package/src/storage.ts +245 -12
  51. package/src/validate.ts +40 -15
package/dist/storage.d.ts CHANGED
@@ -28,6 +28,72 @@ export interface CommitPruneResult {
28
28
  readonly horizonSeq: number;
29
29
  readonly removedCommits: number;
30
30
  }
31
+ /** RFC 0007 backfill lifecycle. `activated` is terminal within a schema version. */
32
+ export type CheckpointState = 'declared' | 'backfilling' | 'activated';
33
+ /**
34
+ * Host-owned checkpoint installation carried on `ensureSchema`: the schema
35
+ * bump, the `declared` row, and the raised writer fence commit together.
36
+ */
37
+ export interface CheckpointDeclaration {
38
+ readonly partition: string;
39
+ readonly name: string;
40
+ readonly schemaVersion: number;
41
+ }
42
+ /** One host-storage backfill checkpoint row. Never a synced application row. */
43
+ export interface StoredCheckpoint {
44
+ readonly partition: string;
45
+ readonly name: string;
46
+ readonly schemaVersion: number;
47
+ readonly state: CheckpointState;
48
+ /** Highest source `commit_seq` the projection was derived from. */
49
+ readonly watermark: number;
50
+ /** Monotonic claim counter; a batch is stale when it does not match. */
51
+ readonly ownerEpoch: number;
52
+ readonly observedRows: number;
53
+ readonly updatedAtMs: number;
54
+ }
55
+ /**
56
+ * RFC 0007 serve-gate token, read inside the transaction that produces the
57
+ * result. `storedSchemaVersion` is the published marker; `logEpoch` is the
58
+ * partition's log generation, so a restore that presents V -> other -> V is
59
+ * still distinguishable. `pending` names every incomplete declared checkpoint
60
+ * at the running schema version, across partitions.
61
+ */
62
+ export interface ServeGate {
63
+ readonly storedSchemaVersion: number;
64
+ readonly logEpoch: string | undefined;
65
+ readonly pending: readonly {
66
+ readonly partition: string;
67
+ readonly name: string;
68
+ }[];
69
+ }
70
+ /** Why `serveGate` refuses, or `undefined` when this request may serve. */
71
+ export type ServeGateRefusal = {
72
+ readonly kind: 'schema';
73
+ readonly stored: number;
74
+ readonly running: number;
75
+ } | {
76
+ readonly kind: 'checkpoint';
77
+ readonly partition: string;
78
+ readonly name: string;
79
+ } | {
80
+ /** The token changed between two reads: a restore rotated the epoch. */
81
+ readonly kind: 'epoch';
82
+ };
83
+ /**
84
+ * Evaluate the RFC 0007 predicate against a gate token. A process that
85
+ * declares the pending checkpoint may serve while it backfills; any other
86
+ * process is refused, and a database newer than the running build is always
87
+ * refused.
88
+ */
89
+ export declare function serveGateRefusal(gate: ServeGate, runningSchemaVersion: number, checkpoints: readonly CheckpointDeclaration[] | undefined): ServeGateRefusal | undefined;
90
+ /**
91
+ * RFC 0007: compare two gate tokens read around a data read. The token binds
92
+ * the published schema version and the partition log epoch, so a restore that
93
+ * presents V → other → V is distinguishable. Returns a refusal when either
94
+ * changed across the read.
95
+ */
96
+ export declare function serveGateTokenChanged(before: ServeGate, after: ServeGate): ServeGateRefusal | undefined;
31
97
  /** The current stored state of a synced row. */
32
98
  export interface StoredRow {
33
99
  readonly rowId: string;
@@ -315,6 +381,19 @@ export interface StorageTransaction {
315
381
  * read-your-own-writes semantics. It is not reachable from SSP2 requests.
316
382
  */
317
383
  scanRowsByIndex?(query: IndexRowScanQuery): Promise<StoredRow[]>;
384
+ /**
385
+ * Optional transaction-bound registered query (§6.7, §6.8): the relation
386
+ * rewrite of `ServerStorage.queryAuthoritative`, bound to this
387
+ * transaction's partition and executed on this transaction's connection.
388
+ * A hook inside the push transaction therefore reads the transaction's
389
+ * snapshot and never waits for a second connection. The push layer has
390
+ * already evaluated the serve gate on this transaction, so the call takes
391
+ * no checkpoint declarations. SQLite and PostgreSQL return the staged
392
+ * writes of this transaction. D1 buffers writes until commit and fails
393
+ * with `sync.storage.query_over_staged_writes` when the query reads a
394
+ * table this transaction has written.
395
+ */
396
+ queryAuthoritative?(query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
318
397
  /**
319
398
  * Serialize every push apply for this partition before any operation read,
320
399
  * validation, merge, or write. The push layer re-checks idempotency only
@@ -352,6 +431,21 @@ export interface StorageTransaction {
352
431
  * first writer wins; callers read the canonical value after commit.
353
432
  */
354
433
  putPushResult(clientId: string, clientCommitId: string, result: StoredPushResult): Promise<void>;
434
+ /**
435
+ * Advance this partition's checkpoint only while `owner_epoch` still
436
+ * matches, so the owner-epoch CAS rides inside the caller's transaction.
437
+ * The host calls this from `processPushOperationsWithTrace`'s operation
438
+ * builder: the backfill is an ordinary server-authoritative write, and this
439
+ * CAS commits with that write's row versions and commit-log append. False
440
+ * means abort and write nothing, rather than a throw for a superseded owner.
441
+ */
442
+ advanceCheckpoint(name: string, ownerEpoch: number, watermark: number, observedRows: number, nowMs: number): Promise<boolean>;
443
+ /**
444
+ * RFC 0007 serve gate on the caller's own connection, for write paths that
445
+ * already hold the partition lock: the gate and the write share one
446
+ * transaction, so a migration cannot interleave between them.
447
+ */
448
+ readServeGate(runningSchemaVersion: number): Promise<ServeGate>;
355
449
  /**
356
450
  * Blob reference index (§5.9.4) — ADDITIVE, optional. Set the blobIds a
357
451
  * row currently references (empty = clear), replacing any prior entries
@@ -379,7 +473,7 @@ export interface ServerStorage {
379
473
  * before binding a public port; protocol handlers still call this method
380
474
  * lazily as a defensive backstop.
381
475
  */
382
- ensureSchema(schema: CompiledSchema): Promise<void>;
476
+ ensureSchema(schema: CompiledSchema, checkpoints?: readonly CheckpointDeclaration[]): Promise<void>;
383
477
  /** Create or refresh the authenticated partition registry row (§2.1). */
384
478
  touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
385
479
  /** Rotate log continuity after restore and discard stale cursor records. */
@@ -413,6 +507,50 @@ export interface ServerStorage {
413
507
  pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
414
508
  /** Newest commitSeq created strictly before the timestamp; 0 if none. */
415
509
  getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
510
+ /** Every declared backfill checkpoint for the partition, ordered by name. */
511
+ readCheckpoints(partition: string): Promise<StoredCheckpoint[]>;
512
+ /**
513
+ * RFC 0007 serve gate: read the published schema version, the partition's
514
+ * log epoch, and every incomplete declared checkpoint at
515
+ * `runningSchemaVersion`. Never memoized; the serve paths call it per
516
+ * request so an already-serving process notices a migration.
517
+ */
518
+ readServeGate(partition: string, runningSchemaVersion: number): Promise<ServeGate>;
519
+ /**
520
+ * Atomically take ownership of a declared or backfilling checkpoint. Increments
521
+ * `owner_epoch` and moves `declared` to `backfilling`. Throws
522
+ * `sync.storage.checkpoint_not_declared` when no claimable row exists (absent,
523
+ * or already `activated`).
524
+ */
525
+ claimCheckpoint(partition: string, name: string, schemaVersion: number, nowMs: number): Promise<StoredCheckpoint>;
526
+ /**
527
+ * Advance the watermark only while `owner_epoch` still matches. Returns false
528
+ * for a superseded owner; the stale case never throws.
529
+ */
530
+ advanceCheckpoint(partition: string, name: string, ownerEpoch: number, watermark: number, observedRows: number, nowMs: number): Promise<boolean>;
531
+ /** Highest commitSeq carrying a change to one of `tables`; 0 when none. */
532
+ sourceCoverageSeq(partition: string, tables: readonly string[]): Promise<number>;
533
+ /**
534
+ * Whether any source change sits above `seq`. `unverifiable` when the pruning
535
+ * horizon has passed `seq`, so the history that would answer the question is
536
+ * gone; a pruned window is never reported as `clean`.
537
+ */
538
+ hasSourceChangesAbove(partition: string, tables: readonly string[], seq: number): Promise<'clean' | 'changed' | 'unverifiable'>;
539
+ /**
540
+ * Install the barrier: insert the checkpoint in `declared` and raise
541
+ * `required_writer_version` for the partition in one transaction. The fence
542
+ * is raised at declaration, before any backfill work, so old writers are
543
+ * rejected for the whole backfill window. Idempotent on an existing declared
544
+ * or backfilling row.
545
+ */
546
+ declareCheckpoint(partition: string, name: string, schemaVersion: number, nowMs: number): Promise<StoredCheckpoint>;
547
+ /**
548
+ * Activate a declared checkpoint under the partition write lock. Refuses when
549
+ * the fence is not already raised at the checkpoint's schema version, when a
550
+ * source change sits above `watermark` (`stale`), or when the pruning horizon
551
+ * has passed `watermark` (`unverifiable`). `activated` is terminal.
552
+ */
553
+ activateCheckpoint(partition: string, name: string, ownerEpoch: number, watermark: number, sources: readonly string[], nowMs: number): Promise<'activated' | 'stale' | 'unverifiable'>;
416
554
  getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
417
555
  /**
418
556
  * Idempotency lookup (§2.3). Throws `SyncError sync.idempotency_cache_miss`
@@ -450,19 +588,20 @@ export interface ServerStorage {
450
588
  * every generated app-table relation with a partition-filtered relation and
451
589
  * return rows plus maxCommitSeq from one consistent database snapshot.
452
590
  *
453
- * The storage serializes this call behind an open transaction on a
454
- * single-connection executor (SQLite, PGlite). A validator runs inside the
455
- * push transaction, so on that executor a validator that calls
456
- * `queryAuthoritative` waits on the transaction it is already inside and
457
- * the push never completes. A pool-backed executor (`pg`, `Bun.sql`) hands
458
- * the call a second connection instead: the validator does not deadlock,
459
- * but it reads committed state without the candidate write. On either
460
- * executor read candidate state through the whole-commit `CommitValidator`
461
- * reader (§6.8). A call made from any other context waits for the open
462
- * transaction to finish on a single-connection executor, and runs
463
- * concurrently on a pool-backed one.
464
- */
465
- queryAuthoritative?(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
591
+ * Do not call this method from a hook that runs inside the push
592
+ * transaction (a row validator, the whole-commit validator, or a reaction
593
+ * planner). On a single-connection executor (SQLite, PGlite) the storage
594
+ * serializes this call behind the open transaction, so the hook waits on
595
+ * the transaction it is inside and the push never completes. A pool-backed
596
+ * executor (`pg`, `Bun.sql`) runs the call on a second connection: it reads
597
+ * committed state without the transaction's staged writes, and it waits
598
+ * forever when the push transactions hold every pool connection. Those
599
+ * hooks call the transaction-bound `queryAuthoritative` on
600
+ * `ValidateContext` or on the candidate-state reader instead (§6.7, §6.8).
601
+ * A call from any other context waits for the open transaction on a
602
+ * single-connection executor and runs concurrently on a pool-backed one.
603
+ */
604
+ queryAuthoritative?(partition: string, query: AuthoritativeQueryRequest, checkpoints?: readonly CheckpointDeclaration[]): Promise<AuthoritativeQueryResult>;
466
605
  /**
467
606
  * Optional trusted-host exact lookup through a declared relational index.
468
607
  * This capability is outside client scope/subscription authorization and
package/dist/storage.js CHANGED
@@ -1 +1,40 @@
1
- export {};
1
+ /**
2
+ * Evaluate the RFC 0007 predicate against a gate token. A process that
3
+ * declares the pending checkpoint may serve while it backfills; any other
4
+ * process is refused, and a database newer than the running build is always
5
+ * refused.
6
+ */
7
+ export function serveGateRefusal(gate, runningSchemaVersion, checkpoints) {
8
+ if (gate.storedSchemaVersion > runningSchemaVersion) {
9
+ return {
10
+ kind: 'schema',
11
+ stored: gate.storedSchemaVersion,
12
+ running: runningSchemaVersion,
13
+ };
14
+ }
15
+ for (const pending of gate.pending) {
16
+ const declared = (checkpoints ?? []).some((declaration) => declaration.partition === pending.partition &&
17
+ declaration.name === pending.name);
18
+ if (!declared) {
19
+ return {
20
+ kind: 'checkpoint',
21
+ partition: pending.partition,
22
+ name: pending.name,
23
+ };
24
+ }
25
+ }
26
+ return undefined;
27
+ }
28
+ /**
29
+ * RFC 0007: compare two gate tokens read around a data read. The token binds
30
+ * the published schema version and the partition log epoch, so a restore that
31
+ * presents V → other → V is distinguishable. Returns a refusal when either
32
+ * changed across the read.
33
+ */
34
+ export function serveGateTokenChanged(before, after) {
35
+ if (before.storedSchemaVersion !== after.storedSchemaVersion ||
36
+ before.logEpoch !== after.logEpoch) {
37
+ return { kind: 'epoch' };
38
+ }
39
+ return undefined;
40
+ }
@@ -14,6 +14,7 @@
14
14
  * object — zero cost, the events-seam discipline.
15
15
  */
16
16
  import { type RejectionDetails, type RowColumn, type RowValue, type ScopeMap } from '@syncular/core';
17
+ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult } from './storage.js';
17
18
  /**
18
19
  * §6.7 reserved code prefixes. A host validator code MUST NOT start with
19
20
  * any of these: they namespace the protocol's own error families (§10.2)
@@ -81,6 +82,15 @@ export interface CommitValidationScanInput {
81
82
  export interface CommitValidationReader {
82
83
  getRow(table: string, rowId: string): Promise<CommitValidationRow | undefined>;
83
84
  scanRows(input: CommitValidationScanInput): Promise<CommitValidationRow[]>;
85
+ /**
86
+ * Run a generated registered query on the commit transaction (§6.8). The
87
+ * request has the shape `ServerStorage.queryAuthoritative` takes; the
88
+ * storage binds every relation to the commit's partition. SQLite and
89
+ * PostgreSQL return the final candidate state; D1 fails with
90
+ * `sync.storage.query_over_staged_writes` when the query reads a table the
91
+ * commit has written.
92
+ */
93
+ queryAuthoritative(query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
84
94
  }
85
95
  export interface ValidateCommitInput {
86
96
  readonly clientId: string;
@@ -98,21 +108,29 @@ export type CommitValidator = (input: ValidateCommitInput) => void | Promise<voi
98
108
  /**
99
109
  * Ambient context a validator may consult (§6.7).
100
110
  *
101
- * A validator runs inside the push transaction. On a single-connection
102
- * executor (SQLite, PGlite) the storage serializes authoritative queries
103
- * behind that transaction, so a validator that calls `queryAuthoritative`
104
- * waits for the transaction it is already inside and the push never
105
- * completes. On a pool-backed executor (`pg`, `Bun.sql`) the query takes a
106
- * second connection and returns committed state without the candidate write.
107
- * Either way a rule that must read rows belongs in the whole-commit
108
- * `CommitValidator`, whose reader runs get and scan operations on the same
109
- * transaction (§6.8).
111
+ * A validator runs inside the push transaction. It MUST NOT call
112
+ * `ServerStorage.queryAuthoritative`: on a single-connection executor
113
+ * (SQLite, PGlite) that call waits for the transaction the validator is
114
+ * inside and the push never completes, and on a pool-backed executor (`pg`,
115
+ * `Bun.sql`) it reads committed state without the staged writes, or waits
116
+ * forever once push transactions hold every pool connection. An authority
117
+ * read calls `queryAuthoritative` on this context instead.
110
118
  */
111
119
  export interface ValidateContext {
112
120
  /** Host-authenticated actor (§1.1) performing the write. */
113
121
  readonly actorId: string;
114
122
  /** The partition (§1.1) the commit targets. */
115
123
  readonly partition: string;
124
+ /**
125
+ * Run a generated registered query on the push transaction (§6.7). The
126
+ * request has the shape `ServerStorage.queryAuthoritative` takes; the
127
+ * storage binds every relation to the commit's partition. SQLite and
128
+ * PostgreSQL return the transaction's snapshot, including the operations
129
+ * staged before this one; D1 fails with
130
+ * `sync.storage.query_over_staged_writes` when the query reads a table the
131
+ * commit has written.
132
+ */
133
+ readonly queryAuthoritative: (query: AuthoritativeQueryRequest) => Promise<AuthoritativeQueryResult>;
116
134
  }
117
135
  /**
118
136
  * A per-table validation hook (§6.7). Called once per push operation on
@@ -123,12 +141,12 @@ export interface ValidateContext {
123
141
  * `sync.constraint_violation` code (a validator SHOULD throw
124
142
  * `ValidationRejection` to control the code). MUST NOT mutate the row.
125
143
  *
126
- * The hook runs inside the push transaction and cannot read candidate rows
127
- * from an authoritative query: on a single-connection executor
128
- * `queryAuthoritative` serializes behind that transaction and the push never
129
- * completes, and on a pool-backed executor it reads committed state without
130
- * the candidate write. Read rows with the whole-commit `CommitValidator`
131
- * reader instead (§6.8).
144
+ * The hook runs inside the push transaction. An authority read uses
145
+ * `ctx.queryAuthoritative`, which executes on that transaction; calling
146
+ * `ServerStorage.queryAuthoritative` from here deadlocks on a
147
+ * single-connection executor and reads committed state on a pool-backed one.
148
+ * A rule over sibling operations belongs in the whole-commit
149
+ * `CommitValidator` (§6.8).
132
150
  */
133
151
  export type Validator = (op: ValidateOperation, ctx: ValidateContext) => void | Promise<void>;
134
152
  /** Table name → validator (§6.7). Absent tables are unvalidated. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -68,7 +68,7 @@
68
68
  "verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
69
69
  },
70
70
  "dependencies": {
71
- "@syncular/core": "0.22.0"
71
+ "@syncular/core": "0.24.0"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"
package/src/admin.ts CHANGED
@@ -22,6 +22,7 @@ import { DEFAULT_RETENTION, type RetentionPolicy } from './prune';
22
22
  import { compileSchema, type ServerSchema } from './schema';
23
23
  import type { SegmentStore, SegmentStoreStats } from './segment-store';
24
24
  import type {
25
+ CheckpointDeclaration,
25
26
  ClientRecord,
26
27
  CommitMetadata,
27
28
  ReactionStatus,
@@ -157,6 +158,8 @@ export interface SyncularAdminOptions {
157
158
  * a storage instance that has not served a sync request yet.
158
159
  */
159
160
  readonly schema?: ServerSchema;
161
+ /** RFC 0007: declarations `inspectRow` carries into `ensureSchema`. */
162
+ readonly checkpoints?: readonly CheckpointDeclaration[];
160
163
  /** The event ring feeding the event tail. Absent ⇒ `events()` is empty. */
161
164
  readonly ring?: RingBufferEvents;
162
165
  readonly segments?: SegmentStore;
@@ -218,6 +221,7 @@ function toAdminClient(
218
221
  export class SyncularAdmin {
219
222
  readonly #storage: ServerStorage;
220
223
  readonly #schema?: ServerSchema;
224
+ readonly #checkpoints?: readonly CheckpointDeclaration[];
221
225
  readonly #ring?: RingBufferEvents;
222
226
  readonly #segments?: SegmentStore;
223
227
  readonly #blobs?: BlobStore;
@@ -228,6 +232,8 @@ export class SyncularAdmin {
228
232
  constructor(options: SyncularAdminOptions) {
229
233
  this.#storage = options.storage;
230
234
  if (options.schema !== undefined) this.#schema = options.schema;
235
+ if (options.checkpoints !== undefined)
236
+ this.#checkpoints = options.checkpoints;
231
237
  if (options.ring !== undefined) this.#ring = options.ring;
232
238
  if (options.segments !== undefined) this.#segments = options.segments;
233
239
  if (options.blobs !== undefined) this.#blobs = options.blobs;
@@ -248,6 +254,9 @@ export class SyncularAdmin {
248
254
  return new SyncularAdmin({
249
255
  storage: config.storage,
250
256
  schema: config.schema,
257
+ ...(config.checkpoints !== undefined
258
+ ? { checkpoints: config.checkpoints }
259
+ : {}),
251
260
  segments: config.segments,
252
261
  ...(config.blobs !== undefined ? { blobs: config.blobs } : {}),
253
262
  ...(config.leases !== undefined ? { leases: config.leases.store } : {}),
@@ -352,7 +361,10 @@ export class SyncularAdmin {
352
361
  'storage',
353
362
  );
354
363
  if (this.#schema !== undefined) {
355
- await this.#storage.ensureSchema(compileSchema(this.#schema));
364
+ await this.#storage.ensureSchema(
365
+ compileSchema(this.#schema),
366
+ this.#checkpoints,
367
+ );
356
368
  }
357
369
  const row = await read(partition, table, rowId);
358
370
  if (row === undefined) {
@@ -233,7 +233,7 @@ async function downloadBlob(
233
233
  const schema = compileSchema(ctx.schema);
234
234
  // A blob download can hit a fresh storage instance before any sync
235
235
  // request has run (Workers): make sure the row tables exist first.
236
- await ctx.storage.ensureSchema(schema);
236
+ await ctx.storage.ensureSchema(schema, ctx.checkpoints);
237
237
  const rows = await listRows.call(ctx.storage, ctx.partition, blobId);
238
238
  let authorized = false;
239
239
  for (const row of rows) {
package/src/context.ts CHANGED
@@ -19,6 +19,7 @@ import type {
19
19
  } from './signed-url';
20
20
  import type { SqliteImageBuilder } from './sqlite-image';
21
21
  import type {
22
+ CheckpointDeclaration,
22
23
  PartitionRegistryEntry,
23
24
  ServerStorage,
24
25
  StoredCommit,
@@ -96,6 +97,13 @@ export interface RealtimeNotifier {
96
97
  export interface SyncServerConfig {
97
98
  readonly schema: ServerSchema;
98
99
  readonly storage: ServerStorage;
100
+ /**
101
+ * RFC 0007: the backfill checkpoints this host installs. Every readiness
102
+ * path carries this set into `ensureSchema`. A process that omits it
103
+ * declares nothing, and the storage refuses to serve a partition whose
104
+ * stored state shows a checkpoint that is not activated.
105
+ */
106
+ readonly checkpoints?: readonly CheckpointDeclaration[];
99
107
  readonly segments: SegmentStore;
100
108
  /**
101
109
  * Blob store (§5.9). Absent ⇒ blobs unsupported: a table with a