@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.
- package/README.md +20 -6
- package/dist/admin.d.ts +3 -1
- package/dist/admin.js +7 -1
- package/dist/blob-handlers.js +1 -1
- package/dist/context.d.ts +8 -1
- package/dist/d1-storage.d.ts +11 -3
- package/dist/d1-storage.js +84 -2
- package/dist/errors.js +10 -0
- package/dist/handler.js +50 -4
- package/dist/operations.js +3 -3
- package/dist/pg-executor.d.ts +11 -1
- package/dist/postgres-storage.d.ts +20 -5
- package/dist/postgres-storage.js +345 -9
- package/dist/pull.js +9 -5
- package/dist/push.js +18 -0
- package/dist/readiness.d.ts +9 -2
- package/dist/readiness.js +27 -2
- package/dist/realtime.d.ts +40 -1
- package/dist/realtime.js +116 -19
- 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 +50 -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 +23 -0
- package/dist/sqlite-segment-store.js +153 -42
- package/dist/sqlite-storage.d.ts +11 -3
- package/dist/sqlite-storage.js +346 -34
- package/dist/storage-errors.d.ts +1 -1
- package/dist/storage-errors.js +4 -0
- package/dist/storage.d.ts +152 -2
- package/dist/storage.js +40 -1
- package/dist/validate.d.ts +20 -1
- package/package.json +2 -2
- package/src/admin.ts +13 -1
- package/src/blob-handlers.ts +1 -1
- package/src/context.ts +8 -0
- package/src/d1-storage.ts +143 -1
- package/src/errors.ts +10 -0
- package/src/handler.ts +62 -4
- package/src/operations.ts +17 -13
- package/src/pg-executor.ts +11 -1
- package/src/postgres-storage.ts +520 -5
- package/src/pull.ts +9 -1
- package/src/push.ts +18 -0
- package/src/readiness.ts +39 -4
- package/src/realtime.ts +117 -18
- 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 +79 -28
- package/src/segment-store.ts +250 -18
- package/src/signed-url.ts +12 -2
- package/src/sqlite-dialect.ts +23 -0
- package/src/sqlite-segment-store.ts +245 -65
- package/src/sqlite-storage.ts +494 -36
- package/src/storage-errors.ts +13 -1
- package/src/storage.ts +243 -1
- package/src/validate.ts +20 -1
package/src/pull.ts
CHANGED
|
@@ -180,6 +180,7 @@ async function signedUrlFields(
|
|
|
180
180
|
|
|
181
181
|
function segmentRefFrame(
|
|
182
182
|
record: SegmentRecord,
|
|
183
|
+
scopeDigest: string,
|
|
183
184
|
extra: { url?: string; urlExpiresAtMs?: number },
|
|
184
185
|
): ResponseFrame {
|
|
185
186
|
return {
|
|
@@ -190,7 +191,11 @@ function segmentRefFrame(
|
|
|
190
191
|
byteLength: record.byteLength,
|
|
191
192
|
rowCount: record.rowCount,
|
|
192
193
|
asOfCommitSeq: record.asOfCommitSeq,
|
|
193
|
-
|
|
194
|
+
// The frame reports the digest THIS subscription is authorized under. A
|
|
195
|
+
// stored entry can carry several digests when identical content was
|
|
196
|
+
// published under different scopes (§5.1), and the record's primary
|
|
197
|
+
// digest is not necessarily the caller's (§5.5).
|
|
198
|
+
scopeDigest,
|
|
194
199
|
...(record.rowCursor !== null ? { rowCursor: record.rowCursor } : {}),
|
|
195
200
|
...(record.nextRowCursor !== null
|
|
196
201
|
? { nextRowCursor: record.nextRowCursor }
|
|
@@ -244,6 +249,7 @@ async function* sqliteImageSegment(
|
|
|
244
249
|
});
|
|
245
250
|
yield segmentRefFrame(
|
|
246
251
|
existing,
|
|
252
|
+
digest,
|
|
247
253
|
await signedUrlFields(ctx, limits, existing.segmentId, digest, now),
|
|
248
254
|
);
|
|
249
255
|
return true;
|
|
@@ -342,6 +348,7 @@ async function* sqliteImageSegment(
|
|
|
342
348
|
});
|
|
343
349
|
yield segmentRefFrame(
|
|
344
350
|
record,
|
|
351
|
+
digest,
|
|
345
352
|
await signedUrlFields(
|
|
346
353
|
ctx,
|
|
347
354
|
limits,
|
|
@@ -458,6 +465,7 @@ async function* bootstrapSegments(
|
|
|
458
465
|
});
|
|
459
466
|
yield segmentRefFrame(
|
|
460
467
|
record,
|
|
468
|
+
digest,
|
|
461
469
|
await signedUrlFields(ctx, limits, record.segmentId, digest, now),
|
|
462
470
|
);
|
|
463
471
|
}
|
package/src/push.ts
CHANGED
|
@@ -52,7 +52,9 @@ import type {
|
|
|
52
52
|
StoredPushResult,
|
|
53
53
|
StoredRow,
|
|
54
54
|
} from './storage';
|
|
55
|
+
import { serveGateRefusal } from './storage';
|
|
55
56
|
import { StorageConstraintError } from './storage-errors';
|
|
57
|
+
import { serveNotReadyError } from './readiness';
|
|
56
58
|
import type {
|
|
57
59
|
CommitValidationReader,
|
|
58
60
|
CommitValidator,
|
|
@@ -1356,6 +1358,15 @@ export async function processPushOperationsWithTrace(
|
|
|
1356
1358
|
);
|
|
1357
1359
|
}
|
|
1358
1360
|
await lockPartitionForPush();
|
|
1361
|
+
// RFC 0007: evaluate the gate on this transaction's own connection while
|
|
1362
|
+
// the partition lock is held, so the migration check and the write share
|
|
1363
|
+
// one transaction and no migration can interleave in between.
|
|
1364
|
+
const gate = await tx.readServeGate(schema.version);
|
|
1365
|
+
const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
|
|
1366
|
+
if (refusal !== undefined) {
|
|
1367
|
+
await tx.rollback();
|
|
1368
|
+
throw serveNotReadyError(refusal);
|
|
1369
|
+
}
|
|
1359
1370
|
// The optimistic lookup above may have raced another delivery. Re-check
|
|
1360
1371
|
// only after acquiring partition serialization and before any operation
|
|
1361
1372
|
// read, validation, merge, or staged write. The re-check runs on the
|
|
@@ -1551,6 +1562,13 @@ export async function processPushOperationsWithTrace(
|
|
|
1551
1562
|
}
|
|
1552
1563
|
}
|
|
1553
1564
|
if (ctx.realtime !== undefined && changes.length > 0) {
|
|
1565
|
+
// RFC 0007 fanout decision: the notification carries the durable commit
|
|
1566
|
+
// entry itself (this commit's `changes`), not a projection read or a pull
|
|
1567
|
+
// window, so fanning it out before the request's buffered read-verify can
|
|
1568
|
+
// never serve a mixed result. Each peer's next pull is gated at the
|
|
1569
|
+
// storage seam; a refused mixed push+pull is safe because the push is
|
|
1570
|
+
// durable and replayable under its commit id. This is commit-entry
|
|
1571
|
+
// durability, not gate inheritance.
|
|
1554
1572
|
await ctx.realtime.notifyCommit(partition, {
|
|
1555
1573
|
commitSeq,
|
|
1556
1574
|
createdAtMs,
|
package/src/readiness.ts
CHANGED
|
@@ -7,12 +7,17 @@
|
|
|
7
7
|
* a structured failure that cannot be confused with request authentication.
|
|
8
8
|
*/
|
|
9
9
|
import type { SyncServerConfig } from './context';
|
|
10
|
+
import { type SyncError, syncError } from './errors';
|
|
10
11
|
import { type CompiledSchema, compileSchema } from './schema';
|
|
12
|
+
import type { ServeGateRefusal } from './storage';
|
|
11
13
|
|
|
12
14
|
export const SYNC_SERVER_READINESS_ERROR_CODE =
|
|
13
15
|
'sync.schema_not_ready' as const;
|
|
14
16
|
|
|
15
|
-
export type SyncServerReadinessPhase =
|
|
17
|
+
export type SyncServerReadinessPhase =
|
|
18
|
+
| 'schema_compile'
|
|
19
|
+
| 'storage_migration'
|
|
20
|
+
| 'backfill_checkpoint';
|
|
16
21
|
|
|
17
22
|
export class SyncServerReadinessError extends Error {
|
|
18
23
|
override readonly name = 'SyncServerReadinessError';
|
|
@@ -42,7 +47,7 @@ export class SyncServerReadinessError extends Error {
|
|
|
42
47
|
* `cause` locally for the table/column or storage diagnostic.
|
|
43
48
|
*/
|
|
44
49
|
export async function ensureSyncServerReady(
|
|
45
|
-
config: Pick<SyncServerConfig, 'schema' | 'storage'>,
|
|
50
|
+
config: Pick<SyncServerConfig, 'schema' | 'storage' | 'checkpoints'>,
|
|
46
51
|
): Promise<void> {
|
|
47
52
|
let compiled: CompiledSchema;
|
|
48
53
|
try {
|
|
@@ -55,12 +60,42 @@ export async function ensureSyncServerReady(
|
|
|
55
60
|
});
|
|
56
61
|
}
|
|
57
62
|
try {
|
|
58
|
-
await config.storage.ensureSchema(compiled);
|
|
63
|
+
await config.storage.ensureSchema(compiled, config.checkpoints);
|
|
59
64
|
} catch (cause) {
|
|
65
|
+
// RFC 0007: a declared-but-incomplete checkpoint is a barrier refusal, not
|
|
66
|
+
// an ordinary migration failure — hosts distinguish it by `phase` while the
|
|
67
|
+
// client-visible identity stays `sync.schema_not_ready`.
|
|
68
|
+
const phase: SyncServerReadinessPhase =
|
|
69
|
+
(cause as { code?: unknown } | null | undefined)?.code ===
|
|
70
|
+
'sync.storage.checkpoint_incomplete'
|
|
71
|
+
? 'backfill_checkpoint'
|
|
72
|
+
: 'storage_migration';
|
|
60
73
|
throw new SyncServerReadinessError({
|
|
61
|
-
phase
|
|
74
|
+
phase,
|
|
62
75
|
schemaVersion: config.schema.version,
|
|
63
76
|
cause,
|
|
64
77
|
});
|
|
65
78
|
}
|
|
66
79
|
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* RFC 0007: refuse a serve path that observed a closed gate. The projection
|
|
83
|
+
* name travels in structured details, never in the message.
|
|
84
|
+
*/
|
|
85
|
+
export function serveNotReadyError(refusal: ServeGateRefusal): SyncError {
|
|
86
|
+
return syncError(
|
|
87
|
+
SYNC_SERVER_READINESS_ERROR_CODE,
|
|
88
|
+
'server schema is not ready for this request',
|
|
89
|
+
refusal.kind === 'checkpoint'
|
|
90
|
+
? JSON.stringify({
|
|
91
|
+
partition: refusal.partition,
|
|
92
|
+
projection: refusal.name,
|
|
93
|
+
})
|
|
94
|
+
: refusal.kind === 'epoch'
|
|
95
|
+
? JSON.stringify({ logEpochChanged: true })
|
|
96
|
+
: JSON.stringify({
|
|
97
|
+
storedSchemaVersion: refusal.stored,
|
|
98
|
+
runningSchemaVersion: refusal.running,
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
}
|
package/src/realtime.ts
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A `RealtimeSession` wraps a connected socket's send/receive callbacks:
|
|
5
5
|
* the host feeds inbound text frames to `handleMessage`, binary frames to
|
|
6
|
-
* `handleBinary`, and wires `session.close()` to socket close.
|
|
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. */
|
|
@@ -575,7 +600,28 @@ export class RealtimeSession {
|
|
|
575
600
|
// begin its next round as soon as END *arrives* (§8.7 rule 3), so
|
|
576
601
|
// in-flight must end with the send, not with generator cleanup.
|
|
577
602
|
const iterator = stream[Symbol.asyncIterator]();
|
|
578
|
-
let step
|
|
603
|
+
let step: IteratorResult<Uint8Array>;
|
|
604
|
+
try {
|
|
605
|
+
step = await iterator.next();
|
|
606
|
+
} catch (error) {
|
|
607
|
+
// The buffered read-verify refusal is thrown on the generator's first
|
|
608
|
+
// `next()`, before any chunk is yielded (the generator builds the whole
|
|
609
|
+
// response, then verifies). Nothing has escaped, so it is delivered as
|
|
610
|
+
// the same §8.4 ERROR frame HTTP returns rather than a silent close. A
|
|
611
|
+
// throw after the first chunk still takes the §8.7 violation path.
|
|
612
|
+
if (error instanceof DecodeError || error instanceof SyncError) {
|
|
613
|
+
const sync =
|
|
614
|
+
error instanceof SyncError
|
|
615
|
+
? error
|
|
616
|
+
: syncError(error.code, error.message);
|
|
617
|
+
finishRound();
|
|
618
|
+
await this.#sendRoundChunk(
|
|
619
|
+
errorResponseBytes(sync, this.wireVersion, this.logEpoch),
|
|
620
|
+
);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
579
625
|
while (!step.done) {
|
|
580
626
|
const chunk = step.value;
|
|
581
627
|
step = await iterator.next();
|
|
@@ -590,7 +636,7 @@ export class RealtimeSession {
|
|
|
590
636
|
finishRound();
|
|
591
637
|
// §8.7 registration at round end: reload the persisted list — it
|
|
592
638
|
// only advances on success, so failed rounds change nothing.
|
|
593
|
-
await this
|
|
639
|
+
await this.refreshRegistrations(false);
|
|
594
640
|
}
|
|
595
641
|
}
|
|
596
642
|
|
|
@@ -601,22 +647,42 @@ export class RealtimeSession {
|
|
|
601
647
|
await this.#send(tagged);
|
|
602
648
|
}
|
|
603
649
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
650
|
+
/**
|
|
651
|
+
* Reload this connection's registrations and reconcile presence (§8.7).
|
|
652
|
+
* The round-end refresh and the host-initiated `RealtimeHub.refreshScopes`
|
|
653
|
+
* both route through here under one per-session generation, so a load that
|
|
654
|
+
* resolves late can never overwrite a newer result: a slow resolver may
|
|
655
|
+
* return scopes a later refresh already revoked, and the later refresh
|
|
656
|
+
* keeps the connection's current grants.
|
|
657
|
+
*
|
|
658
|
+
* `failClosed` selects the failure semantics. The round-end refresh keeps
|
|
659
|
+
* the previous registrations so a failed round changes nothing. The host
|
|
660
|
+
* refresh empties them so a revoked or unreadable grant stops receiving
|
|
661
|
+
* deltas immediately; it also survives a newer round-end refresh that
|
|
662
|
+
* failed, because that round applied nothing.
|
|
663
|
+
*/
|
|
664
|
+
async refreshRegistrations(failClosed: boolean): Promise<void> {
|
|
665
|
+
const generation = ++this.#registrationGeneration;
|
|
666
|
+
let registrations: Registration[];
|
|
609
667
|
try {
|
|
610
|
-
|
|
668
|
+
registrations = await this.#hub.loadRegistrations(
|
|
611
669
|
this.partition,
|
|
612
670
|
this.actorId,
|
|
613
671
|
this.clientId,
|
|
614
672
|
);
|
|
615
673
|
} catch {
|
|
616
|
-
|
|
617
|
-
//
|
|
618
|
-
|
|
674
|
+
if (!failClosed) return;
|
|
675
|
+
// Fail closed (host refresh): drop every grant on the connection.
|
|
676
|
+
registrations = [];
|
|
619
677
|
}
|
|
678
|
+
// A newer refresh already applied its result; this late one is stale.
|
|
679
|
+
if (generation < this.#registrationApplied) return;
|
|
680
|
+
this.#registrationApplied = generation;
|
|
681
|
+
// §8.6.3: a registration change re-derives the presence grant — capture
|
|
682
|
+
// the keys before, reconcile after (leaves on lost keys, snapshots on
|
|
683
|
+
// gained keys).
|
|
684
|
+
const previousKeys = this.#currentScopeKeys();
|
|
685
|
+
this.registrations = registrations;
|
|
620
686
|
this.reconcilePresence(previousKeys);
|
|
621
687
|
}
|
|
622
688
|
|
|
@@ -628,8 +694,11 @@ export class RealtimeSession {
|
|
|
628
694
|
this.#hub.disconnect(this);
|
|
629
695
|
}
|
|
630
696
|
|
|
631
|
-
|
|
632
|
-
|
|
697
|
+
#persistCursor(): void {
|
|
698
|
+
// §8.2: start the write now and keep it on the control tail so `drain()`
|
|
699
|
+
// can await it. Two acks may write concurrently: storage folds them with
|
|
700
|
+
// `MAX(cursor, ?)`, so neither order loses the later cursor.
|
|
701
|
+
const write = (async () => {
|
|
633
702
|
await this.#storage.advanceClientCursor(
|
|
634
703
|
this.partition,
|
|
635
704
|
this.clientId,
|
|
@@ -638,9 +707,15 @@ export class RealtimeSession {
|
|
|
638
707
|
this.cursor,
|
|
639
708
|
this.#clock(),
|
|
640
709
|
);
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
|
|
710
|
+
})();
|
|
711
|
+
write.catch((error: unknown) => {
|
|
712
|
+
// Best-effort for the connection (the next pull repairs the cursor);
|
|
713
|
+
// `drain()` carries the first failure to the host.
|
|
714
|
+
this.#controlFailure ??= error;
|
|
715
|
+
});
|
|
716
|
+
this.#controlTail = Promise.allSettled([this.#controlTail, write]).then(
|
|
717
|
+
() => undefined,
|
|
718
|
+
);
|
|
644
719
|
}
|
|
645
720
|
|
|
646
721
|
/** Fire-and-forget send for control text and deltas; a host send
|
|
@@ -1106,6 +1181,30 @@ export class RealtimeHub {
|
|
|
1106
1181
|
}
|
|
1107
1182
|
}
|
|
1108
1183
|
|
|
1184
|
+
/**
|
|
1185
|
+
* Host-initiated, fail-closed registration refresh (§8.7). A host calls this
|
|
1186
|
+
* after it changes an actor's membership or connection state, so the
|
|
1187
|
+
* partition's connected sessions stop using grants the host already revoked
|
|
1188
|
+
* without waiting for the client to happen to run a round.
|
|
1189
|
+
*
|
|
1190
|
+
* This differs deliberately from the round-end refresh
|
|
1191
|
+
* (`RealtimeSession.refreshRegistrations`), which keeps the previous
|
|
1192
|
+
* registrations when the client record or resolver cannot be read so a
|
|
1193
|
+
* failed round changes nothing. Here an unresolvable session is emptied:
|
|
1194
|
+
* a revoked or unreadable grant must stop receiving deltas rather than live
|
|
1195
|
+
* on until the next round. Presence is reconciled exactly as at round end.
|
|
1196
|
+
*/
|
|
1197
|
+
async refreshScopes(partition: string, actorId?: string): Promise<void> {
|
|
1198
|
+
for (const session of [...this.#sessions]) {
|
|
1199
|
+
if (session.partition !== partition) continue;
|
|
1200
|
+
if (actorId !== undefined && session.actorId !== actorId) continue;
|
|
1201
|
+
// Fail closed (see the doc above): an unreadable client record drops
|
|
1202
|
+
// every grant on the connection. The session's generation guard keeps
|
|
1203
|
+
// this revocation from being overwritten by a slower round-end reload.
|
|
1204
|
+
await session.refreshRegistrations(true);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1109
1208
|
/** Broadcast a wake-up (host-initiated resync, schema rollover, §8.3). */
|
|
1110
1209
|
wake(partition: string, reason: WakeReason): void {
|
|
1111
1210
|
for (const session of this.#sessions) {
|
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,
|