@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/postgres-storage.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { validateCommitPruneQuery } from './prune';
|
|
2
2
|
import { StorageQueryError } from './storage-errors';
|
|
3
3
|
import type { CommitPruneQuery, CommitPruneResult } from './storage';
|
|
4
|
+
import { serveGateRefusal } from './storage';
|
|
5
|
+
import { serveNotReadyError } from './readiness';
|
|
4
6
|
/**
|
|
5
7
|
* Postgres server storage: the production database path.
|
|
6
8
|
*
|
|
@@ -52,6 +54,7 @@ import {
|
|
|
52
54
|
type PgQueryable,
|
|
53
55
|
} from './pg-executor';
|
|
54
56
|
import {
|
|
57
|
+
assertPhysicalColumns,
|
|
55
58
|
commitWindowPageSql,
|
|
56
59
|
deleteRowSql,
|
|
57
60
|
dropTableDdl,
|
|
@@ -78,6 +81,7 @@ import { matchesEffective } from './scopes';
|
|
|
78
81
|
import type {
|
|
79
82
|
AuthoritativeQueryRequest,
|
|
80
83
|
AuthoritativeQueryResult,
|
|
84
|
+
CheckpointDeclaration,
|
|
81
85
|
ClientCursorInfo,
|
|
82
86
|
ClientRecord,
|
|
83
87
|
ClientSubscription,
|
|
@@ -99,8 +103,10 @@ import type {
|
|
|
99
103
|
ScopeActivityQuery,
|
|
100
104
|
ScopeCommitActivity,
|
|
101
105
|
ServerStorage,
|
|
106
|
+
ServeGate,
|
|
102
107
|
StorageTransaction,
|
|
103
108
|
StoredChange,
|
|
109
|
+
StoredCheckpoint,
|
|
104
110
|
StoredCommit,
|
|
105
111
|
StoredPushResult,
|
|
106
112
|
StoredReaction,
|
|
@@ -122,7 +128,14 @@ import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query';
|
|
|
122
128
|
* - `sync_row_scopes_pk (partition, tbl, var, value, row_id)` — same shape
|
|
123
129
|
* for `scanRows`, ordered by `row_id`.
|
|
124
130
|
* Both are the PRIMARY KEY, so they are the clustering/covering index for
|
|
125
|
-
* their table.
|
|
131
|
+
* their table.
|
|
132
|
+
*
|
|
133
|
+
* `sync_row_scopes_by_row (partition, tbl, row_id)` is the one secondary
|
|
134
|
+
* scope index: `writeRowOn` and `deleteRow` replace a row's scope entries
|
|
135
|
+
* with a `(partition, tbl, row_id)` predicate, and `row_id` is the LAST
|
|
136
|
+
* column of the inverted PRIMARY KEY, so without this index that predicate
|
|
137
|
+
* scans the table's whole scope range on every row written (a bulk import
|
|
138
|
+
* costs one such scan per row).
|
|
126
139
|
*
|
|
127
140
|
* Blob reference index (§5.9.4) — parity with the SQLite dialect's
|
|
128
141
|
* `sync_blob_refs`:
|
|
@@ -151,10 +164,13 @@ CREATE TABLE IF NOT EXISTS sync_row_scopes(
|
|
|
151
164
|
var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,
|
|
152
165
|
PRIMARY KEY(partition, tbl, var, value, row_id)
|
|
153
166
|
);
|
|
167
|
+
CREATE INDEX IF NOT EXISTS sync_row_scopes_by_row
|
|
168
|
+
ON sync_row_scopes(partition, tbl, row_id);
|
|
154
169
|
CREATE TABLE IF NOT EXISTS sync_commits(
|
|
155
170
|
partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,
|
|
156
171
|
client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,
|
|
157
172
|
actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,
|
|
173
|
+
writer_version BIGINT,
|
|
158
174
|
PRIMARY KEY(partition, commit_seq)
|
|
159
175
|
);
|
|
160
176
|
CREATE INDEX IF NOT EXISTS sync_commits_by_time
|
|
@@ -218,6 +234,20 @@ CREATE TABLE IF NOT EXISTS sync_tombstones(
|
|
|
218
234
|
commit_seq BIGINT NOT NULL,
|
|
219
235
|
PRIMARY KEY(partition, tbl, row_id)
|
|
220
236
|
);
|
|
237
|
+
CREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(
|
|
238
|
+
partition TEXT NOT NULL, name TEXT NOT NULL,
|
|
239
|
+
schema_version INTEGER NOT NULL,
|
|
240
|
+
state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),
|
|
241
|
+
watermark BIGINT NOT NULL DEFAULT 0,
|
|
242
|
+
owner_epoch BIGINT NOT NULL DEFAULT 0,
|
|
243
|
+
observed_rows BIGINT NOT NULL DEFAULT 0,
|
|
244
|
+
updated_at_ms BIGINT NOT NULL,
|
|
245
|
+
PRIMARY KEY(partition, name)
|
|
246
|
+
);
|
|
247
|
+
CREATE TABLE IF NOT EXISTS sync_writer_fence(
|
|
248
|
+
partition TEXT PRIMARY KEY,
|
|
249
|
+
required_writer_version INTEGER NOT NULL
|
|
250
|
+
);
|
|
221
251
|
`;
|
|
222
252
|
|
|
223
253
|
interface SerializedResult {
|
|
@@ -254,6 +284,96 @@ async function lockPartitionOn(
|
|
|
254
284
|
);
|
|
255
285
|
}
|
|
256
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Insert the `declared` row and raise the fence, on the caller's open
|
|
289
|
+
* transaction. Shared by `declareCheckpoint` and the `ensureSchema`
|
|
290
|
+
* migration so the schema bump and the fence commit together. Never lowers a
|
|
291
|
+
* fence and never re-declares an existing row.
|
|
292
|
+
*/
|
|
293
|
+
async function installCheckpointOn(
|
|
294
|
+
client: PgQueryable,
|
|
295
|
+
partition: string,
|
|
296
|
+
name: string,
|
|
297
|
+
schemaVersion: number,
|
|
298
|
+
nowMs: number,
|
|
299
|
+
): Promise<void> {
|
|
300
|
+
await client.query(
|
|
301
|
+
`INSERT INTO sync_backfill_checkpoints(
|
|
302
|
+
partition, name, schema_version, state, watermark,
|
|
303
|
+
owner_epoch, observed_rows, updated_at_ms)
|
|
304
|
+
VALUES ($1,$2,$3,'declared',0,0,0,$4)
|
|
305
|
+
ON CONFLICT (partition, name) DO NOTHING`,
|
|
306
|
+
[partition, name, schemaVersion, nowMs],
|
|
307
|
+
);
|
|
308
|
+
await client.query(
|
|
309
|
+
`INSERT INTO sync_writer_fence(partition, required_writer_version)
|
|
310
|
+
VALUES ($1,$2)
|
|
311
|
+
ON CONFLICT (partition) DO UPDATE SET
|
|
312
|
+
required_writer_version=GREATEST(
|
|
313
|
+
sync_writer_fence.required_writer_version,
|
|
314
|
+
EXCLUDED.required_writer_version)`,
|
|
315
|
+
[partition, schemaVersion],
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** RFC 0007 serve gate on either the pool or a transaction's client. */
|
|
320
|
+
async function readServeGateOn(
|
|
321
|
+
client: PgQueryable,
|
|
322
|
+
partition: string,
|
|
323
|
+
runningSchemaVersion: number,
|
|
324
|
+
): Promise<ServeGate> {
|
|
325
|
+
const marker = await client.query<{ schema_version: unknown }>(
|
|
326
|
+
'SELECT schema_version FROM sync_schema_meta WHERE id=1',
|
|
327
|
+
);
|
|
328
|
+
const epoch = await client.query<{ log_epoch: string }>(
|
|
329
|
+
'SELECT log_epoch FROM sync_partition_registry WHERE partition=$1',
|
|
330
|
+
[partition],
|
|
331
|
+
);
|
|
332
|
+
const pending = await client.query<{ partition: string; name: string }>(
|
|
333
|
+
`SELECT partition, name FROM sync_backfill_checkpoints
|
|
334
|
+
WHERE schema_version=$1 AND state<>'activated'`,
|
|
335
|
+
[runningSchemaVersion],
|
|
336
|
+
);
|
|
337
|
+
return {
|
|
338
|
+
storedSchemaVersion:
|
|
339
|
+
marker.rows[0] === undefined
|
|
340
|
+
? 0
|
|
341
|
+
: asNumber(marker.rows[0].schema_version),
|
|
342
|
+
logEpoch: epoch.rows[0]?.log_epoch,
|
|
343
|
+
pending: pending.rows.map((row) => ({
|
|
344
|
+
partition: row.partition,
|
|
345
|
+
name: row.name,
|
|
346
|
+
})),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function hasSourceChangesAboveOn(
|
|
351
|
+
client: PgQueryable,
|
|
352
|
+
partition: string,
|
|
353
|
+
tables: readonly string[],
|
|
354
|
+
seq: number,
|
|
355
|
+
): Promise<'clean' | 'changed' | 'unverifiable'> {
|
|
356
|
+
// Read the horizon before the window scan: a horizon past `seq` means the
|
|
357
|
+
// history that would answer the question is gone, so an empty scan proves
|
|
358
|
+
// nothing and must not be reported as `clean`.
|
|
359
|
+
const horizon = await client.query<{ horizon_seq: unknown }>(
|
|
360
|
+
'SELECT horizon_seq FROM sync_partitions WHERE partition=$1',
|
|
361
|
+
[partition],
|
|
362
|
+
);
|
|
363
|
+
const horizonSeq =
|
|
364
|
+
horizon.rows[0] === undefined ? 0 : asNumber(horizon.rows[0].horizon_seq);
|
|
365
|
+
if (tables.length === 0) return 'clean';
|
|
366
|
+
const tableParams = tables.map((_, index) => `$${index + 2}`).join(',');
|
|
367
|
+
const hit = await client.query<{ hit: number }>(
|
|
368
|
+
`SELECT 1 AS hit FROM sync_changes
|
|
369
|
+
WHERE partition=$1 AND tbl IN (${tableParams}) AND commit_seq>$${tables.length + 2}
|
|
370
|
+
LIMIT 1`,
|
|
371
|
+
[partition, ...tables, seq],
|
|
372
|
+
);
|
|
373
|
+
if (hit.rows[0] !== undefined) return 'changed';
|
|
374
|
+
return horizonSeq > seq ? 'unverifiable' : 'clean';
|
|
375
|
+
}
|
|
376
|
+
|
|
257
377
|
function toBase64(bytes: Uint8Array): string {
|
|
258
378
|
return Buffer.from(bytes).toString('base64');
|
|
259
379
|
}
|
|
@@ -262,6 +382,32 @@ function fromBase64(text: string): Uint8Array {
|
|
|
262
382
|
return new Uint8Array(Buffer.from(text, 'base64'));
|
|
263
383
|
}
|
|
264
384
|
|
|
385
|
+
interface PostgresCheckpointRecord {
|
|
386
|
+
partition: string;
|
|
387
|
+
name: string;
|
|
388
|
+
schema_version: unknown;
|
|
389
|
+
state: StoredCheckpoint['state'];
|
|
390
|
+
watermark: unknown;
|
|
391
|
+
owner_epoch: unknown;
|
|
392
|
+
observed_rows: unknown;
|
|
393
|
+
updated_at_ms: unknown;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function toStoredCheckpoint(
|
|
397
|
+
record: PostgresCheckpointRecord,
|
|
398
|
+
): StoredCheckpoint {
|
|
399
|
+
return {
|
|
400
|
+
partition: record.partition,
|
|
401
|
+
name: record.name,
|
|
402
|
+
schemaVersion: asNumber(record.schema_version),
|
|
403
|
+
state: record.state,
|
|
404
|
+
watermark: asNumber(record.watermark),
|
|
405
|
+
ownerEpoch: asNumber(record.owner_epoch),
|
|
406
|
+
observedRows: asNumber(record.observed_rows),
|
|
407
|
+
updatedAtMs: asNumber(record.updated_at_ms),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
265
411
|
interface PostgresReactionRecord {
|
|
266
412
|
idempotency_key: string;
|
|
267
413
|
type: string;
|
|
@@ -587,6 +733,8 @@ async function getPushResultOn(
|
|
|
587
733
|
class PostgresTransaction implements StorageTransaction {
|
|
588
734
|
#client: PgQueryable;
|
|
589
735
|
#partition: string;
|
|
736
|
+
/** Schema version this transaction writes; rides every appended commit row. */
|
|
737
|
+
#writerVersion: number;
|
|
590
738
|
#resolveTable: (name: string) => CompiledTable;
|
|
591
739
|
#open = true;
|
|
592
740
|
#pushApplySavepoint = false;
|
|
@@ -597,12 +745,30 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
597
745
|
constructor(
|
|
598
746
|
client: PgQueryable,
|
|
599
747
|
partition: string,
|
|
748
|
+
writerVersion: number,
|
|
600
749
|
resolveTable: (name: string) => CompiledTable,
|
|
601
750
|
resolve: () => void,
|
|
602
751
|
reject: (error: unknown) => void,
|
|
603
752
|
) {
|
|
604
|
-
|
|
753
|
+
// One pinned connection carries one statement at a time: node-postgres
|
|
754
|
+
// warns about a concurrently issued second query today and rejects it in
|
|
755
|
+
// node-postgres 9. A whole-commit validator that fans independent reads
|
|
756
|
+
// out with Promise.all must not interleave them, so every statement
|
|
757
|
+
// issued through this transaction is chained on one FIFO — the same
|
|
758
|
+
// shape as `SqliteServerStorage.queryAuthoritative`.
|
|
759
|
+
let tail: Promise<void> = Promise.resolve();
|
|
760
|
+
this.#client = {
|
|
761
|
+
query: <Row>(text: string, params?: readonly unknown[]) => {
|
|
762
|
+
const run = tail.then(() => client.query<Row>(text, params));
|
|
763
|
+
tail = run.then(
|
|
764
|
+
() => undefined,
|
|
765
|
+
() => undefined,
|
|
766
|
+
);
|
|
767
|
+
return run;
|
|
768
|
+
},
|
|
769
|
+
};
|
|
605
770
|
this.#partition = partition;
|
|
771
|
+
this.#writerVersion = writerVersion;
|
|
606
772
|
this.#resolveTable = resolveTable;
|
|
607
773
|
this.#resolve = resolve;
|
|
608
774
|
this.#reject = reject;
|
|
@@ -712,6 +878,35 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
712
878
|
this.#pushApplySavepoint = true;
|
|
713
879
|
}
|
|
714
880
|
|
|
881
|
+
async advanceCheckpoint(
|
|
882
|
+
name: string,
|
|
883
|
+
ownerEpoch: number,
|
|
884
|
+
watermark: number,
|
|
885
|
+
observedRows: number,
|
|
886
|
+
nowMs: number,
|
|
887
|
+
): Promise<boolean> {
|
|
888
|
+
this.#assertOpen();
|
|
889
|
+
// `RETURNING`: the affected-row check must not depend on a driver's
|
|
890
|
+
// `rowCount` for a statement with no result rows. Bun.sql's canonical
|
|
891
|
+
// adapter derives `rowCount` from the returned rows, so a bare UPDATE
|
|
892
|
+
// reports 0 and the CAS would never advance on a real Bun.sql host.
|
|
893
|
+
const result = await this.#client.query<{ name: string }>(
|
|
894
|
+
`UPDATE sync_backfill_checkpoints
|
|
895
|
+
SET watermark=$1, observed_rows=$2, updated_at_ms=$3
|
|
896
|
+
WHERE partition=$4 AND name=$5 AND owner_epoch=$6
|
|
897
|
+
RETURNING name`,
|
|
898
|
+
[watermark, observedRows, nowMs, this.#partition, name, ownerEpoch],
|
|
899
|
+
);
|
|
900
|
+
return result.rows.length === 1;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
readServeGate(runningSchemaVersion: number): Promise<ServeGate> {
|
|
904
|
+
this.#assertOpen();
|
|
905
|
+
// The transaction's own client: the gate and the write share one
|
|
906
|
+
// transaction, so a migration cannot interleave between them.
|
|
907
|
+
return readServeGateOn(this.#client, this.#partition, runningSchemaVersion);
|
|
908
|
+
}
|
|
909
|
+
|
|
715
910
|
async commitRejectedPushResult(
|
|
716
911
|
clientId: string,
|
|
717
912
|
clientCommitId: string,
|
|
@@ -800,8 +995,8 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
800
995
|
SET max_commit_seq = sync_partitions.max_commit_seq + 1
|
|
801
996
|
RETURNING max_commit_seq
|
|
802
997
|
)
|
|
803
|
-
INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms)
|
|
804
|
-
SELECT $1, max_commit_seq, $2, $3, $4, $5 FROM allocated
|
|
998
|
+
INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms, writer_version)
|
|
999
|
+
SELECT $1, max_commit_seq, $2, $3, $4, $5, $6 FROM allocated
|
|
805
1000
|
RETURNING commit_seq`,
|
|
806
1001
|
[
|
|
807
1002
|
p,
|
|
@@ -809,6 +1004,7 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
809
1004
|
commit.clientCommitId,
|
|
810
1005
|
commit.actorId,
|
|
811
1006
|
commit.createdAtMs,
|
|
1007
|
+
this.#writerVersion,
|
|
812
1008
|
],
|
|
813
1009
|
);
|
|
814
1010
|
const commitSeq = asNumber(rows[0]?.commit_seq);
|
|
@@ -946,6 +1142,33 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
946
1142
|
for (const statement of statements) {
|
|
947
1143
|
await this.#exec.query(statement);
|
|
948
1144
|
}
|
|
1145
|
+
await this.#exec.query(
|
|
1146
|
+
'ALTER TABLE sync_commits ADD COLUMN IF NOT EXISTS writer_version BIGINT',
|
|
1147
|
+
);
|
|
1148
|
+
// Database-side fence: an old binary's INSERT never reaches JS. An absent
|
|
1149
|
+
// `sync_writer_fence` row makes the EXISTS false, so a partition with no
|
|
1150
|
+
// barrier behaves exactly as before. Sent as separate statements because
|
|
1151
|
+
// the dollar-quoted body contains `;`.
|
|
1152
|
+
await this.#exec.query(`CREATE OR REPLACE FUNCTION syncular_writer_fence()
|
|
1153
|
+
RETURNS trigger AS $$
|
|
1154
|
+
BEGIN
|
|
1155
|
+
IF EXISTS (
|
|
1156
|
+
SELECT 1 FROM sync_writer_fence
|
|
1157
|
+
WHERE partition = NEW.partition
|
|
1158
|
+
AND (NEW.writer_version IS NULL
|
|
1159
|
+
OR NEW.writer_version < required_writer_version)
|
|
1160
|
+
) THEN
|
|
1161
|
+
RAISE EXCEPTION 'sync.storage.writer_fence_rejected';
|
|
1162
|
+
END IF;
|
|
1163
|
+
RETURN NEW;
|
|
1164
|
+
END;
|
|
1165
|
+
$$ LANGUAGE plpgsql`);
|
|
1166
|
+
await this.#exec.query(
|
|
1167
|
+
'DROP TRIGGER IF EXISTS sync_commits_writer_fence ON sync_commits',
|
|
1168
|
+
);
|
|
1169
|
+
await this.#exec.query(`CREATE TRIGGER sync_commits_writer_fence
|
|
1170
|
+
BEFORE INSERT ON sync_commits
|
|
1171
|
+
FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
949
1172
|
}
|
|
950
1173
|
|
|
951
1174
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
@@ -959,10 +1182,38 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
959
1182
|
return table;
|
|
960
1183
|
}
|
|
961
1184
|
|
|
962
|
-
async ensureSchema(
|
|
1185
|
+
async ensureSchema(
|
|
1186
|
+
schema: CompiledSchema,
|
|
1187
|
+
checkpoints?: readonly CheckpointDeclaration[],
|
|
1188
|
+
): Promise<void> {
|
|
963
1189
|
// Memoized fast path: same instance, same schema version.
|
|
964
1190
|
if (this.#schemaVersion === schema.version) return;
|
|
965
1191
|
await this.migrate();
|
|
1192
|
+
// The barrier reads stored state: a checkpoint at this schema version that
|
|
1193
|
+
// is not activated and that this caller did not declare means this process
|
|
1194
|
+
// must not serve. An omitted or empty declaration set declares nothing and
|
|
1195
|
+
// is refused here, never treated as a bypass.
|
|
1196
|
+
const declarations = checkpoints ?? [];
|
|
1197
|
+
const incomplete = await this.#exec.query<{
|
|
1198
|
+
partition: string;
|
|
1199
|
+
name: string;
|
|
1200
|
+
}>(
|
|
1201
|
+
`SELECT partition, name FROM sync_backfill_checkpoints
|
|
1202
|
+
WHERE schema_version=$1 AND state<>'activated'`,
|
|
1203
|
+
[schema.version],
|
|
1204
|
+
);
|
|
1205
|
+
if (
|
|
1206
|
+
incomplete.rows.some(
|
|
1207
|
+
(row) =>
|
|
1208
|
+
!declarations.some(
|
|
1209
|
+
(declaration) =>
|
|
1210
|
+
declaration.partition === row.partition &&
|
|
1211
|
+
declaration.name === row.name,
|
|
1212
|
+
),
|
|
1213
|
+
)
|
|
1214
|
+
) {
|
|
1215
|
+
throw new StorageQueryError('sync.storage.checkpoint_incomplete');
|
|
1216
|
+
}
|
|
966
1217
|
await this.#exec.query(SCHEMA_META_DDL_POSTGRES);
|
|
967
1218
|
const marker = await this.#exec.query<{
|
|
968
1219
|
schema_version: unknown;
|
|
@@ -977,6 +1228,68 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
977
1228
|
`stored schema version ${stored} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`,
|
|
978
1229
|
);
|
|
979
1230
|
}
|
|
1231
|
+
if (stored !== undefined && stored === schema.version) {
|
|
1232
|
+
// Version equality is not layout equality: a marker written by another
|
|
1233
|
+
// build at the same version describes rows the running codec cannot
|
|
1234
|
+
// decode. Compare the stored layouts instead of trusting the number.
|
|
1235
|
+
const storedLayouts = parseLayouts(
|
|
1236
|
+
typeof marker.rows[0]?.layouts === 'string'
|
|
1237
|
+
? marker.rows[0].layouts
|
|
1238
|
+
: undefined,
|
|
1239
|
+
);
|
|
1240
|
+
const configuredLayouts = parseLayouts(layoutsOf(schema));
|
|
1241
|
+
let mismatch: string | undefined;
|
|
1242
|
+
for (const [tableName, columns] of Object.entries(configuredLayouts)) {
|
|
1243
|
+
const storedTable = storedLayouts[tableName];
|
|
1244
|
+
const columnCount = Math.max(columns.length, storedTable?.length ?? 0);
|
|
1245
|
+
for (let index = 0; index < columnCount; index++) {
|
|
1246
|
+
const expected = columns[index];
|
|
1247
|
+
const actual = storedTable?.[index];
|
|
1248
|
+
if (
|
|
1249
|
+
expected !== undefined &&
|
|
1250
|
+
actual !== undefined &&
|
|
1251
|
+
actual.name === expected.name &&
|
|
1252
|
+
actual.type === expected.type &&
|
|
1253
|
+
actual.nullable === expected.nullable
|
|
1254
|
+
) {
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1257
|
+
mismatch = `table ${JSON.stringify(tableName)} column ${JSON.stringify(expected?.name ?? actual?.name ?? '')}`;
|
|
1258
|
+
break;
|
|
1259
|
+
}
|
|
1260
|
+
if (mismatch !== undefined) break;
|
|
1261
|
+
}
|
|
1262
|
+
if (mismatch === undefined) {
|
|
1263
|
+
for (const tableName of Object.keys(storedLayouts)) {
|
|
1264
|
+
if (!(tableName in configuredLayouts)) {
|
|
1265
|
+
mismatch = `table ${JSON.stringify(tableName)}`;
|
|
1266
|
+
break;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
if (mismatch !== undefined) {
|
|
1271
|
+
throw new Error(
|
|
1272
|
+
`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`,
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
// The persisted layouts describe the codec's app columns only: read the
|
|
1276
|
+
// physical tables so a same-version database missing a
|
|
1277
|
+
// storage-internal column is refused at startup instead of failing at
|
|
1278
|
+
// the first write. Resolve through `to_regclass`, which follows the
|
|
1279
|
+
// session's `search_path` exactly as the unqualified row queries do —
|
|
1280
|
+
// `current_schema()` names only the first existing schema, so it would
|
|
1281
|
+
// reject a table that resolves later in the path.
|
|
1282
|
+
for (const table of schema.tables.values()) {
|
|
1283
|
+
const { rows } = await this.#exec.query<{ column_name: string }>(
|
|
1284
|
+
`SELECT attribute.attname AS column_name
|
|
1285
|
+
FROM pg_attribute attribute
|
|
1286
|
+
WHERE attribute.attrelid = to_regclass($1)
|
|
1287
|
+
AND attribute.attnum > 0 AND NOT attribute.attisdropped`,
|
|
1288
|
+
[table.name],
|
|
1289
|
+
);
|
|
1290
|
+
assertPhysicalColumns(table, new Set(rows.map((r) => r.column_name)));
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
980
1293
|
if (stored === undefined || stored < schema.version) {
|
|
981
1294
|
// Introspect existing app tables, apply the migration subset
|
|
982
1295
|
// (CREATE TABLE / ADD COLUMN / rebuild indexes), then rewrite stored
|
|
@@ -991,6 +1304,17 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
991
1304
|
);
|
|
992
1305
|
const retiredTables = retiredTableNames(schema, layouts);
|
|
993
1306
|
await this.#exec.transaction(async (client) => {
|
|
1307
|
+
// RFC 0007 migration barrier: `LOCK TABLE ... EXCLUSIVE` is the FIRST
|
|
1308
|
+
// statement, before any read, DDL, rewrite, marker or checkpoint
|
|
1309
|
+
// install. EXCLUSIVE conflicts with the ROW SHARE that every writer's
|
|
1310
|
+
// `lockPartitionOn` takes (`SELECT … FOR UPDATE`) and with the ROW
|
|
1311
|
+
// EXCLUSIVE of its allocation (`UPDATE sync_partitions` / `INSERT … ON
|
|
1312
|
+
// CONFLICT DO UPDATE`), so an unaware old writer blocks at its first
|
|
1313
|
+
// statement with no cooperation; plain `SELECT` (ACCESS SHARE) still
|
|
1314
|
+
// works. The lock order is acyclic: the migration holds nothing else
|
|
1315
|
+
// when it takes this lock, so a writer already inside completes while
|
|
1316
|
+
// the migration waits.
|
|
1317
|
+
await client.query('LOCK TABLE sync_partitions IN EXCLUSIVE MODE');
|
|
994
1318
|
const existing = new Map<string, ReadonlySet<string>>();
|
|
995
1319
|
const existingIndexes = new Map<string, ReadonlySet<string>>();
|
|
996
1320
|
for (const table of schema.tables.values()) {
|
|
@@ -1064,6 +1388,19 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1064
1388
|
SET schema_version=EXCLUDED.schema_version, layouts=EXCLUDED.layouts`,
|
|
1065
1389
|
[schema.version, layoutsOf(schema)],
|
|
1066
1390
|
);
|
|
1391
|
+
// The host's declared checkpoints install in the schema-bump
|
|
1392
|
+
// transaction, so no observer sees the bumped marker without the
|
|
1393
|
+
// fence, or the fence without its declaration row.
|
|
1394
|
+
const installedAtMs = Date.now();
|
|
1395
|
+
for (const declaration of checkpoints ?? []) {
|
|
1396
|
+
await installCheckpointOn(
|
|
1397
|
+
client,
|
|
1398
|
+
declaration.partition,
|
|
1399
|
+
declaration.name,
|
|
1400
|
+
declaration.schemaVersion,
|
|
1401
|
+
installedAtMs,
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1067
1404
|
});
|
|
1068
1405
|
}
|
|
1069
1406
|
this.#tables = schema.tables;
|
|
@@ -1175,6 +1512,7 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1175
1512
|
const tx = new PostgresTransaction(
|
|
1176
1513
|
client,
|
|
1177
1514
|
partition,
|
|
1515
|
+
this.#schemaVersion ?? 0,
|
|
1178
1516
|
(name) => this.table(name),
|
|
1179
1517
|
resolveScope,
|
|
1180
1518
|
rejectScope,
|
|
@@ -1203,6 +1541,7 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1203
1541
|
async queryAuthoritative(
|
|
1204
1542
|
partition: string,
|
|
1205
1543
|
query: AuthoritativeQueryRequest,
|
|
1544
|
+
checkpoints?: readonly CheckpointDeclaration[],
|
|
1206
1545
|
): Promise<AuthoritativeQueryResult> {
|
|
1207
1546
|
if (this.#tables === undefined) {
|
|
1208
1547
|
throw new Error(
|
|
@@ -1222,6 +1561,14 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1222
1561
|
await client.query(
|
|
1223
1562
|
'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY',
|
|
1224
1563
|
);
|
|
1564
|
+
// RFC 0007: evaluate the gate inside this pinned snapshot, so a
|
|
1565
|
+
// migration or an epoch rotation cannot land between the gate read and
|
|
1566
|
+
// the query's own read. The registered-query path uses the pinned
|
|
1567
|
+
// snapshot, not a request-entry check.
|
|
1568
|
+
const runningVersion = this.#schemaVersion ?? 0;
|
|
1569
|
+
const gate = await readServeGateOn(client, partition, runningVersion);
|
|
1570
|
+
const refusal = serveGateRefusal(gate, runningVersion, checkpoints);
|
|
1571
|
+
if (refusal !== undefined) throw serveNotReadyError(refusal);
|
|
1225
1572
|
const result = await client.query<Readonly<Record<string, unknown>>>(
|
|
1226
1573
|
postgresPlaceholders(prepared.sql),
|
|
1227
1574
|
prepared.params,
|
|
@@ -1324,6 +1671,174 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1324
1671
|
return seq === null || seq === undefined ? 0 : asNumber(seq);
|
|
1325
1672
|
}
|
|
1326
1673
|
|
|
1674
|
+
async readCheckpoints(partition: string): Promise<StoredCheckpoint[]> {
|
|
1675
|
+
const { rows } = await this.#exec.query<PostgresCheckpointRecord>(
|
|
1676
|
+
`SELECT partition, name, schema_version, state, watermark,
|
|
1677
|
+
owner_epoch, observed_rows, updated_at_ms
|
|
1678
|
+
FROM sync_backfill_checkpoints WHERE partition=$1 ORDER BY name`,
|
|
1679
|
+
[partition],
|
|
1680
|
+
);
|
|
1681
|
+
return rows.map(toStoredCheckpoint);
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
async readServeGate(
|
|
1685
|
+
partition: string,
|
|
1686
|
+
runningSchemaVersion: number,
|
|
1687
|
+
): Promise<ServeGate> {
|
|
1688
|
+
return readServeGateOn(this.#exec, partition, runningSchemaVersion);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
async declareCheckpoint(
|
|
1692
|
+
partition: string,
|
|
1693
|
+
name: string,
|
|
1694
|
+
schemaVersion: number,
|
|
1695
|
+
nowMs: number,
|
|
1696
|
+
): Promise<StoredCheckpoint> {
|
|
1697
|
+
// One transaction: the checkpoint becomes visible and the fence that
|
|
1698
|
+
// protects it is raised together. The fence is raised at declaration, not
|
|
1699
|
+
// at activation, so old writers are rejected for the whole backfill.
|
|
1700
|
+
//
|
|
1701
|
+
// RFC 0007 lock decision: this transaction writes only the checkpoint and
|
|
1702
|
+
// fence rows, no application rows and no schema marker, so it does not
|
|
1703
|
+
// take the migration table lock or a partition lock. The fence it raises is
|
|
1704
|
+
// enforced by the `sync_commits` trigger on every append from the commit
|
|
1705
|
+
// of this transaction onward; a writer that committed before the fence was
|
|
1706
|
+
// visible is pre-barrier by the design's own "from declaration onward"
|
|
1707
|
+
// rule, not a bypass of it.
|
|
1708
|
+
return this.#exec.transaction(async (client) => {
|
|
1709
|
+
await installCheckpointOn(client, partition, name, schemaVersion, nowMs);
|
|
1710
|
+
const { rows } = await client.query<PostgresCheckpointRecord>(
|
|
1711
|
+
`SELECT partition, name, schema_version, state, watermark,
|
|
1712
|
+
owner_epoch, observed_rows, updated_at_ms
|
|
1713
|
+
FROM sync_backfill_checkpoints WHERE partition=$1 AND name=$2`,
|
|
1714
|
+
[partition, name],
|
|
1715
|
+
);
|
|
1716
|
+
const row = rows[0];
|
|
1717
|
+
if (row === undefined) {
|
|
1718
|
+
throw new StorageQueryError('sync.storage.checkpoint_not_declared');
|
|
1719
|
+
}
|
|
1720
|
+
return toStoredCheckpoint(row);
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
async activateCheckpoint(
|
|
1725
|
+
partition: string,
|
|
1726
|
+
name: string,
|
|
1727
|
+
ownerEpoch: number,
|
|
1728
|
+
watermark: number,
|
|
1729
|
+
sources: readonly string[],
|
|
1730
|
+
nowMs: number,
|
|
1731
|
+
): Promise<'activated' | 'stale' | 'unverifiable'> {
|
|
1732
|
+
return this.#exec.transaction(async (client) => {
|
|
1733
|
+
// The same `SELECT … FOR UPDATE` partition row lock that
|
|
1734
|
+
// `lockPartitionForPush` takes; a bare `BEGIN` would race a push.
|
|
1735
|
+
await lockPartitionOn(client, partition);
|
|
1736
|
+
const fence = await client.query<{ required_writer_version: unknown }>(
|
|
1737
|
+
'SELECT required_writer_version FROM sync_writer_fence WHERE partition=$1',
|
|
1738
|
+
[partition],
|
|
1739
|
+
);
|
|
1740
|
+
const checkpoint = await client.query<{ schema_version: unknown }>(
|
|
1741
|
+
'SELECT schema_version FROM sync_backfill_checkpoints WHERE partition=$1 AND name=$2',
|
|
1742
|
+
[partition, name],
|
|
1743
|
+
);
|
|
1744
|
+
const fenceRow = fence.rows[0];
|
|
1745
|
+
const checkpointRow = checkpoint.rows[0];
|
|
1746
|
+
if (
|
|
1747
|
+
fenceRow === undefined ||
|
|
1748
|
+
checkpointRow === undefined ||
|
|
1749
|
+
asNumber(fenceRow.required_writer_version) <
|
|
1750
|
+
asNumber(checkpointRow.schema_version)
|
|
1751
|
+
) {
|
|
1752
|
+
// The barrier is not installed. Activating would certify a state old
|
|
1753
|
+
// writers can still decay; refuse instead of silently proceeding.
|
|
1754
|
+
throw new StorageQueryError('sync.storage.checkpoint_fence_missing');
|
|
1755
|
+
}
|
|
1756
|
+
const coverage = await hasSourceChangesAboveOn(
|
|
1757
|
+
client,
|
|
1758
|
+
partition,
|
|
1759
|
+
sources,
|
|
1760
|
+
watermark,
|
|
1761
|
+
);
|
|
1762
|
+
if (coverage !== 'clean') {
|
|
1763
|
+
return coverage === 'changed' ? 'stale' : 'unverifiable';
|
|
1764
|
+
}
|
|
1765
|
+
const updated = await client.query<{ partition: string }>(
|
|
1766
|
+
`UPDATE sync_backfill_checkpoints
|
|
1767
|
+
SET state='activated', watermark=$1, updated_at_ms=$2
|
|
1768
|
+
WHERE partition=$3 AND name=$4 AND owner_epoch=$5
|
|
1769
|
+
AND state<>'activated'
|
|
1770
|
+
RETURNING partition`,
|
|
1771
|
+
[watermark, nowMs, partition, name, ownerEpoch],
|
|
1772
|
+
);
|
|
1773
|
+
return updated.rows.length === 1 ? 'activated' : 'stale';
|
|
1774
|
+
});
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
async claimCheckpoint(
|
|
1778
|
+
partition: string,
|
|
1779
|
+
name: string,
|
|
1780
|
+
schemaVersion: number,
|
|
1781
|
+
nowMs: number,
|
|
1782
|
+
): Promise<StoredCheckpoint> {
|
|
1783
|
+
// The UPDATE's own row lock serializes concurrent claims; owner_epoch+1
|
|
1784
|
+
// is evaluated under it, so two claimers never observe the same epoch.
|
|
1785
|
+
const { rows } = await this.#exec.query<PostgresCheckpointRecord>(
|
|
1786
|
+
`UPDATE sync_backfill_checkpoints
|
|
1787
|
+
SET owner_epoch=owner_epoch+1,
|
|
1788
|
+
state=CASE WHEN state='declared' THEN 'backfilling' ELSE state END,
|
|
1789
|
+
schema_version=$1, updated_at_ms=$2
|
|
1790
|
+
WHERE partition=$3 AND name=$4 AND state<>'activated'
|
|
1791
|
+
RETURNING partition, name, schema_version, state, watermark,
|
|
1792
|
+
owner_epoch, observed_rows, updated_at_ms`,
|
|
1793
|
+
[schemaVersion, nowMs, partition, name],
|
|
1794
|
+
);
|
|
1795
|
+
if (rows[0] === undefined) {
|
|
1796
|
+
throw new StorageQueryError('sync.storage.checkpoint_not_declared');
|
|
1797
|
+
}
|
|
1798
|
+
return toStoredCheckpoint(rows[0]);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
async advanceCheckpoint(
|
|
1802
|
+
partition: string,
|
|
1803
|
+
name: string,
|
|
1804
|
+
ownerEpoch: number,
|
|
1805
|
+
watermark: number,
|
|
1806
|
+
observedRows: number,
|
|
1807
|
+
nowMs: number,
|
|
1808
|
+
): Promise<boolean> {
|
|
1809
|
+
const result = await this.#exec.query<{ name: string }>(
|
|
1810
|
+
`UPDATE sync_backfill_checkpoints
|
|
1811
|
+
SET watermark=$1, observed_rows=$2, updated_at_ms=$3
|
|
1812
|
+
WHERE partition=$4 AND name=$5 AND owner_epoch=$6
|
|
1813
|
+
RETURNING name`,
|
|
1814
|
+
[watermark, observedRows, nowMs, partition, name, ownerEpoch],
|
|
1815
|
+
);
|
|
1816
|
+
return result.rows.length === 1;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
async sourceCoverageSeq(
|
|
1820
|
+
partition: string,
|
|
1821
|
+
tables: readonly string[],
|
|
1822
|
+
): Promise<number> {
|
|
1823
|
+
if (tables.length === 0) return 0;
|
|
1824
|
+
const tableParams = tables.map((_, index) => `$${index + 2}`).join(',');
|
|
1825
|
+
const { rows } = await this.#exec.query<{ seq: unknown }>(
|
|
1826
|
+
`SELECT max(commit_seq) AS seq FROM sync_changes
|
|
1827
|
+
WHERE partition=$1 AND tbl IN (${tableParams})`,
|
|
1828
|
+
[partition, ...tables],
|
|
1829
|
+
);
|
|
1830
|
+
const seq = rows[0]?.seq;
|
|
1831
|
+
return seq === null || seq === undefined ? 0 : asNumber(seq);
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
hasSourceChangesAbove(
|
|
1835
|
+
partition: string,
|
|
1836
|
+
tables: readonly string[],
|
|
1837
|
+
seq: number,
|
|
1838
|
+
): Promise<'clean' | 'changed' | 'unverifiable'> {
|
|
1839
|
+
return hasSourceChangesAboveOn(this.#exec, partition, tables, seq);
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1327
1842
|
getRow(
|
|
1328
1843
|
partition: string,
|
|
1329
1844
|
table: string,
|