@syncular/server 0.21.0 → 0.22.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.
@@ -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.0",
3
+ "version": "0.22.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.0"
71
+ "@syncular/core": "0.22.0"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"
@@ -41,6 +41,14 @@ export type PgRow = Record<string, unknown>;
41
41
  * `params` use Postgres positional placeholders (`$1`, `$2`, ...). `text`
42
42
  * is a single statement (no multi-statement `;`-joined batches — those are
43
43
  * driver-specific and defeat parameterization).
44
+ *
45
+ * Syncular never overlaps statements on one `PgQueryable`. A client pinned
46
+ * to a connection by `PgExecutor.transaction` gets every statement chained
47
+ * on one FIFO by `PostgresServerStorage`, so a host validator that starts
48
+ * independent reads concurrently still reaches the driver one statement at
49
+ * a time. An adapter does not need to queue calls itself, and must not
50
+ * accept two in flight: node-postgres warns about that today and rejects it
51
+ * in node-postgres 9.
44
52
  */
45
53
  export interface PgQueryable {
46
54
  query<Row = PgRow>(
@@ -58,7 +66,9 @@ export interface PgExecutor extends PgQueryable {
58
66
  /**
59
67
  * Run `fn` inside a single Postgres transaction on one pinned connection.
60
68
  * The executor issues `BEGIN` before `fn`, `COMMIT` on resolve, and
61
- * `ROLLBACK` on throw (rethrowing the original error).
69
+ * `ROLLBACK` on throw (rethrowing the original error). The caller
70
+ * serializes the statements `fn` issues on the provided client (see
71
+ * `PgQueryable`), so the driver never sees two of them in flight.
62
72
  */
63
73
  transaction<T>(fn: (client: PgQueryable) => Promise<T>): Promise<T>;
64
74
  /** Release any pooled resources. Optional (pglite/Bun.sql). */
@@ -52,6 +52,7 @@ import {
52
52
  type PgQueryable,
53
53
  } from './pg-executor';
54
54
  import {
55
+ assertPhysicalColumns,
55
56
  commitWindowPageSql,
56
57
  deleteRowSql,
57
58
  dropTableDdl,
@@ -122,7 +123,14 @@ import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query';
122
123
  * - `sync_row_scopes_pk (partition, tbl, var, value, row_id)` — same shape
123
124
  * for `scanRows`, ordered by `row_id`.
124
125
  * Both are the PRIMARY KEY, so they are the clustering/covering index for
125
- * their table. No secondary index is needed for the hot path.
126
+ * their table.
127
+ *
128
+ * `sync_row_scopes_by_row (partition, tbl, row_id)` is the one secondary
129
+ * scope index: `writeRowOn` and `deleteRow` replace a row's scope entries
130
+ * with a `(partition, tbl, row_id)` predicate, and `row_id` is the LAST
131
+ * column of the inverted PRIMARY KEY, so without this index that predicate
132
+ * scans the table's whole scope range on every row written (a bulk import
133
+ * costs one such scan per row).
126
134
  *
127
135
  * Blob reference index (§5.9.4) — parity with the SQLite dialect's
128
136
  * `sync_blob_refs`:
@@ -151,6 +159,8 @@ CREATE TABLE IF NOT EXISTS sync_row_scopes(
151
159
  var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,
152
160
  PRIMARY KEY(partition, tbl, var, value, row_id)
153
161
  );
162
+ CREATE INDEX IF NOT EXISTS sync_row_scopes_by_row
163
+ ON sync_row_scopes(partition, tbl, row_id);
154
164
  CREATE TABLE IF NOT EXISTS sync_commits(
155
165
  partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,
156
166
  client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,
@@ -601,7 +611,23 @@ class PostgresTransaction implements StorageTransaction {
601
611
  resolve: () => void,
602
612
  reject: (error: unknown) => void,
603
613
  ) {
604
- this.#client = client;
614
+ // One pinned connection carries one statement at a time: node-postgres
615
+ // warns about a concurrently issued second query today and rejects it in
616
+ // node-postgres 9. A whole-commit validator that fans independent reads
617
+ // out with Promise.all must not interleave them, so every statement
618
+ // issued through this transaction is chained on one FIFO — the same
619
+ // shape as `SqliteServerStorage.queryAuthoritative`.
620
+ let tail: Promise<void> = Promise.resolve();
621
+ this.#client = {
622
+ query: <Row>(text: string, params?: readonly unknown[]) => {
623
+ const run = tail.then(() => client.query<Row>(text, params));
624
+ tail = run.then(
625
+ () => undefined,
626
+ () => undefined,
627
+ );
628
+ return run;
629
+ },
630
+ };
605
631
  this.#partition = partition;
606
632
  this.#resolveTable = resolveTable;
607
633
  this.#resolve = resolve;
@@ -977,6 +1003,68 @@ export class PostgresServerStorage implements ServerStorage {
977
1003
  `stored schema version ${stored} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`,
978
1004
  );
979
1005
  }
1006
+ if (stored !== undefined && stored === schema.version) {
1007
+ // Version equality is not layout equality: a marker written by another
1008
+ // build at the same version describes rows the running codec cannot
1009
+ // decode. Compare the stored layouts instead of trusting the number.
1010
+ const storedLayouts = parseLayouts(
1011
+ typeof marker.rows[0]?.layouts === 'string'
1012
+ ? marker.rows[0].layouts
1013
+ : undefined,
1014
+ );
1015
+ const configuredLayouts = parseLayouts(layoutsOf(schema));
1016
+ let mismatch: string | undefined;
1017
+ for (const [tableName, columns] of Object.entries(configuredLayouts)) {
1018
+ const storedTable = storedLayouts[tableName];
1019
+ const columnCount = Math.max(columns.length, storedTable?.length ?? 0);
1020
+ for (let index = 0; index < columnCount; index++) {
1021
+ const expected = columns[index];
1022
+ const actual = storedTable?.[index];
1023
+ if (
1024
+ expected !== undefined &&
1025
+ actual !== undefined &&
1026
+ actual.name === expected.name &&
1027
+ actual.type === expected.type &&
1028
+ actual.nullable === expected.nullable
1029
+ ) {
1030
+ continue;
1031
+ }
1032
+ mismatch = `table ${JSON.stringify(tableName)} column ${JSON.stringify(expected?.name ?? actual?.name ?? '')}`;
1033
+ break;
1034
+ }
1035
+ if (mismatch !== undefined) break;
1036
+ }
1037
+ if (mismatch === undefined) {
1038
+ for (const tableName of Object.keys(storedLayouts)) {
1039
+ if (!(tableName in configuredLayouts)) {
1040
+ mismatch = `table ${JSON.stringify(tableName)}`;
1041
+ break;
1042
+ }
1043
+ }
1044
+ }
1045
+ if (mismatch !== undefined) {
1046
+ throw new Error(
1047
+ `stored schema layouts disagree with the configured schema at version ${schema.version} (${mismatch}) — refusing to serve a database whose stored rows the running code cannot decode`,
1048
+ );
1049
+ }
1050
+ // The persisted layouts describe the codec's app columns only: read the
1051
+ // physical tables so a same-version database missing a
1052
+ // storage-internal column is refused at startup instead of failing at
1053
+ // the first write. Resolve through `to_regclass`, which follows the
1054
+ // session's `search_path` exactly as the unqualified row queries do —
1055
+ // `current_schema()` names only the first existing schema, so it would
1056
+ // reject a table that resolves later in the path.
1057
+ for (const table of schema.tables.values()) {
1058
+ const { rows } = await this.#exec.query<{ column_name: string }>(
1059
+ `SELECT attribute.attname AS column_name
1060
+ FROM pg_attribute attribute
1061
+ WHERE attribute.attrelid = to_regclass($1)
1062
+ AND attribute.attnum > 0 AND NOT attribute.attisdropped`,
1063
+ [table.name],
1064
+ );
1065
+ assertPhysicalColumns(table, new Set(rows.map((r) => r.column_name)));
1066
+ }
1067
+ }
980
1068
  if (stored === undefined || stored < schema.version) {
981
1069
  // Introspect existing app tables, apply the migration subset
982
1070
  // (CREATE TABLE / ADD COLUMN / rebuild indexes), then rewrite stored
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/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. */
@@ -590,7 +615,7 @@ export class RealtimeSession {
590
615
  finishRound();
591
616
  // §8.7 registration at round end: reload the persisted list — it
592
617
  // only advances on success, so failed rounds change nothing.
593
- await this.#refreshRegistrations();
618
+ await this.refreshRegistrations(false);
594
619
  }
595
620
  }
596
621
 
@@ -601,22 +626,42 @@ export class RealtimeSession {
601
626
  await this.#send(tagged);
602
627
  }
603
628
 
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();
629
+ /**
630
+ * Reload this connection's registrations and reconcile presence (§8.7).
631
+ * The round-end refresh and the host-initiated `RealtimeHub.refreshScopes`
632
+ * both route through here under one per-session generation, so a load that
633
+ * resolves late can never overwrite a newer result: a slow resolver may
634
+ * return scopes a later refresh already revoked, and the later refresh
635
+ * keeps the connection's current grants.
636
+ *
637
+ * `failClosed` selects the failure semantics. The round-end refresh keeps
638
+ * the previous registrations so a failed round changes nothing. The host
639
+ * refresh empties them so a revoked or unreadable grant stops receiving
640
+ * deltas immediately; it also survives a newer round-end refresh that
641
+ * failed, because that round applied nothing.
642
+ */
643
+ async refreshRegistrations(failClosed: boolean): Promise<void> {
644
+ const generation = ++this.#registrationGeneration;
645
+ let registrations: Registration[];
609
646
  try {
610
- this.registrations = await this.#hub.loadRegistrations(
647
+ registrations = await this.#hub.loadRegistrations(
611
648
  this.partition,
612
649
  this.actorId,
613
650
  this.clientId,
614
651
  );
615
652
  } catch {
616
- // Fail closed: an unreadable record or resolver failure leaves the
617
- // previous registrations in place; the next round repairs it.
618
- return;
653
+ if (!failClosed) return;
654
+ // Fail closed (host refresh): drop every grant on the connection.
655
+ registrations = [];
619
656
  }
657
+ // A newer refresh already applied its result; this late one is stale.
658
+ if (generation < this.#registrationApplied) return;
659
+ this.#registrationApplied = generation;
660
+ // §8.6.3: a registration change re-derives the presence grant — capture
661
+ // the keys before, reconcile after (leaves on lost keys, snapshots on
662
+ // gained keys).
663
+ const previousKeys = this.#currentScopeKeys();
664
+ this.registrations = registrations;
620
665
  this.reconcilePresence(previousKeys);
621
666
  }
622
667
 
@@ -628,8 +673,11 @@ export class RealtimeSession {
628
673
  this.#hub.disconnect(this);
629
674
  }
630
675
 
631
- async #persistCursor(): Promise<void> {
632
- try {
676
+ #persistCursor(): void {
677
+ // §8.2: start the write now and keep it on the control tail so `drain()`
678
+ // can await it. Two acks may write concurrently: storage folds them with
679
+ // `MAX(cursor, ?)`, so neither order loses the later cursor.
680
+ const write = (async () => {
633
681
  await this.#storage.advanceClientCursor(
634
682
  this.partition,
635
683
  this.clientId,
@@ -638,9 +686,15 @@ export class RealtimeSession {
638
686
  this.cursor,
639
687
  this.#clock(),
640
688
  );
641
- } catch {
642
- // Cursor persistence is best-effort; the next pull repairs it.
643
- }
689
+ })();
690
+ write.catch((error: unknown) => {
691
+ // Best-effort for the connection (the next pull repairs the cursor);
692
+ // `drain()` carries the first failure to the host.
693
+ this.#controlFailure ??= error;
694
+ });
695
+ this.#controlTail = Promise.allSettled([this.#controlTail, write]).then(
696
+ () => undefined,
697
+ );
644
698
  }
645
699
 
646
700
  /** Fire-and-forget send for control text and deltas; a host send
@@ -1106,6 +1160,30 @@ export class RealtimeHub {
1106
1160
  }
1107
1161
  }
1108
1162
 
1163
+ /**
1164
+ * Host-initiated, fail-closed registration refresh (§8.7). A host calls this
1165
+ * after it changes an actor's membership or connection state, so the
1166
+ * partition's connected sessions stop using grants the host already revoked
1167
+ * without waiting for the client to happen to run a round.
1168
+ *
1169
+ * This differs deliberately from the round-end refresh
1170
+ * (`RealtimeSession.refreshRegistrations`), which keeps the previous
1171
+ * registrations when the client record or resolver cannot be read so a
1172
+ * failed round changes nothing. Here an unresolvable session is emptied:
1173
+ * a revoked or unreadable grant must stop receiving deltas rather than live
1174
+ * on until the next round. Presence is reconciled exactly as at round end.
1175
+ */
1176
+ async refreshScopes(partition: string, actorId?: string): Promise<void> {
1177
+ for (const session of [...this.#sessions]) {
1178
+ if (session.partition !== partition) continue;
1179
+ if (actorId !== undefined && session.actorId !== actorId) continue;
1180
+ // Fail closed (see the doc above): an unreadable client record drops
1181
+ // every grant on the connection. The session's generation guard keeps
1182
+ // this revocation from being overwritten by a slower round-end reload.
1183
+ await session.refreshRegistrations(true);
1184
+ }
1185
+ }
1186
+
1109
1187
  /** Broadcast a wake-up (host-initiated resync, schema rollover, §8.3). */
1110
1188
  wake(partition: string, reason: WakeReason): void {
1111
1189
  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,