@syncular/server 0.21.1 → 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.
package/README.md CHANGED
@@ -660,14 +660,28 @@ which all three providers accept.
660
660
  **Key layout.** Deterministic, so every lookup is a GET/HEAD — never a
661
661
  LIST:
662
662
 
663
- - `{keyPrefix}seg/sha256/{hex}` — the segment bytes, verbatim (the
664
- object body is exactly the content-addressed bytes, so presigned GETs
665
- serve them directly and the client's §5.1 hash check passes). The
666
- record metadata rides in object user metadata
667
- (`x-amz-meta-syncular-record`, base64url JSON), so `get` is one GET.
663
+ - `{keyPrefix}seg/sha256/{hex}` — the segment bytes, verbatim and
664
+ immutable (the object body is exactly the content-addressed bytes, so
665
+ presigned GETs serve them directly and the client's §5.1 hash check
666
+ passes).
667
+ - `{keyPrefix}rec/sha256/{hex}.json` — the mutable `SegmentRecord`. It
668
+ carries the `publications` array: one entry per (partition, log epoch,
669
+ table, schema version, media type, scope digest, pin, cursors) context
670
+ the content was published under, each with its own TTL. `get` is two
671
+ GETs (record + bytes). A record written by 0.21 read the metadata from
672
+ the bytes object's user metadata; `get` still reads that shape and
673
+ materializes one publication per recorded digest.
668
674
  - `{keyPrefix}find/{sha256(reuse key)}.json` — the §5.3 whole-table
669
675
  reuse pointer, written only for `rowCursor: null` segments; `find` is
670
- one GET plus a HEAD to confirm the segment object still exists.
676
+ one GET plus a HEAD to confirm the segment object still exists, and it
677
+ selects the publication the key names.
678
+
679
+ A host that implements `SegmentStore` itself must return `publications`
680
+ and `scopeDigests` on every `SegmentRecord` (`scopeDigests` is the
681
+ compatibility view over `publications`). Download and `find` select a
682
+ publication matching the caller's partition, live log epoch, and digest;
683
+ they never union digests across contexts. The shared contract suite is
684
+ `test/segment-store-contract.ts`.
671
685
 
672
686
  **TTL and lifecycle.** Expiry is store-side and authoritative:
673
687
  `expiresAtMs` (`put` time + `ttlMs`, default 24 h) is recorded with the
@@ -39,6 +39,14 @@ export type PgRow = Record<string, unknown>;
39
39
  * `params` use Postgres positional placeholders (`$1`, `$2`, ...). `text`
40
40
  * is a single statement (no multi-statement `;`-joined batches — those are
41
41
  * driver-specific and defeat parameterization).
42
+ *
43
+ * Syncular never overlaps statements on one `PgQueryable`. A client pinned
44
+ * to a connection by `PgExecutor.transaction` gets every statement chained
45
+ * on one FIFO by `PostgresServerStorage`, so a host validator that starts
46
+ * independent reads concurrently still reaches the driver one statement at
47
+ * a time. An adapter does not need to queue calls itself, and must not
48
+ * accept two in flight: node-postgres warns about that today and rejects it
49
+ * in node-postgres 9.
42
50
  */
43
51
  export interface PgQueryable {
44
52
  query<Row = PgRow>(text: string, params?: readonly unknown[]): Promise<{
@@ -55,7 +63,9 @@ export interface PgExecutor extends PgQueryable {
55
63
  /**
56
64
  * Run `fn` inside a single Postgres transaction on one pinned connection.
57
65
  * The executor issues `BEGIN` before `fn`, `COMMIT` on resolve, and
58
- * `ROLLBACK` on throw (rethrowing the original error).
66
+ * `ROLLBACK` on throw (rethrowing the original error). The caller
67
+ * serializes the statements `fn` issues on the provided client (see
68
+ * `PgQueryable`), so the driver never sees two of them in flight.
59
69
  */
60
70
  transaction<T>(fn: (client: PgQueryable) => Promise<T>): Promise<T>;
61
71
  /** Release any pooled resources. Optional (pglite/Bun.sql). */
@@ -12,7 +12,14 @@ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorI
12
12
  * - `sync_row_scopes_pk (partition, tbl, var, value, row_id)` — same shape
13
13
  * for `scanRows`, ordered by `row_id`.
14
14
  * Both are the PRIMARY KEY, so they are the clustering/covering index for
15
- * their table. No secondary index is needed for the hot path.
15
+ * their table.
16
+ *
17
+ * `sync_row_scopes_by_row (partition, tbl, row_id)` is the one secondary
18
+ * scope index: `writeRowOn` and `deleteRow` replace a row's scope entries
19
+ * with a `(partition, tbl, row_id)` predicate, and `row_id` is the LAST
20
+ * column of the inverted PRIMARY KEY, so without this index that predicate
21
+ * scans the table's whole scope range on every row written (a bulk import
22
+ * costs one such scan per row).
16
23
  *
17
24
  * Blob reference index (§5.9.4) — parity with the SQLite dialect's
18
25
  * `sync_blob_refs`:
@@ -24,7 +31,7 @@ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorI
24
31
  * set, §5.9.5) as an index range, never a scan. `postgres-explain
25
32
  * .test.ts` asserts an `Index` node here too.
26
33
  */
27
- export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required BOOLEAN NOT NULL DEFAULT FALSE,\n last_authenticated_at_ms BIGINT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result JSONB NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload JSONB NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,\n available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,\n last_failure JSONB,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nALTER TABLE sync_clients\n ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\n";
34
+ export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required BOOLEAN NOT NULL DEFAULT FALSE,\n last_authenticated_at_ms BIGINT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE INDEX IF NOT EXISTS sync_row_scopes_by_row\n ON sync_row_scopes(partition, tbl, row_id);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result JSONB NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload JSONB NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,\n available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,\n last_failure JSONB,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nALTER TABLE sync_clients\n ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\n";
28
35
  export declare class PostgresServerStorage implements ServerStorage {
29
36
  #private;
30
37
  constructor(exec: PgExecutor);
@@ -3,7 +3,7 @@ import { StorageQueryError } from './storage-errors.js';
3
3
  import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
4
4
  import { syncError } from './errors.js';
5
5
  import { asBytes, asNumber, } from './pg-executor.js';
6
- import { commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
6
+ import { assertPhysicalColumns, commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
7
7
  import { matchesEffective } from './scopes.js';
8
8
  import { isPostgresConstraintError, StorageConstraintError, } from './storage-errors.js';
9
9
  import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
@@ -17,7 +17,14 @@ import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js'
17
17
  * - `sync_row_scopes_pk (partition, tbl, var, value, row_id)` — same shape
18
18
  * for `scanRows`, ordered by `row_id`.
19
19
  * Both are the PRIMARY KEY, so they are the clustering/covering index for
20
- * their table. No secondary index is needed for the hot path.
20
+ * their table.
21
+ *
22
+ * `sync_row_scopes_by_row (partition, tbl, row_id)` is the one secondary
23
+ * scope index: `writeRowOn` and `deleteRow` replace a row's scope entries
24
+ * with a `(partition, tbl, row_id)` predicate, and `row_id` is the LAST
25
+ * column of the inverted PRIMARY KEY, so without this index that predicate
26
+ * scans the table's whole scope range on every row written (a bulk import
27
+ * costs one such scan per row).
21
28
  *
22
29
  * Blob reference index (§5.9.4) — parity with the SQLite dialect's
23
30
  * `sync_blob_refs`:
@@ -46,6 +53,8 @@ CREATE TABLE IF NOT EXISTS sync_row_scopes(
46
53
  var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,
47
54
  PRIMARY KEY(partition, tbl, var, value, row_id)
48
55
  );
56
+ CREATE INDEX IF NOT EXISTS sync_row_scopes_by_row
57
+ ON sync_row_scopes(partition, tbl, row_id);
49
58
  CREATE TABLE IF NOT EXISTS sync_commits(
50
59
  partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,
51
60
  client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,
@@ -339,7 +348,20 @@ class PostgresTransaction {
339
348
  #resolve;
340
349
  #reject;
341
350
  constructor(client, partition, resolveTable, resolve, reject) {
342
- this.#client = client;
351
+ // One pinned connection carries one statement at a time: node-postgres
352
+ // warns about a concurrently issued second query today and rejects it in
353
+ // node-postgres 9. A whole-commit validator that fans independent reads
354
+ // out with Promise.all must not interleave them, so every statement
355
+ // issued through this transaction is chained on one FIFO — the same
356
+ // shape as `SqliteServerStorage.queryAuthoritative`.
357
+ let tail = Promise.resolve();
358
+ this.#client = {
359
+ query: (text, params) => {
360
+ const run = tail.then(() => client.query(text, params));
361
+ tail = run.then(() => undefined, () => undefined);
362
+ return run;
363
+ },
364
+ };
343
365
  this.#partition = partition;
344
366
  this.#resolveTable = resolveTable;
345
367
  this.#resolve = resolve;
@@ -615,6 +637,60 @@ export class PostgresServerStorage {
615
637
  if (stored !== undefined && stored > schema.version) {
616
638
  throw new Error(`stored schema version ${stored} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`);
617
639
  }
640
+ if (stored !== undefined && stored === schema.version) {
641
+ // Version equality is not layout equality: a marker written by another
642
+ // build at the same version describes rows the running codec cannot
643
+ // decode. Compare the stored layouts instead of trusting the number.
644
+ const storedLayouts = parseLayouts(typeof marker.rows[0]?.layouts === 'string'
645
+ ? marker.rows[0].layouts
646
+ : undefined);
647
+ const configuredLayouts = parseLayouts(layoutsOf(schema));
648
+ let mismatch;
649
+ for (const [tableName, columns] of Object.entries(configuredLayouts)) {
650
+ const storedTable = storedLayouts[tableName];
651
+ const columnCount = Math.max(columns.length, storedTable?.length ?? 0);
652
+ for (let index = 0; index < columnCount; index++) {
653
+ const expected = columns[index];
654
+ const actual = storedTable?.[index];
655
+ if (expected !== undefined &&
656
+ actual !== undefined &&
657
+ actual.name === expected.name &&
658
+ actual.type === expected.type &&
659
+ actual.nullable === expected.nullable) {
660
+ continue;
661
+ }
662
+ mismatch = `table ${JSON.stringify(tableName)} column ${JSON.stringify(expected?.name ?? actual?.name ?? '')}`;
663
+ break;
664
+ }
665
+ if (mismatch !== undefined)
666
+ break;
667
+ }
668
+ if (mismatch === undefined) {
669
+ for (const tableName of Object.keys(storedLayouts)) {
670
+ if (!(tableName in configuredLayouts)) {
671
+ mismatch = `table ${JSON.stringify(tableName)}`;
672
+ break;
673
+ }
674
+ }
675
+ }
676
+ if (mismatch !== undefined) {
677
+ throw new Error(`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`);
678
+ }
679
+ // The persisted layouts describe the codec's app columns only: read the
680
+ // physical tables so a same-version database missing a
681
+ // storage-internal column is refused at startup instead of failing at
682
+ // the first write. Resolve through `to_regclass`, which follows the
683
+ // session's `search_path` exactly as the unqualified row queries do —
684
+ // `current_schema()` names only the first existing schema, so it would
685
+ // reject a table that resolves later in the path.
686
+ for (const table of schema.tables.values()) {
687
+ const { rows } = await this.#exec.query(`SELECT attribute.attname AS column_name
688
+ FROM pg_attribute attribute
689
+ WHERE attribute.attrelid = to_regclass($1)
690
+ AND attribute.attnum > 0 AND NOT attribute.attisdropped`, [table.name]);
691
+ assertPhysicalColumns(table, new Set(rows.map((r) => r.column_name)));
692
+ }
693
+ }
618
694
  if (stored === undefined || stored < schema.version) {
619
695
  // Introspect existing app tables, apply the migration subset
620
696
  // (CREATE TABLE / ADD COLUMN / rebuild indexes), then rewrite stored
package/dist/pull.js CHANGED
@@ -108,7 +108,7 @@ async function signedUrlFields(ctx, limits, segmentId, digest, now) {
108
108
  nowMs: now,
109
109
  });
110
110
  }
111
- function segmentRefFrame(record, extra) {
111
+ function segmentRefFrame(record, scopeDigest, extra) {
112
112
  return {
113
113
  type: 'SEGMENT_REF',
114
114
  segmentId: record.segmentId,
@@ -117,7 +117,11 @@ function segmentRefFrame(record, extra) {
117
117
  byteLength: record.byteLength,
118
118
  rowCount: record.rowCount,
119
119
  asOfCommitSeq: record.asOfCommitSeq,
120
- scopeDigest: record.scopeDigest,
120
+ // The frame reports the digest THIS subscription is authorized under. A
121
+ // stored entry can carry several digests when identical content was
122
+ // published under different scopes (§5.1), and the record's primary
123
+ // digest is not necessarily the caller's (§5.5).
124
+ scopeDigest,
121
125
  ...(record.rowCursor !== null ? { rowCursor: record.rowCursor } : {}),
122
126
  ...(record.nextRowCursor !== null
123
127
  ? { nextRowCursor: record.nextRowCursor }
@@ -156,7 +160,7 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
156
160
  bytes: existing.byteLength,
157
161
  rows: existing.rowCount,
158
162
  });
159
- yield segmentRefFrame(existing, await signedUrlFields(ctx, limits, existing.segmentId, digest, now));
163
+ yield segmentRefFrame(existing, digest, await signedUrlFields(ctx, limits, existing.segmentId, digest, now));
160
164
  return true;
161
165
  }
162
166
  // Eligibility probe (§5.3): image only when the snapshot exceeds one
@@ -253,7 +257,7 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
253
257
  bytes: record.byteLength,
254
258
  rows: record.rowCount,
255
259
  });
256
- yield segmentRefFrame(record, await signedUrlFields(ctx, limits, record.segmentId, digest, clockOf(ctx)()));
260
+ yield segmentRefFrame(record, digest, await signedUrlFields(ctx, limits, record.segmentId, digest, clockOf(ctx)()));
257
261
  return true;
258
262
  }
259
263
  async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace, logEpoch) {
@@ -331,7 +335,7 @@ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCurso
331
335
  bytes: record.byteLength,
332
336
  rows: record.rowCount,
333
337
  });
334
- yield segmentRefFrame(record, await signedUrlFields(ctx, limits, record.segmentId, digest, now));
338
+ yield segmentRefFrame(record, digest, await signedUrlFields(ctx, limits, record.segmentId, digest, now));
335
339
  }
336
340
  if (!hasMore)
337
341
  return { complete: true, rowCursor: null };
@@ -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) {
@@ -439,7 +464,7 @@ export class RealtimeSession {
439
464
  finishRound();
440
465
  // §8.7 registration at round end: reload the persisted list — it
441
466
  // only advances on success, so failed rounds change nothing.
442
- await this.#refreshRegistrations();
467
+ await this.refreshRegistrations(false);
443
468
  }
444
469
  }
445
470
  async #sendRoundChunk(chunk) {
@@ -448,19 +473,41 @@ export class RealtimeSession {
448
473
  tagged.set(chunk, 1);
449
474
  await this.#send(tagged);
450
475
  }
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();
476
+ /**
477
+ * Reload this connection's registrations and reconcile presence (§8.7).
478
+ * The round-end refresh and the host-initiated `RealtimeHub.refreshScopes`
479
+ * both route through here under one per-session generation, so a load that
480
+ * resolves late can never overwrite a newer result: a slow resolver may
481
+ * return scopes a later refresh already revoked, and the later refresh
482
+ * keeps the connection's current grants.
483
+ *
484
+ * `failClosed` selects the failure semantics. The round-end refresh keeps
485
+ * the previous registrations so a failed round changes nothing. The host
486
+ * refresh empties them so a revoked or unreadable grant stops receiving
487
+ * deltas immediately; it also survives a newer round-end refresh that
488
+ * failed, because that round applied nothing.
489
+ */
490
+ async refreshRegistrations(failClosed) {
491
+ const generation = ++this.#registrationGeneration;
492
+ let registrations;
456
493
  try {
457
- this.registrations = await this.#hub.loadRegistrations(this.partition, this.actorId, this.clientId);
494
+ registrations = await this.#hub.loadRegistrations(this.partition, this.actorId, this.clientId);
458
495
  }
459
496
  catch {
460
- // Fail closed: an unreadable record or resolver failure leaves the
461
- // previous registrations in place; the next round repairs it.
462
- return;
497
+ if (!failClosed)
498
+ return;
499
+ // Fail closed (host refresh): drop every grant on the connection.
500
+ registrations = [];
463
501
  }
502
+ // A newer refresh already applied its result; this late one is stale.
503
+ if (generation < this.#registrationApplied)
504
+ return;
505
+ this.#registrationApplied = generation;
506
+ // §8.6.3: a registration change re-derives the presence grant — capture
507
+ // the keys before, reconcile after (leaves on lost keys, snapshots on
508
+ // gained keys).
509
+ const previousKeys = this.#currentScopeKeys();
510
+ this.registrations = registrations;
464
511
  this.reconcilePresence(previousKeys);
465
512
  }
466
513
  /** §8.7 protocol violation: drop the connection, fail loud. The
@@ -470,13 +517,19 @@ export class RealtimeSession {
470
517
  this.#closeSocket?.();
471
518
  this.#hub.disconnect(this);
472
519
  }
473
- async #persistCursor() {
474
- try {
520
+ #persistCursor() {
521
+ // §8.2: start the write now and keep it on the control tail so `drain()`
522
+ // can await it. Two acks may write concurrently: storage folds them with
523
+ // `MAX(cursor, ?)`, so neither order loses the later cursor.
524
+ const write = (async () => {
475
525
  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
- }
526
+ })();
527
+ write.catch((error) => {
528
+ // Best-effort for the connection (the next pull repairs the cursor);
529
+ // `drain()` carries the first failure to the host.
530
+ this.#controlFailure ??= error;
531
+ });
532
+ this.#controlTail = Promise.allSettled([this.#controlTail, write]).then(() => undefined);
480
533
  }
481
534
  /** Fire-and-forget send for control text and deltas; a host send
482
535
  * failure surfaces on the socket, not here. */
@@ -872,6 +925,31 @@ export class RealtimeHub {
872
925
  session.deliverCommit(commit);
873
926
  }
874
927
  }
928
+ /**
929
+ * Host-initiated, fail-closed registration refresh (§8.7). A host calls this
930
+ * after it changes an actor's membership or connection state, so the
931
+ * partition's connected sessions stop using grants the host already revoked
932
+ * without waiting for the client to happen to run a round.
933
+ *
934
+ * This differs deliberately from the round-end refresh
935
+ * (`RealtimeSession.refreshRegistrations`), which keeps the previous
936
+ * registrations when the client record or resolver cannot be read so a
937
+ * failed round changes nothing. Here an unresolvable session is emptied:
938
+ * a revoked or unreadable grant must stop receiving deltas rather than live
939
+ * on until the next round. Presence is reconciled exactly as at round end.
940
+ */
941
+ async refreshScopes(partition, actorId) {
942
+ for (const session of [...this.#sessions]) {
943
+ if (session.partition !== partition)
944
+ continue;
945
+ if (actorId !== undefined && session.actorId !== actorId)
946
+ continue;
947
+ // Fail closed (see the doc above): an unreadable client record drops
948
+ // every grant on the connection. The session's generation guard keeps
949
+ // this revocation from being overwritten by a slower round-end reload.
950
+ await session.refreshRegistrations(true);
951
+ }
952
+ }
875
953
  /** Broadcast a wake-up (host-initiated resync, schema rollover, §8.3). */
876
954
  wake(partition, reason) {
877
955
  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