@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.
- package/README.md +20 -6
- package/dist/pg-executor.d.ts +11 -1
- package/dist/postgres-storage.d.ts +9 -2
- package/dist/postgres-storage.js +79 -3
- package/dist/pull.js +9 -5
- package/dist/realtime.d.ts +40 -1
- package/dist/realtime.js +96 -18
- package/dist/relational-rows.d.ts +20 -0
- package/dist/relational-rows.js +29 -0
- package/dist/s3-segment-store.d.ts +20 -8
- package/dist/s3-segment-store.js +216 -68
- package/dist/schema.js +11 -0
- package/dist/segment-download.d.ts +1 -1
- package/dist/segment-download.js +42 -17
- package/dist/segment-store.d.ts +86 -0
- package/dist/segment-store.js +156 -17
- package/dist/signed-url.d.ts +7 -1
- package/dist/signed-url.js +10 -1
- package/dist/sqlite-dialect.d.ts +7 -1
- package/dist/sqlite-dialect.js +8 -0
- package/dist/sqlite-segment-store.js +153 -42
- package/dist/sqlite-storage.js +50 -1
- package/dist/storage.d.ts +25 -0
- package/dist/validate.d.ts +20 -1
- package/package.json +2 -2
- package/src/pg-executor.ts +11 -1
- package/src/postgres-storage.ts +90 -2
- package/src/pull.ts +9 -1
- package/src/realtime.ts +95 -17
- package/src/relational-rows.ts +36 -0
- package/src/s3-segment-store.ts +275 -84
- package/src/schema.ts +15 -0
- package/src/segment-download.ts +63 -28
- package/src/segment-store.ts +250 -18
- package/src/signed-url.ts +12 -2
- package/src/sqlite-dialect.ts +8 -0
- package/src/sqlite-segment-store.ts +245 -65
- package/src/sqlite-storage.ts +55 -0
- package/src/storage.ts +25 -0
- package/src/validate.ts +20 -1
package/dist/validate.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
|
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.
|
|
71
|
+
"@syncular/core": "0.22.0"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/pg-executor.ts
CHANGED
|
@@ -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). */
|
package/src/postgres-storage.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
647
|
+
registrations = await this.#hub.loadRegistrations(
|
|
611
648
|
this.partition,
|
|
612
649
|
this.actorId,
|
|
613
650
|
this.clientId,
|
|
614
651
|
);
|
|
615
652
|
} catch {
|
|
616
|
-
|
|
617
|
-
//
|
|
618
|
-
|
|
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
|
-
|
|
632
|
-
|
|
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
|
-
}
|
|
642
|
-
|
|
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) {
|
package/src/relational-rows.ts
CHANGED
|
@@ -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,
|