@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
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;
@@ -352,6 +418,21 @@ export interface StorageTransaction {
352
418
  * first writer wins; callers read the canonical value after commit.
353
419
  */
354
420
  putPushResult(clientId: string, clientCommitId: string, result: StoredPushResult): Promise<void>;
421
+ /**
422
+ * Advance this partition's checkpoint only while `owner_epoch` still
423
+ * matches, so the owner-epoch CAS rides inside the caller's transaction.
424
+ * The host calls this from `processPushOperationsWithTrace`'s operation
425
+ * builder: the backfill is an ordinary server-authoritative write, and this
426
+ * CAS commits with that write's row versions and commit-log append. False
427
+ * means abort and write nothing, rather than a throw for a superseded owner.
428
+ */
429
+ advanceCheckpoint(name: string, ownerEpoch: number, watermark: number, observedRows: number, nowMs: number): Promise<boolean>;
430
+ /**
431
+ * RFC 0007 serve gate on the caller's own connection, for write paths that
432
+ * already hold the partition lock: the gate and the write share one
433
+ * transaction, so a migration cannot interleave between them.
434
+ */
435
+ readServeGate(runningSchemaVersion: number): Promise<ServeGate>;
355
436
  /**
356
437
  * Blob reference index (§5.9.4) — ADDITIVE, optional. Set the blobIds a
357
438
  * row currently references (empty = clear), replacing any prior entries
@@ -379,7 +460,7 @@ export interface ServerStorage {
379
460
  * before binding a public port; protocol handlers still call this method
380
461
  * lazily as a defensive backstop.
381
462
  */
382
- ensureSchema(schema: CompiledSchema): Promise<void>;
463
+ ensureSchema(schema: CompiledSchema, checkpoints?: readonly CheckpointDeclaration[]): Promise<void>;
383
464
  /** Create or refresh the authenticated partition registry row (§2.1). */
384
465
  touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
385
466
  /** Rotate log continuity after restore and discard stale cursor records. */
@@ -388,6 +469,19 @@ export interface ServerStorage {
388
469
  listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
389
470
  /** Read continuity without refreshing authenticated activity. */
390
471
  getPartitionLogEpoch(partition: string): Promise<string | undefined>;
472
+ /**
473
+ * Open a storage transaction. This is the raw primitive, not a command
474
+ * boundary: it acquires NO partition write lock. In-tree SQLite begins
475
+ * `IMMEDIATE`, so a hand-rolled command on SQLite happens to be serialized
476
+ * against a concurrent push; PostgreSQL's `BEGIN` takes no row lock, so the
477
+ * same command races a push on Postgres and a SQLite-only test conceals it.
478
+ *
479
+ * A trusted command must therefore run through `registerRemoteCommand`,
480
+ * which builds after `lockPartitionForPush` and the idempotency recheck and
481
+ * shares the committed transaction, or call `lockPartitionForPush` before
482
+ * its first authority, version, or replay read. SSP2 request handling does
483
+ * this for you; only host-authored commands choose.
484
+ */
391
485
  begin(partition: string): Promise<StorageTransaction>;
392
486
  getMaxCommitSeq(partition: string): Promise<number>;
393
487
  getHorizonSeq(partition: string): Promise<number>;
@@ -400,6 +494,50 @@ export interface ServerStorage {
400
494
  pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
401
495
  /** Newest commitSeq created strictly before the timestamp; 0 if none. */
402
496
  getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
497
+ /** Every declared backfill checkpoint for the partition, ordered by name. */
498
+ readCheckpoints(partition: string): Promise<StoredCheckpoint[]>;
499
+ /**
500
+ * RFC 0007 serve gate: read the published schema version, the partition's
501
+ * log epoch, and every incomplete declared checkpoint at
502
+ * `runningSchemaVersion`. Never memoized; the serve paths call it per
503
+ * request so an already-serving process notices a migration.
504
+ */
505
+ readServeGate(partition: string, runningSchemaVersion: number): Promise<ServeGate>;
506
+ /**
507
+ * Atomically take ownership of a declared or backfilling checkpoint. Increments
508
+ * `owner_epoch` and moves `declared` to `backfilling`. Throws
509
+ * `sync.storage.checkpoint_not_declared` when no claimable row exists (absent,
510
+ * or already `activated`).
511
+ */
512
+ claimCheckpoint(partition: string, name: string, schemaVersion: number, nowMs: number): Promise<StoredCheckpoint>;
513
+ /**
514
+ * Advance the watermark only while `owner_epoch` still matches. Returns false
515
+ * for a superseded owner; the stale case never throws.
516
+ */
517
+ advanceCheckpoint(partition: string, name: string, ownerEpoch: number, watermark: number, observedRows: number, nowMs: number): Promise<boolean>;
518
+ /** Highest commitSeq carrying a change to one of `tables`; 0 when none. */
519
+ sourceCoverageSeq(partition: string, tables: readonly string[]): Promise<number>;
520
+ /**
521
+ * Whether any source change sits above `seq`. `unverifiable` when the pruning
522
+ * horizon has passed `seq`, so the history that would answer the question is
523
+ * gone; a pruned window is never reported as `clean`.
524
+ */
525
+ hasSourceChangesAbove(partition: string, tables: readonly string[], seq: number): Promise<'clean' | 'changed' | 'unverifiable'>;
526
+ /**
527
+ * Install the barrier: insert the checkpoint in `declared` and raise
528
+ * `required_writer_version` for the partition in one transaction. The fence
529
+ * is raised at declaration, before any backfill work, so old writers are
530
+ * rejected for the whole backfill window. Idempotent on an existing declared
531
+ * or backfilling row.
532
+ */
533
+ declareCheckpoint(partition: string, name: string, schemaVersion: number, nowMs: number): Promise<StoredCheckpoint>;
534
+ /**
535
+ * Activate a declared checkpoint under the partition write lock. Refuses when
536
+ * the fence is not already raised at the checkpoint's schema version, when a
537
+ * source change sits above `watermark` (`stale`), or when the pruning horizon
538
+ * has passed `watermark` (`unverifiable`). `activated` is terminal.
539
+ */
540
+ activateCheckpoint(partition: string, name: string, ownerEpoch: number, watermark: number, sources: readonly string[], nowMs: number): Promise<'activated' | 'stale' | 'unverifiable'>;
403
541
  getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
404
542
  /**
405
543
  * Idempotency lookup (§2.3). Throws `SyncError sync.idempotency_cache_miss`
@@ -436,8 +574,20 @@ export interface ServerStorage {
436
574
  * Optional registered-query capability. The implementation MUST replace
437
575
  * every generated app-table relation with a partition-filtered relation and
438
576
  * return rows plus maxCommitSeq from one consistent database snapshot.
577
+ *
578
+ * The storage serializes this call behind an open transaction on a
579
+ * single-connection executor (SQLite, PGlite). A validator runs inside the
580
+ * push transaction, so on that executor a validator that calls
581
+ * `queryAuthoritative` waits on the transaction it is already inside and
582
+ * the push never completes. A pool-backed executor (`pg`, `Bun.sql`) hands
583
+ * the call a second connection instead: the validator does not deadlock,
584
+ * but it reads committed state without the candidate write. On either
585
+ * executor read candidate state through the whole-commit `CommitValidator`
586
+ * reader (§6.8). A call made from any other context waits for the open
587
+ * transaction to finish on a single-connection executor, and runs
588
+ * concurrently on a pool-backed one.
439
589
  */
440
- queryAuthoritative?(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
590
+ queryAuthoritative?(partition: string, query: AuthoritativeQueryRequest, checkpoints?: readonly CheckpointDeclaration[]): Promise<AuthoritativeQueryResult>;
441
591
  /**
442
592
  * Optional trusted-host exact lookup through a declared relational index.
443
593
  * 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
+ }
@@ -95,7 +95,19 @@ export interface ValidateCommitInput {
95
95
  * staged, but before commit-log/idempotency append and transaction commit.
96
96
  */
97
97
  export type CommitValidator = (input: ValidateCommitInput) => void | Promise<void>;
98
- /** Ambient context a validator may consult (§6.7). */
98
+ /**
99
+ * Ambient context a validator may consult (§6.7).
100
+ *
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).
110
+ */
99
111
  export interface ValidateContext {
100
112
  /** Host-authenticated actor (§1.1) performing the write. */
101
113
  readonly actorId: string;
@@ -110,6 +122,13 @@ export interface ValidateContext {
110
122
  * any other error is treated as a rejection with a generic
111
123
  * `sync.constraint_violation` code (a validator SHOULD throw
112
124
  * `ValidationRejection` to control the code). MUST NOT mutate the row.
125
+ *
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).
113
132
  */
114
133
  export type Validator = (op: ValidateOperation, ctx: ValidateContext) => void | Promise<void>;
115
134
  /** 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.21.1",
3
+ "version": "0.23.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.21.1"
71
+ "@syncular/core": "0.23.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
package/src/d1-storage.ts CHANGED
@@ -90,6 +90,7 @@ import {
90
90
  import type {
91
91
  AuthoritativeQueryRequest,
92
92
  AuthoritativeQueryResult,
93
+ CheckpointDeclaration,
93
94
  ClientCursorInfo,
94
95
  ClientRecord,
95
96
  ClientSubscription,
@@ -111,8 +112,10 @@ import type {
111
112
  ScopeActivityQuery,
112
113
  ScopeCommitActivity,
113
114
  ServerStorage,
115
+ ServeGate,
114
116
  StorageTransaction,
115
117
  StoredCommit,
118
+ StoredCheckpoint,
116
119
  StoredPushResult,
117
120
  StoredReaction,
118
121
  StoredRow,
@@ -447,6 +450,31 @@ class D1Transaction implements StorageTransaction {
447
450
  this.#pushApplyCheckpoint = this.#buffer.length;
448
451
  }
449
452
 
453
+ advanceCheckpoint(
454
+ _name: string,
455
+ _ownerEpoch: number,
456
+ _watermark: number,
457
+ _observedRows: number,
458
+ _nowMs: number,
459
+ ): Promise<boolean> {
460
+ // D1 installs no fence and declares no checkpoints in this release.
461
+ return Promise.reject(
462
+ new StorageQueryError('sync.storage.checkpoint_unsupported'),
463
+ );
464
+ }
465
+
466
+ async readServeGate(_runningSchemaVersion: number): Promise<ServeGate> {
467
+ this.#assertOpen();
468
+ const marker = await this.#db
469
+ .prepare('SELECT schema_version FROM sync_schema_meta WHERE id=1')
470
+ .first<{ schema_version: number }>();
471
+ return {
472
+ storedSchemaVersion: marker?.schema_version ?? 0,
473
+ logEpoch: undefined,
474
+ pending: [],
475
+ };
476
+ }
477
+
450
478
  async commitRejectedPushResult(
451
479
  clientId: string,
452
480
  clientCommitId: string,
@@ -834,7 +862,15 @@ export class D1ServerStorage implements ServerStorage {
834
862
  return table;
835
863
  }
836
864
 
837
- async ensureSchema(schema: CompiledSchema): Promise<void> {
865
+ async ensureSchema(
866
+ schema: CompiledSchema,
867
+ checkpoints?: readonly CheckpointDeclaration[],
868
+ ): Promise<void> {
869
+ if (checkpoints !== undefined && checkpoints.length > 0) {
870
+ // No database-side fence can be installed atomically with schema
871
+ // visibility here; refuse instead of accepting a weaker guarantee.
872
+ throw new StorageQueryError('sync.storage.checkpoint_unsupported');
873
+ }
838
874
  if (this.#schemaVersion === schema.version) {
839
875
  await this.#schemaDatabase().batch([]);
840
876
  return;
@@ -1427,7 +1463,11 @@ export class D1ServerStorage implements ServerStorage {
1427
1463
  async queryAuthoritative(
1428
1464
  partition: string,
1429
1465
  query: AuthoritativeQueryRequest,
1466
+ _checkpoints?: readonly CheckpointDeclaration[],
1430
1467
  ): Promise<AuthoritativeQueryResult> {
1468
+ // D1 declares no checkpoints. `#schemaDatabase` prepends a guard that
1469
+ // re-checks the published marker inside this same batch, so condition 2
1470
+ // is already evaluated with the query (§2.4 D1 schema readiness).
1431
1471
  const db = this.#schemaDatabase();
1432
1472
  if (this.#tables === undefined) {
1433
1473
  throw new Error(
@@ -1591,6 +1631,108 @@ export class D1ServerStorage implements ServerStorage {
1591
1631
  return row?.seq ?? 0;
1592
1632
  }
1593
1633
 
1634
+ async readCheckpoints(_partition: string): Promise<StoredCheckpoint[]> {
1635
+ // D1 supports no declared backfill checkpoints in this release: migrations
1636
+ // run out of band and no database-side fence can be installed atomically
1637
+ // with schema visibility. No checkpoint row can exist.
1638
+ return [];
1639
+ }
1640
+
1641
+ async readServeGate(
1642
+ partition: string,
1643
+ _runningSchemaVersion: number,
1644
+ ): Promise<ServeGate> {
1645
+ // D1 has no checkpoints. The published marker is the only gate input;
1646
+ // `#schemaDatabase` already re-checks it per protected batch.
1647
+ const marker = await this.#db
1648
+ .prepare('SELECT schema_version FROM sync_schema_meta WHERE id=1')
1649
+ .first<{ schema_version: number }>();
1650
+ return {
1651
+ storedSchemaVersion: marker?.schema_version ?? 0,
1652
+ logEpoch: await this.getPartitionLogEpoch(partition),
1653
+ pending: [],
1654
+ };
1655
+ }
1656
+
1657
+ async declareCheckpoint(
1658
+ _partition: string,
1659
+ _name: string,
1660
+ _schemaVersion: number,
1661
+ _nowMs: number,
1662
+ ): Promise<StoredCheckpoint> {
1663
+ throw new StorageQueryError('sync.storage.checkpoint_unsupported');
1664
+ }
1665
+
1666
+ async activateCheckpoint(
1667
+ _partition: string,
1668
+ _name: string,
1669
+ _ownerEpoch: number,
1670
+ _watermark: number,
1671
+ _sources: readonly string[],
1672
+ _nowMs: number,
1673
+ ): Promise<'activated' | 'stale' | 'unverifiable'> {
1674
+ throw new StorageQueryError('sync.storage.checkpoint_unsupported');
1675
+ }
1676
+
1677
+ async claimCheckpoint(
1678
+ _partition: string,
1679
+ _name: string,
1680
+ _schemaVersion: number,
1681
+ _nowMs: number,
1682
+ ): Promise<StoredCheckpoint> {
1683
+ throw new StorageQueryError('sync.storage.checkpoint_unsupported');
1684
+ }
1685
+
1686
+ async advanceCheckpoint(
1687
+ _partition: string,
1688
+ _name: string,
1689
+ _ownerEpoch: number,
1690
+ _watermark: number,
1691
+ _observedRows: number,
1692
+ _nowMs: number,
1693
+ ): Promise<boolean> {
1694
+ throw new StorageQueryError('sync.storage.checkpoint_unsupported');
1695
+ }
1696
+
1697
+ async sourceCoverageSeq(
1698
+ partition: string,
1699
+ tables: readonly string[],
1700
+ ): Promise<number> {
1701
+ if (tables.length === 0) return 0;
1702
+ const tableParams = tables.map(() => '?').join(',');
1703
+ const row = await this.#db
1704
+ .prepare(
1705
+ `SELECT max(commit_seq) AS seq FROM sync_changes
1706
+ WHERE partition=? AND tbl IN (${tableParams})`,
1707
+ )
1708
+ .bind(partition, ...tables)
1709
+ .first<{ seq: number | null }>();
1710
+ return row?.seq ?? 0;
1711
+ }
1712
+
1713
+ async hasSourceChangesAbove(
1714
+ partition: string,
1715
+ tables: readonly string[],
1716
+ seq: number,
1717
+ ): Promise<'clean' | 'changed' | 'unverifiable'> {
1718
+ // Read the horizon before the window scan: a horizon past `seq` means the
1719
+ // history that would answer the question is gone, so an empty scan proves
1720
+ // nothing and must not be reported as `clean`.
1721
+ const horizon = await this.getHorizonSeq(partition);
1722
+ if (tables.length === 0) return 'clean';
1723
+ const tableParams = tables.map(() => '?').join(',');
1724
+ const hit = await this.#db
1725
+ .prepare(
1726
+ `SELECT 1 AS hit FROM sync_changes
1727
+ WHERE partition=? AND tbl IN (${tableParams}) AND commit_seq>?
1728
+ LIMIT 1`,
1729
+ )
1730
+ .bind(partition, ...tables, seq)
1731
+ .first<{ hit: number }>();
1732
+ if (hit !== null) return 'changed';
1733
+ return horizon > seq ? 'unverifiable' : 'clean';
1734
+ }
1735
+
1594
1736
  async getRow(
1595
1737
  partition: string,
1596
1738
  table: string,
package/src/errors.ts CHANGED
@@ -167,6 +167,16 @@ export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
167
167
  recommendedAction: 'retryLater',
168
168
  httpStatus: 500,
169
169
  },
170
+ // §2.4 on a non-D1 backend: the server refuses while a declared backfill
171
+ // checkpoint is incomplete or the database is newer than the running build.
172
+ // Formerly host-only; promoted to the §10.2 catalogue so an in-flight
173
+ // request can be refused with a distinguishable identity.
174
+ 'sync.schema_not_ready': {
175
+ category: 'internal',
176
+ retryable: true,
177
+ recommendedAction: 'retryLater',
178
+ httpStatus: 503,
179
+ },
170
180
  'sync.too_many_operations': {
171
181
  category: 'invalid-request',
172
182
  retryable: false,
package/src/handler.ts CHANGED
@@ -51,10 +51,17 @@ import {
51
51
  subscriptionSection,
52
52
  } from './pull';
53
53
  import { type ProcessedPushCommit, processPushCommitWithTrace } from './push';
54
+ import { serveNotReadyError } from './readiness';
54
55
  import type { CompiledSchema } from './schema';
55
56
  import { compileSchema } from './schema';
56
57
  import { computeEffective, type ResolvedScopes } from './scopes';
57
- import type { ClientSubscription, PartitionRegistryEntry } from './storage';
58
+ import { StorageQueryError } from './storage-errors';
59
+ import {
60
+ type ClientSubscription,
61
+ type PartitionRegistryEntry,
62
+ serveGateRefusal,
63
+ serveGateTokenChanged,
64
+ } from './storage';
58
65
 
59
66
  interface RequestPlan {
60
67
  readonly wireVersion: number;
@@ -709,13 +716,64 @@ async function createStreamCore(
709
716
  const schema = compileSchema(ctx.schema);
710
717
  // Relational row tables: create/
711
718
  // migrate on first contact; memoized per storage instance thereafter.
712
- await ctx.storage.ensureSchema(schema);
719
+ try {
720
+ await ctx.storage.ensureSchema(schema, ctx.checkpoints);
721
+ } catch (error) {
722
+ if (
723
+ error instanceof StorageQueryError &&
724
+ error.code === 'sync.storage.checkpoint_incomplete'
725
+ ) {
726
+ const refusal = serveGateRefusal(
727
+ await ctx.storage.readServeGate(ctx.partition, schema.version),
728
+ schema.version,
729
+ ctx.checkpoints,
730
+ );
731
+ throw refusal !== undefined ? serveNotReadyError(refusal) : error;
732
+ }
733
+ throw error;
734
+ }
735
+ // RFC 0007 serve gate: re-read stored state on every request so an
736
+ // already-serving process notices a migration or a newly declared
737
+ // checkpoint. Read after `touchAuthenticatedPartition` establishes the
738
+ // partition's log epoch, so the entry token is the epoch this request
739
+ // serves under. Nothing has escaped at this point; the push transaction
740
+ // re-evaluates the gate under the partition lock.
713
741
  const registry = await touchAuthenticatedPartition(ctx);
742
+ const gate = await ctx.storage.readServeGate(ctx.partition, schema.version);
743
+ const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
744
+ if (refusal !== undefined) throw serveNotReadyError(refusal);
714
745
  const plan = await planRequest(request, ctx, schema, registry);
715
- if (events === undefined) return streamResponse(plan, ctx, schema);
716
746
  const report: RequestReport = { outcome: 'ok' };
747
+ // RFC 0007 read-verify-refuse for the streamed read path: the generator
748
+ // builds the whole response (its data reads) before yielding the first
749
+ // frame, then compares the gate token across those reads. Nothing escapes
750
+ // before the verification, and the buffered size is bounded by the request's
751
+ // pull limits and the server's inline segment cap. Every request verifies,
752
+ // including a mixed push+pull: a write that already applied when the token
753
+ // changed is durable and replayable under the same commit id / idempotency
754
+ // key, so refusing the request is safe (proved in the serve-gate lane).
755
+ const verified = async function* (): AsyncGenerator<Uint8Array> {
756
+ const buffered: Uint8Array[] = [];
757
+ for await (const chunk of streamResponse(plan, ctx, schema, report)) {
758
+ buffered.push(chunk);
759
+ }
760
+ const after = await ctx.storage.readServeGate(
761
+ ctx.partition,
762
+ schema.version,
763
+ );
764
+ const changed = serveGateTokenChanged(gate, after);
765
+ const afterRefusal = serveGateRefusal(
766
+ after,
767
+ schema.version,
768
+ ctx.checkpoints,
769
+ );
770
+ if (afterRefusal !== undefined) throw serveNotReadyError(afterRefusal);
771
+ if (changed !== undefined) throw serveNotReadyError(changed);
772
+ for (const chunk of buffered) yield chunk;
773
+ };
774
+ if (events === undefined) return verified();
717
775
  return instrumentedStream(
718
- streamResponse(plan, ctx, schema, report),
776
+ verified(),
719
777
  ctx,
720
778
  events,
721
779
  plan,