@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
@@ -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
@@ -112,6 +115,13 @@ export declare class RealtimeSession {
112
115
  /** Feed an inbound text frame (client → server control message, §8.2
113
116
  * ack, §8.6.2 presence). */
114
117
  handleMessage(text: string): void;
118
+ /**
119
+ * Resolve once every queued control-plane write has settled (§8.2). A host
120
+ * awaits this before it closes its storage or ends a hibernatable event, so
121
+ * an ack's cursor write is not abandoned mid-flight. A persistence failure
122
+ * surfaces here instead of vanishing.
123
+ */
124
+ drain(): Promise<void>;
115
125
  /** Deliver a fanout event to this session (called by the hub for peers,
116
126
  * §8.6.3 receive authorization checked by the caller). */
117
127
  receivePresence(scopeKey: string, kind: PresenceKind, actorId: string, clientId: string, doc: PresenceDoc | null): void;
@@ -135,6 +145,21 @@ export declare class RealtimeSession {
135
145
  * coordinated hosts await it to retain their partition FIFO through commit.
136
146
  */
137
147
  handleBinary(bytes: Uint8Array): Promise<void> | undefined;
148
+ /**
149
+ * Reload this connection's registrations and reconcile presence (§8.7).
150
+ * The round-end refresh and the host-initiated `RealtimeHub.refreshScopes`
151
+ * both route through here under one per-session generation, so a load that
152
+ * resolves late can never overwrite a newer result: a slow resolver may
153
+ * return scopes a later refresh already revoked, and the later refresh
154
+ * keeps the connection's current grants.
155
+ *
156
+ * `failClosed` selects the failure semantics. The round-end refresh keeps
157
+ * the previous registrations so a failed round changes nothing. The host
158
+ * refresh empties them so a revoked or unreadable grant stops receiving
159
+ * deltas immediately; it also survives a newer round-end refresh that
160
+ * failed, because that round applied nothing.
161
+ */
162
+ refreshRegistrations(failClosed: boolean): Promise<void>;
138
163
  sendHeartbeat(): void;
139
164
  sendWake(reason: WakeReason): void;
140
165
  /** Called by the hub for every applied commit notification. */
@@ -182,6 +207,20 @@ export declare class RealtimeHub {
182
207
  disconnect(session: RealtimeSession): void;
183
208
  /** RealtimeNotifier: fan an applied commit out to matching sessions. */
184
209
  notifyCommit(partition: string, commit: StoredCommit): Promise<void>;
210
+ /**
211
+ * Host-initiated, fail-closed registration refresh (§8.7). A host calls this
212
+ * after it changes an actor's membership or connection state, so the
213
+ * partition's connected sessions stop using grants the host already revoked
214
+ * without waiting for the client to happen to run a round.
215
+ *
216
+ * This differs deliberately from the round-end refresh
217
+ * (`RealtimeSession.refreshRegistrations`), which keeps the previous
218
+ * registrations when the client record or resolver cannot be read so a
219
+ * failed round changes nothing. Here an unresolvable session is emptied:
220
+ * a revoked or unreadable grant must stop receiving deltas rather than live
221
+ * on until the next round. Presence is reconciled exactly as at round end.
222
+ */
223
+ refreshScopes(partition: string, actorId?: string): Promise<void>;
185
224
  /** Broadcast a wake-up (host-initiated resync, schema rollover, §8.3). */
186
225
  wake(partition: string, reason: WakeReason): void;
187
226
  }
package/dist/realtime.js 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
@@ -147,6 +150,14 @@ export class RealtimeSession {
147
150
  /** §8.6.4 rate cap: per-scope-key last-fanout time + a pending latest
148
151
  * document coalesced while over the cap. */
149
152
  #presenceRate = new Map();
153
+ /** Queued control-plane writes (ack cursor persistence, §8.2). */
154
+ #controlTail = Promise.resolve();
155
+ /** First control-plane failure not yet surfaced by `drain()`. */
156
+ #controlFailure = undefined;
157
+ /** Issue counter for registration refreshes (§8.7). */
158
+ #registrationGeneration = 0;
159
+ /** Generation of the most recent refresh whose result was applied. */
160
+ #registrationApplied = 0;
150
161
  constructor(hub, options, registrations, cursor, latestSeq, clock, maxDeltaBytes, storage, logEpoch, wireVersion, events) {
151
162
  this.sessionId = crypto.randomUUID();
152
163
  this.partition = options.partition;
@@ -198,7 +209,21 @@ export class RealtimeSession {
198
209
  if (this.cursor >= this.lastKnownSeq)
199
210
  this.wakePending = false;
200
211
  // §8.2: acks update the client cursor record without an HTTP pull.
201
- void this.#persistCursor();
212
+ this.#persistCursor();
213
+ }
214
+ /**
215
+ * Resolve once every queued control-plane write has settled (§8.2). A host
216
+ * awaits this before it closes its storage or ends a hibernatable event, so
217
+ * an ack's cursor write is not abandoned mid-flight. A persistence failure
218
+ * surfaces here instead of vanishing.
219
+ */
220
+ drain() {
221
+ return this.#controlTail.then(() => {
222
+ const failure = this.#controlFailure;
223
+ this.#controlFailure = undefined;
224
+ if (failure !== undefined)
225
+ throw failure;
226
+ });
202
227
  }
203
228
  /** §8.6.2 inbound presence publish/leave from the client. */
204
229
  #handlePresence(text) {
@@ -421,7 +446,26 @@ export class RealtimeSession {
421
446
  // begin its next round as soon as END *arrives* (§8.7 rule 3), so
422
447
  // in-flight must end with the send, not with generator cleanup.
423
448
  const iterator = stream[Symbol.asyncIterator]();
424
- 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
+ }
425
469
  while (!step.done) {
426
470
  const chunk = step.value;
427
471
  step = await iterator.next();
@@ -439,7 +483,7 @@ export class RealtimeSession {
439
483
  finishRound();
440
484
  // §8.7 registration at round end: reload the persisted list — it
441
485
  // only advances on success, so failed rounds change nothing.
442
- await this.#refreshRegistrations();
486
+ await this.refreshRegistrations(false);
443
487
  }
444
488
  }
445
489
  async #sendRoundChunk(chunk) {
@@ -448,19 +492,41 @@ export class RealtimeSession {
448
492
  tagged.set(chunk, 1);
449
493
  await this.#send(tagged);
450
494
  }
451
- async #refreshRegistrations() {
452
- // §8.6.3: a registration change re-derives the presence grant — capture
453
- // the keys before, reconcile after (leaves on lost keys, snapshots on
454
- // gained keys).
455
- const previousKeys = this.#currentScopeKeys();
495
+ /**
496
+ * Reload this connection's registrations and reconcile presence (§8.7).
497
+ * The round-end refresh and the host-initiated `RealtimeHub.refreshScopes`
498
+ * both route through here under one per-session generation, so a load that
499
+ * resolves late can never overwrite a newer result: a slow resolver may
500
+ * return scopes a later refresh already revoked, and the later refresh
501
+ * keeps the connection's current grants.
502
+ *
503
+ * `failClosed` selects the failure semantics. The round-end refresh keeps
504
+ * the previous registrations so a failed round changes nothing. The host
505
+ * refresh empties them so a revoked or unreadable grant stops receiving
506
+ * deltas immediately; it also survives a newer round-end refresh that
507
+ * failed, because that round applied nothing.
508
+ */
509
+ async refreshRegistrations(failClosed) {
510
+ const generation = ++this.#registrationGeneration;
511
+ let registrations;
456
512
  try {
457
- this.registrations = await this.#hub.loadRegistrations(this.partition, this.actorId, this.clientId);
513
+ registrations = await this.#hub.loadRegistrations(this.partition, this.actorId, this.clientId);
458
514
  }
459
515
  catch {
460
- // Fail closed: an unreadable record or resolver failure leaves the
461
- // previous registrations in place; the next round repairs it.
462
- return;
516
+ if (!failClosed)
517
+ return;
518
+ // Fail closed (host refresh): drop every grant on the connection.
519
+ registrations = [];
463
520
  }
521
+ // A newer refresh already applied its result; this late one is stale.
522
+ if (generation < this.#registrationApplied)
523
+ return;
524
+ this.#registrationApplied = generation;
525
+ // §8.6.3: a registration change re-derives the presence grant — capture
526
+ // the keys before, reconcile after (leaves on lost keys, snapshots on
527
+ // gained keys).
528
+ const previousKeys = this.#currentScopeKeys();
529
+ this.registrations = registrations;
464
530
  this.reconcilePresence(previousKeys);
465
531
  }
466
532
  /** §8.7 protocol violation: drop the connection, fail loud. The
@@ -470,13 +536,19 @@ export class RealtimeSession {
470
536
  this.#closeSocket?.();
471
537
  this.#hub.disconnect(this);
472
538
  }
473
- async #persistCursor() {
474
- try {
539
+ #persistCursor() {
540
+ // §8.2: start the write now and keep it on the control tail so `drain()`
541
+ // can await it. Two acks may write concurrently: storage folds them with
542
+ // `MAX(cursor, ?)`, so neither order loses the later cursor.
543
+ const write = (async () => {
475
544
  await this.#storage.advanceClientCursor(this.partition, this.clientId, this.actorId, this.logEpoch, this.cursor, this.#clock());
476
- }
477
- catch {
478
- // Cursor persistence is best-effort; the next pull repairs it.
479
- }
545
+ })();
546
+ write.catch((error) => {
547
+ // Best-effort for the connection (the next pull repairs the cursor);
548
+ // `drain()` carries the first failure to the host.
549
+ this.#controlFailure ??= error;
550
+ });
551
+ this.#controlTail = Promise.allSettled([this.#controlTail, write]).then(() => undefined);
480
552
  }
481
553
  /** Fire-and-forget send for control text and deltas; a host send
482
554
  * failure surfaces on the socket, not here. */
@@ -872,6 +944,31 @@ export class RealtimeHub {
872
944
  session.deliverCommit(commit);
873
945
  }
874
946
  }
947
+ /**
948
+ * Host-initiated, fail-closed registration refresh (§8.7). A host calls this
949
+ * after it changes an actor's membership or connection state, so the
950
+ * partition's connected sessions stop using grants the host already revoked
951
+ * without waiting for the client to happen to run a round.
952
+ *
953
+ * This differs deliberately from the round-end refresh
954
+ * (`RealtimeSession.refreshRegistrations`), which keeps the previous
955
+ * registrations when the client record or resolver cannot be read so a
956
+ * failed round changes nothing. Here an unresolvable session is emptied:
957
+ * a revoked or unreadable grant must stop receiving deltas rather than live
958
+ * on until the next round. Presence is reconciled exactly as at round end.
959
+ */
960
+ async refreshScopes(partition, actorId) {
961
+ for (const session of [...this.#sessions]) {
962
+ if (session.partition !== partition)
963
+ continue;
964
+ if (actorId !== undefined && session.actorId !== actorId)
965
+ continue;
966
+ // Fail closed (see the doc above): an unreadable client record drops
967
+ // every grant on the connection. The session's generation guard keeps
968
+ // this revocation from being overwritten by a slower round-end reload.
969
+ await session.refreshRegistrations(true);
970
+ }
971
+ }
875
972
  /** Broadcast a wake-up (host-initiated resync, schema rollover, §8.3). */
876
973
  wake(partition, reason) {
877
974
  for (const session of this.#sessions) {
@@ -75,6 +75,26 @@ export declare function columnSqlType(column: RowColumn, dialect: RelationalDial
75
75
  * materialized), version, scopes, payload.
76
76
  */
77
77
  export declare function tableColumnNames(table: CompiledTable): string[];
78
+ /**
79
+ * The physical column set the row codec and the scope index read for one
80
+ * configured table. The persisted marker's layouts describe the codec's app
81
+ * columns only and its version number is the schema's, not the database's, so
82
+ * a same-version database that is missing a storage-internal meta column (a
83
+ * version-only bump whose ALTER never ran) still has to be refused: compare
84
+ * the physical table and fail closed without writing a migration.
85
+ *
86
+ * `existingColumns` must be resolved the way the unqualified row queries
87
+ * resolve the table — on PostgreSQL, through the session `search_path`, not
88
+ * `current_schema()`. A search path whose first schema is not where the
89
+ * configured tables live is outside the supported contract: DDL is created in
90
+ * that first schema, so `ensureSchema` already writes shadow tables there.
91
+ *
92
+ * Scope: this checks that the columns EXIST. Stored types, nullability, and
93
+ * non-column storage internals (the tombstone bookkeeping) stay outside it,
94
+ * because a wrong type or nullability is a codec-compatibility question the
95
+ * persisted layouts answer for app columns.
96
+ */
97
+ export declare function assertPhysicalColumns(table: CompiledTable, existingColumns: ReadonlySet<string>): void;
78
98
  /** CREATE TABLE IF NOT EXISTS for one app table. */
79
99
  export declare function createTableDdl(table: CompiledTable, dialect: RelationalDialect): string;
80
100
  /**
@@ -169,6 +169,35 @@ export function tableColumnNames(table) {
169
169
  SYNC_COLUMN_VERSIONS_COLUMN,
170
170
  ];
171
171
  }
172
+ /**
173
+ * The physical column set the row codec and the scope index read for one
174
+ * configured table. The persisted marker's layouts describe the codec's app
175
+ * columns only and its version number is the schema's, not the database's, so
176
+ * a same-version database that is missing a storage-internal meta column (a
177
+ * version-only bump whose ALTER never ran) still has to be refused: compare
178
+ * the physical table and fail closed without writing a migration.
179
+ *
180
+ * `existingColumns` must be resolved the way the unqualified row queries
181
+ * resolve the table — on PostgreSQL, through the session `search_path`, not
182
+ * `current_schema()`. A search path whose first schema is not where the
183
+ * configured tables live is outside the supported contract: DDL is created in
184
+ * that first schema, so `ensureSchema` already writes shadow tables there.
185
+ *
186
+ * Scope: this checks that the columns EXIST. Stored types, nullability, and
187
+ * non-column storage internals (the tombstone bookkeeping) stay outside it,
188
+ * because a wrong type or nullability is a codec-compatibility question the
189
+ * persisted layouts answer for app columns.
190
+ */
191
+ export function assertPhysicalColumns(table, existingColumns) {
192
+ if (existingColumns.size === 0) {
193
+ throw new Error(`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`);
194
+ }
195
+ for (const name of tableColumnNames(table)) {
196
+ if (existingColumns.has(name))
197
+ continue;
198
+ throw new Error(`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`);
199
+ }
200
+ }
172
201
  /** CREATE TABLE IF NOT EXISTS for one app table. */
173
202
  export function createTableDdl(table, dialect) {
174
203
  const versionType = dialect === 'postgres' ? 'BIGINT' : 'INTEGER';
@@ -6,12 +6,19 @@
6
6
  * Key layout (deterministic — every lookup is a GET/HEAD, never a LIST):
7
7
  *
8
8
  * {keyPrefix}seg/sha256/{hex}
9
- * The segment bytes, verbatim (the object body MUST be exactly the
10
- * content-addressed bytes so presigned GETs serve them directly and
11
- * the client's §5.1 hash check passes). The full `SegmentRecord`
12
- * (minus bytes) rides along as object user metadata
13
- * `x-amz-meta-syncular-record` = base64url(JSON), so `get` is a
14
- * single GET.
9
+ * The segment bytes, verbatim and immutable (the object body MUST be
10
+ * exactly the content-addressed bytes so presigned GETs serve them
11
+ * directly and the client's §5.1 hash check passes).
12
+ *
13
+ * {keyPrefix}rec/sha256/{hex}.json
14
+ * The `SegmentRecord` (§5.1). It is the one MUTABLE object of the two:
15
+ * the scope-digest set is a union that every publication extends, so
16
+ * the merge is a read-modify-write. Keeping the record in the body (not
17
+ * in the bytes object's user metadata) is what makes that write
18
+ * conditional — a body ETag changes when the union changes, while the
19
+ * bytes ETag is a content hash and is identical for identical bytes. A
20
+ * `412` re-reads and merges again, so no concurrent publication can drop
21
+ * another one's digest. `get` therefore reads two objects.
15
22
  *
16
23
  * {keyPrefix}find/{sha256Hex(canonical reuse key)}.json
17
24
  * Whole-table reuse pointer (§5.3): written only when
@@ -21,9 +28,14 @@
21
28
  * confirm the segment object itself still exists — lifecycle GC may
22
29
  * remove objects independently of pointers).
23
30
  *
31
+ * Records written before the split kept the record in the bytes object's
32
+ * user metadata `x-amz-meta-syncular-record`; `get` still reads that shape
33
+ * and the next `put` materializes the record object from it, so an upgrade
34
+ * loses no cache entry.
35
+ *
24
36
  * TTL mapping: expiry is **store-side and authoritative** — `expiresAtMs`
25
- * (put-time + `ttlMs`, default 24 h) is recorded in the object metadata
26
- * and the pointer; `get` returns expired records so §5.5 can answer
37
+ * (put-time + `ttlMs`, default 24 h) is recorded in the record and the
38
+ * pointer; `get` returns expired records so §5.5 can answer
27
39
  * `sync.segment_expired`, and `find` filters them out itself. S3 lifecycle
28
40
  * expiration is garbage collection only: configure it comfortably ABOVE
29
41
  * `ttlMs` (e.g. 2 days for the 24 h default) so clients normally see the