@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/src/pull.ts CHANGED
@@ -180,6 +180,7 @@ async function signedUrlFields(
180
180
 
181
181
  function segmentRefFrame(
182
182
  record: SegmentRecord,
183
+ scopeDigest: string,
183
184
  extra: { url?: string; urlExpiresAtMs?: number },
184
185
  ): ResponseFrame {
185
186
  return {
@@ -190,7 +191,11 @@ function segmentRefFrame(
190
191
  byteLength: record.byteLength,
191
192
  rowCount: record.rowCount,
192
193
  asOfCommitSeq: record.asOfCommitSeq,
193
- scopeDigest: record.scopeDigest,
194
+ // The frame reports the digest THIS subscription is authorized under. A
195
+ // stored entry can carry several digests when identical content was
196
+ // published under different scopes (§5.1), and the record's primary
197
+ // digest is not necessarily the caller's (§5.5).
198
+ scopeDigest,
194
199
  ...(record.rowCursor !== null ? { rowCursor: record.rowCursor } : {}),
195
200
  ...(record.nextRowCursor !== null
196
201
  ? { nextRowCursor: record.nextRowCursor }
@@ -244,6 +249,7 @@ async function* sqliteImageSegment(
244
249
  });
245
250
  yield segmentRefFrame(
246
251
  existing,
252
+ digest,
247
253
  await signedUrlFields(ctx, limits, existing.segmentId, digest, now),
248
254
  );
249
255
  return true;
@@ -342,6 +348,7 @@ async function* sqliteImageSegment(
342
348
  });
343
349
  yield segmentRefFrame(
344
350
  record,
351
+ digest,
345
352
  await signedUrlFields(
346
353
  ctx,
347
354
  limits,
@@ -458,6 +465,7 @@ async function* bootstrapSegments(
458
465
  });
459
466
  yield segmentRefFrame(
460
467
  record,
468
+ digest,
461
469
  await signedUrlFields(ctx, limits, record.segmentId, digest, now),
462
470
  );
463
471
  }
package/src/push.ts CHANGED
@@ -52,7 +52,9 @@ import type {
52
52
  StoredPushResult,
53
53
  StoredRow,
54
54
  } from './storage';
55
+ import { serveGateRefusal } from './storage';
55
56
  import { StorageConstraintError } from './storage-errors';
57
+ import { serveNotReadyError } from './readiness';
56
58
  import type {
57
59
  CommitValidationReader,
58
60
  CommitValidator,
@@ -1356,6 +1358,15 @@ export async function processPushOperationsWithTrace(
1356
1358
  );
1357
1359
  }
1358
1360
  await lockPartitionForPush();
1361
+ // RFC 0007: evaluate the gate on this transaction's own connection while
1362
+ // the partition lock is held, so the migration check and the write share
1363
+ // one transaction and no migration can interleave in between.
1364
+ const gate = await tx.readServeGate(schema.version);
1365
+ const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
1366
+ if (refusal !== undefined) {
1367
+ await tx.rollback();
1368
+ throw serveNotReadyError(refusal);
1369
+ }
1359
1370
  // The optimistic lookup above may have raced another delivery. Re-check
1360
1371
  // only after acquiring partition serialization and before any operation
1361
1372
  // read, validation, merge, or staged write. The re-check runs on the
@@ -1551,6 +1562,13 @@ export async function processPushOperationsWithTrace(
1551
1562
  }
1552
1563
  }
1553
1564
  if (ctx.realtime !== undefined && changes.length > 0) {
1565
+ // RFC 0007 fanout decision: the notification carries the durable commit
1566
+ // entry itself (this commit's `changes`), not a projection read or a pull
1567
+ // window, so fanning it out before the request's buffered read-verify can
1568
+ // never serve a mixed result. Each peer's next pull is gated at the
1569
+ // storage seam; a refused mixed push+pull is safe because the push is
1570
+ // durable and replayable under its commit id. This is commit-entry
1571
+ // durability, not gate inheritance.
1554
1572
  await ctx.realtime.notifyCommit(partition, {
1555
1573
  commitSeq,
1556
1574
  createdAtMs,
package/src/readiness.ts CHANGED
@@ -7,12 +7,17 @@
7
7
  * a structured failure that cannot be confused with request authentication.
8
8
  */
9
9
  import type { SyncServerConfig } from './context';
10
+ import { type SyncError, syncError } from './errors';
10
11
  import { type CompiledSchema, compileSchema } from './schema';
12
+ import type { ServeGateRefusal } from './storage';
11
13
 
12
14
  export const SYNC_SERVER_READINESS_ERROR_CODE =
13
15
  'sync.schema_not_ready' as const;
14
16
 
15
- export type SyncServerReadinessPhase = 'schema_compile' | 'storage_migration';
17
+ export type SyncServerReadinessPhase =
18
+ | 'schema_compile'
19
+ | 'storage_migration'
20
+ | 'backfill_checkpoint';
16
21
 
17
22
  export class SyncServerReadinessError extends Error {
18
23
  override readonly name = 'SyncServerReadinessError';
@@ -42,7 +47,7 @@ export class SyncServerReadinessError extends Error {
42
47
  * `cause` locally for the table/column or storage diagnostic.
43
48
  */
44
49
  export async function ensureSyncServerReady(
45
- config: Pick<SyncServerConfig, 'schema' | 'storage'>,
50
+ config: Pick<SyncServerConfig, 'schema' | 'storage' | 'checkpoints'>,
46
51
  ): Promise<void> {
47
52
  let compiled: CompiledSchema;
48
53
  try {
@@ -55,12 +60,42 @@ export async function ensureSyncServerReady(
55
60
  });
56
61
  }
57
62
  try {
58
- await config.storage.ensureSchema(compiled);
63
+ await config.storage.ensureSchema(compiled, config.checkpoints);
59
64
  } catch (cause) {
65
+ // RFC 0007: a declared-but-incomplete checkpoint is a barrier refusal, not
66
+ // an ordinary migration failure — hosts distinguish it by `phase` while the
67
+ // client-visible identity stays `sync.schema_not_ready`.
68
+ const phase: SyncServerReadinessPhase =
69
+ (cause as { code?: unknown } | null | undefined)?.code ===
70
+ 'sync.storage.checkpoint_incomplete'
71
+ ? 'backfill_checkpoint'
72
+ : 'storage_migration';
60
73
  throw new SyncServerReadinessError({
61
- phase: 'storage_migration',
74
+ phase,
62
75
  schemaVersion: config.schema.version,
63
76
  cause,
64
77
  });
65
78
  }
66
79
  }
80
+
81
+ /**
82
+ * RFC 0007: refuse a serve path that observed a closed gate. The projection
83
+ * name travels in structured details, never in the message.
84
+ */
85
+ export function serveNotReadyError(refusal: ServeGateRefusal): SyncError {
86
+ return syncError(
87
+ SYNC_SERVER_READINESS_ERROR_CODE,
88
+ 'server schema is not ready for this request',
89
+ refusal.kind === 'checkpoint'
90
+ ? JSON.stringify({
91
+ partition: refusal.partition,
92
+ projection: refusal.name,
93
+ })
94
+ : refusal.kind === 'epoch'
95
+ ? JSON.stringify({ logEpochChanged: true })
96
+ : JSON.stringify({
97
+ storedSchemaVersion: refusal.stored,
98
+ runningSchemaVersion: refusal.running,
99
+ }),
100
+ );
101
+ }
package/src/realtime.ts CHANGED
@@ -3,7 +3,10 @@
3
3
  *
4
4
  * A `RealtimeSession` wraps a connected socket's send/receive callbacks:
5
5
  * the host feeds inbound text frames to `handleMessage`, binary frames to
6
- * `handleBinary`, and wires `session.close()` to socket close. Initial
6
+ * `handleBinary`, and wires `session.close()` to socket close. `handleMessage`
7
+ * queues the ack's cursor write without awaiting it, so `drain()` is the
8
+ * awaitable boundary a host uses before closing its storage or ending a
9
+ * hibernatable event (§8.2). Initial
7
10
  * subscription registration comes from the client's most recent pull
8
11
  * (§8.1, loaded from the client record); a sync round completed on the
9
12
  * connection replaces it at round end (§8.7). Deltas are complete SSP2
@@ -253,6 +256,14 @@ export class RealtimeSession {
253
256
  timer?: ReturnType<typeof setTimeout>;
254
257
  }
255
258
  >();
259
+ /** Queued control-plane writes (ack cursor persistence, §8.2). */
260
+ #controlTail: Promise<void> = Promise.resolve();
261
+ /** First control-plane failure not yet surfaced by `drain()`. */
262
+ #controlFailure: unknown = undefined;
263
+ /** Issue counter for registration refreshes (§8.7). */
264
+ #registrationGeneration = 0;
265
+ /** Generation of the most recent refresh whose result was applied. */
266
+ #registrationApplied = 0;
256
267
 
257
268
  constructor(
258
269
  hub: RealtimeHub,
@@ -313,7 +324,21 @@ export class RealtimeSession {
313
324
  this.lastKnownSeq = Math.max(this.lastKnownSeq, this.cursor);
314
325
  if (this.cursor >= this.lastKnownSeq) this.wakePending = false;
315
326
  // §8.2: acks update the client cursor record without an HTTP pull.
316
- void this.#persistCursor();
327
+ this.#persistCursor();
328
+ }
329
+
330
+ /**
331
+ * Resolve once every queued control-plane write has settled (§8.2). A host
332
+ * awaits this before it closes its storage or ends a hibernatable event, so
333
+ * an ack's cursor write is not abandoned mid-flight. A persistence failure
334
+ * surfaces here instead of vanishing.
335
+ */
336
+ drain(): Promise<void> {
337
+ return this.#controlTail.then(() => {
338
+ const failure = this.#controlFailure;
339
+ this.#controlFailure = undefined;
340
+ if (failure !== undefined) throw failure;
341
+ });
317
342
  }
318
343
 
319
344
  /** §8.6.2 inbound presence publish/leave from the client. */
@@ -575,7 +600,28 @@ export class RealtimeSession {
575
600
  // begin its next round as soon as END *arrives* (§8.7 rule 3), so
576
601
  // in-flight must end with the send, not with generator cleanup.
577
602
  const iterator = stream[Symbol.asyncIterator]();
578
- let step = await iterator.next();
603
+ let step: IteratorResult<Uint8Array>;
604
+ try {
605
+ step = await iterator.next();
606
+ } catch (error) {
607
+ // The buffered read-verify refusal is thrown on the generator's first
608
+ // `next()`, before any chunk is yielded (the generator builds the whole
609
+ // response, then verifies). Nothing has escaped, so it is delivered as
610
+ // the same §8.4 ERROR frame HTTP returns rather than a silent close. A
611
+ // throw after the first chunk still takes the §8.7 violation path.
612
+ if (error instanceof DecodeError || error instanceof SyncError) {
613
+ const sync =
614
+ error instanceof SyncError
615
+ ? error
616
+ : syncError(error.code, error.message);
617
+ finishRound();
618
+ await this.#sendRoundChunk(
619
+ errorResponseBytes(sync, this.wireVersion, this.logEpoch),
620
+ );
621
+ return;
622
+ }
623
+ throw error;
624
+ }
579
625
  while (!step.done) {
580
626
  const chunk = step.value;
581
627
  step = await iterator.next();
@@ -590,7 +636,7 @@ export class RealtimeSession {
590
636
  finishRound();
591
637
  // §8.7 registration at round end: reload the persisted list — it
592
638
  // only advances on success, so failed rounds change nothing.
593
- await this.#refreshRegistrations();
639
+ await this.refreshRegistrations(false);
594
640
  }
595
641
  }
596
642
 
@@ -601,22 +647,42 @@ export class RealtimeSession {
601
647
  await this.#send(tagged);
602
648
  }
603
649
 
604
- async #refreshRegistrations(): Promise<void> {
605
- // §8.6.3: a registration change re-derives the presence grant — capture
606
- // the keys before, reconcile after (leaves on lost keys, snapshots on
607
- // gained keys).
608
- const previousKeys = this.#currentScopeKeys();
650
+ /**
651
+ * Reload this connection's registrations and reconcile presence (§8.7).
652
+ * The round-end refresh and the host-initiated `RealtimeHub.refreshScopes`
653
+ * both route through here under one per-session generation, so a load that
654
+ * resolves late can never overwrite a newer result: a slow resolver may
655
+ * return scopes a later refresh already revoked, and the later refresh
656
+ * keeps the connection's current grants.
657
+ *
658
+ * `failClosed` selects the failure semantics. The round-end refresh keeps
659
+ * the previous registrations so a failed round changes nothing. The host
660
+ * refresh empties them so a revoked or unreadable grant stops receiving
661
+ * deltas immediately; it also survives a newer round-end refresh that
662
+ * failed, because that round applied nothing.
663
+ */
664
+ async refreshRegistrations(failClosed: boolean): Promise<void> {
665
+ const generation = ++this.#registrationGeneration;
666
+ let registrations: Registration[];
609
667
  try {
610
- this.registrations = await this.#hub.loadRegistrations(
668
+ registrations = await this.#hub.loadRegistrations(
611
669
  this.partition,
612
670
  this.actorId,
613
671
  this.clientId,
614
672
  );
615
673
  } catch {
616
- // Fail closed: an unreadable record or resolver failure leaves the
617
- // previous registrations in place; the next round repairs it.
618
- return;
674
+ if (!failClosed) return;
675
+ // Fail closed (host refresh): drop every grant on the connection.
676
+ registrations = [];
619
677
  }
678
+ // A newer refresh already applied its result; this late one is stale.
679
+ if (generation < this.#registrationApplied) return;
680
+ this.#registrationApplied = generation;
681
+ // §8.6.3: a registration change re-derives the presence grant — capture
682
+ // the keys before, reconcile after (leaves on lost keys, snapshots on
683
+ // gained keys).
684
+ const previousKeys = this.#currentScopeKeys();
685
+ this.registrations = registrations;
620
686
  this.reconcilePresence(previousKeys);
621
687
  }
622
688
 
@@ -628,8 +694,11 @@ export class RealtimeSession {
628
694
  this.#hub.disconnect(this);
629
695
  }
630
696
 
631
- async #persistCursor(): Promise<void> {
632
- try {
697
+ #persistCursor(): void {
698
+ // §8.2: start the write now and keep it on the control tail so `drain()`
699
+ // can await it. Two acks may write concurrently: storage folds them with
700
+ // `MAX(cursor, ?)`, so neither order loses the later cursor.
701
+ const write = (async () => {
633
702
  await this.#storage.advanceClientCursor(
634
703
  this.partition,
635
704
  this.clientId,
@@ -638,9 +707,15 @@ export class RealtimeSession {
638
707
  this.cursor,
639
708
  this.#clock(),
640
709
  );
641
- } catch {
642
- // Cursor persistence is best-effort; the next pull repairs it.
643
- }
710
+ })();
711
+ write.catch((error: unknown) => {
712
+ // Best-effort for the connection (the next pull repairs the cursor);
713
+ // `drain()` carries the first failure to the host.
714
+ this.#controlFailure ??= error;
715
+ });
716
+ this.#controlTail = Promise.allSettled([this.#controlTail, write]).then(
717
+ () => undefined,
718
+ );
644
719
  }
645
720
 
646
721
  /** Fire-and-forget send for control text and deltas; a host send
@@ -1106,6 +1181,30 @@ export class RealtimeHub {
1106
1181
  }
1107
1182
  }
1108
1183
 
1184
+ /**
1185
+ * Host-initiated, fail-closed registration refresh (§8.7). A host calls this
1186
+ * after it changes an actor's membership or connection state, so the
1187
+ * partition's connected sessions stop using grants the host already revoked
1188
+ * without waiting for the client to happen to run a round.
1189
+ *
1190
+ * This differs deliberately from the round-end refresh
1191
+ * (`RealtimeSession.refreshRegistrations`), which keeps the previous
1192
+ * registrations when the client record or resolver cannot be read so a
1193
+ * failed round changes nothing. Here an unresolvable session is emptied:
1194
+ * a revoked or unreadable grant must stop receiving deltas rather than live
1195
+ * on until the next round. Presence is reconciled exactly as at round end.
1196
+ */
1197
+ async refreshScopes(partition: string, actorId?: string): Promise<void> {
1198
+ for (const session of [...this.#sessions]) {
1199
+ if (session.partition !== partition) continue;
1200
+ if (actorId !== undefined && session.actorId !== actorId) continue;
1201
+ // Fail closed (see the doc above): an unreadable client record drops
1202
+ // every grant on the connection. The session's generation guard keeps
1203
+ // this revocation from being overwritten by a slower round-end reload.
1204
+ await session.refreshRegistrations(true);
1205
+ }
1206
+ }
1207
+
1109
1208
  /** Broadcast a wake-up (host-initiated resync, schema rollover, §8.3). */
1110
1209
  wake(partition: string, reason: WakeReason): void {
1111
1210
  for (const session of this.#sessions) {
@@ -191,6 +191,42 @@ export function tableColumnNames(table: CompiledTable): string[] {
191
191
  ];
192
192
  }
193
193
 
194
+ /**
195
+ * The physical column set the row codec and the scope index read for one
196
+ * configured table. The persisted marker's layouts describe the codec's app
197
+ * columns only and its version number is the schema's, not the database's, so
198
+ * a same-version database that is missing a storage-internal meta column (a
199
+ * version-only bump whose ALTER never ran) still has to be refused: compare
200
+ * the physical table and fail closed without writing a migration.
201
+ *
202
+ * `existingColumns` must be resolved the way the unqualified row queries
203
+ * resolve the table — on PostgreSQL, through the session `search_path`, not
204
+ * `current_schema()`. A search path whose first schema is not where the
205
+ * configured tables live is outside the supported contract: DDL is created in
206
+ * that first schema, so `ensureSchema` already writes shadow tables there.
207
+ *
208
+ * Scope: this checks that the columns EXIST. Stored types, nullability, and
209
+ * non-column storage internals (the tombstone bookkeeping) stay outside it,
210
+ * because a wrong type or nullability is a codec-compatibility question the
211
+ * persisted layouts answer for app columns.
212
+ */
213
+ export function assertPhysicalColumns(
214
+ table: CompiledTable,
215
+ existingColumns: ReadonlySet<string>,
216
+ ): void {
217
+ if (existingColumns.size === 0) {
218
+ throw new Error(
219
+ `table ${JSON.stringify(table.name)} is missing from the database at the stored schema version — refusing to serve a database whose rows the running code cannot read`,
220
+ );
221
+ }
222
+ for (const name of tableColumnNames(table)) {
223
+ if (existingColumns.has(name)) continue;
224
+ throw new Error(
225
+ `table ${JSON.stringify(table.name)} is missing column ${JSON.stringify(name)} at the stored schema version — refusing to serve a database whose rows the running code cannot read`,
226
+ );
227
+ }
228
+ }
229
+
194
230
  /** CREATE TABLE IF NOT EXISTS for one app table. */
195
231
  export function createTableDdl(
196
232
  table: CompiledTable,