@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/dist/postgres-storage.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { validateCommitPruneQuery } from './prune.js';
|
|
2
2
|
import { StorageQueryError } from './storage-errors.js';
|
|
3
|
+
import { serveGateRefusal } from './storage.js';
|
|
4
|
+
import { serveNotReadyError } from './readiness.js';
|
|
3
5
|
import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
4
6
|
import { syncError } from './errors.js';
|
|
5
7
|
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';
|
|
8
|
+
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
9
|
import { matchesEffective } from './scopes.js';
|
|
8
10
|
import { isPostgresConstraintError, StorageConstraintError, } from './storage-errors.js';
|
|
9
11
|
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
@@ -17,7 +19,14 @@ import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js'
|
|
|
17
19
|
* - `sync_row_scopes_pk (partition, tbl, var, value, row_id)` — same shape
|
|
18
20
|
* for `scanRows`, ordered by `row_id`.
|
|
19
21
|
* Both are the PRIMARY KEY, so they are the clustering/covering index for
|
|
20
|
-
* their table.
|
|
22
|
+
* their table.
|
|
23
|
+
*
|
|
24
|
+
* `sync_row_scopes_by_row (partition, tbl, row_id)` is the one secondary
|
|
25
|
+
* scope index: `writeRowOn` and `deleteRow` replace a row's scope entries
|
|
26
|
+
* with a `(partition, tbl, row_id)` predicate, and `row_id` is the LAST
|
|
27
|
+
* column of the inverted PRIMARY KEY, so without this index that predicate
|
|
28
|
+
* scans the table's whole scope range on every row written (a bulk import
|
|
29
|
+
* costs one such scan per row).
|
|
21
30
|
*
|
|
22
31
|
* Blob reference index (§5.9.4) — parity with the SQLite dialect's
|
|
23
32
|
* `sync_blob_refs`:
|
|
@@ -46,10 +55,13 @@ CREATE TABLE IF NOT EXISTS sync_row_scopes(
|
|
|
46
55
|
var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,
|
|
47
56
|
PRIMARY KEY(partition, tbl, var, value, row_id)
|
|
48
57
|
);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS sync_row_scopes_by_row
|
|
59
|
+
ON sync_row_scopes(partition, tbl, row_id);
|
|
49
60
|
CREATE TABLE IF NOT EXISTS sync_commits(
|
|
50
61
|
partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,
|
|
51
62
|
client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,
|
|
52
63
|
actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,
|
|
64
|
+
writer_version BIGINT,
|
|
53
65
|
PRIMARY KEY(partition, commit_seq)
|
|
54
66
|
);
|
|
55
67
|
CREATE INDEX IF NOT EXISTS sync_commits_by_time
|
|
@@ -113,6 +125,20 @@ CREATE TABLE IF NOT EXISTS sync_tombstones(
|
|
|
113
125
|
commit_seq BIGINT NOT NULL,
|
|
114
126
|
PRIMARY KEY(partition, tbl, row_id)
|
|
115
127
|
);
|
|
128
|
+
CREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(
|
|
129
|
+
partition TEXT NOT NULL, name TEXT NOT NULL,
|
|
130
|
+
schema_version INTEGER NOT NULL,
|
|
131
|
+
state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),
|
|
132
|
+
watermark BIGINT NOT NULL DEFAULT 0,
|
|
133
|
+
owner_epoch BIGINT NOT NULL DEFAULT 0,
|
|
134
|
+
observed_rows BIGINT NOT NULL DEFAULT 0,
|
|
135
|
+
updated_at_ms BIGINT NOT NULL,
|
|
136
|
+
PRIMARY KEY(partition, name)
|
|
137
|
+
);
|
|
138
|
+
CREATE TABLE IF NOT EXISTS sync_writer_fence(
|
|
139
|
+
partition TEXT PRIMARY KEY,
|
|
140
|
+
required_writer_version INTEGER NOT NULL
|
|
141
|
+
);
|
|
116
142
|
`;
|
|
117
143
|
async function lockPartitionOn(client, partition) {
|
|
118
144
|
const locked = await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [partition]);
|
|
@@ -124,12 +150,76 @@ async function lockPartitionOn(client, partition) {
|
|
|
124
150
|
ON CONFLICT (partition) DO NOTHING`, [partition]);
|
|
125
151
|
await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [partition]);
|
|
126
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Insert the `declared` row and raise the fence, on the caller's open
|
|
155
|
+
* transaction. Shared by `declareCheckpoint` and the `ensureSchema`
|
|
156
|
+
* migration so the schema bump and the fence commit together. Never lowers a
|
|
157
|
+
* fence and never re-declares an existing row.
|
|
158
|
+
*/
|
|
159
|
+
async function installCheckpointOn(client, partition, name, schemaVersion, nowMs) {
|
|
160
|
+
await client.query(`INSERT INTO sync_backfill_checkpoints(
|
|
161
|
+
partition, name, schema_version, state, watermark,
|
|
162
|
+
owner_epoch, observed_rows, updated_at_ms)
|
|
163
|
+
VALUES ($1,$2,$3,'declared',0,0,0,$4)
|
|
164
|
+
ON CONFLICT (partition, name) DO NOTHING`, [partition, name, schemaVersion, nowMs]);
|
|
165
|
+
await client.query(`INSERT INTO sync_writer_fence(partition, required_writer_version)
|
|
166
|
+
VALUES ($1,$2)
|
|
167
|
+
ON CONFLICT (partition) DO UPDATE SET
|
|
168
|
+
required_writer_version=GREATEST(
|
|
169
|
+
sync_writer_fence.required_writer_version,
|
|
170
|
+
EXCLUDED.required_writer_version)`, [partition, schemaVersion]);
|
|
171
|
+
}
|
|
172
|
+
/** RFC 0007 serve gate on either the pool or a transaction's client. */
|
|
173
|
+
async function readServeGateOn(client, partition, runningSchemaVersion) {
|
|
174
|
+
const marker = await client.query('SELECT schema_version FROM sync_schema_meta WHERE id=1');
|
|
175
|
+
const epoch = await client.query('SELECT log_epoch FROM sync_partition_registry WHERE partition=$1', [partition]);
|
|
176
|
+
const pending = await client.query(`SELECT partition, name FROM sync_backfill_checkpoints
|
|
177
|
+
WHERE schema_version=$1 AND state<>'activated'`, [runningSchemaVersion]);
|
|
178
|
+
return {
|
|
179
|
+
storedSchemaVersion: marker.rows[0] === undefined
|
|
180
|
+
? 0
|
|
181
|
+
: asNumber(marker.rows[0].schema_version),
|
|
182
|
+
logEpoch: epoch.rows[0]?.log_epoch,
|
|
183
|
+
pending: pending.rows.map((row) => ({
|
|
184
|
+
partition: row.partition,
|
|
185
|
+
name: row.name,
|
|
186
|
+
})),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
async function hasSourceChangesAboveOn(client, partition, tables, seq) {
|
|
190
|
+
// Read the horizon before the window scan: a horizon past `seq` means the
|
|
191
|
+
// history that would answer the question is gone, so an empty scan proves
|
|
192
|
+
// nothing and must not be reported as `clean`.
|
|
193
|
+
const horizon = await client.query('SELECT horizon_seq FROM sync_partitions WHERE partition=$1', [partition]);
|
|
194
|
+
const horizonSeq = horizon.rows[0] === undefined ? 0 : asNumber(horizon.rows[0].horizon_seq);
|
|
195
|
+
if (tables.length === 0)
|
|
196
|
+
return 'clean';
|
|
197
|
+
const tableParams = tables.map((_, index) => `$${index + 2}`).join(',');
|
|
198
|
+
const hit = await client.query(`SELECT 1 AS hit FROM sync_changes
|
|
199
|
+
WHERE partition=$1 AND tbl IN (${tableParams}) AND commit_seq>$${tables.length + 2}
|
|
200
|
+
LIMIT 1`, [partition, ...tables, seq]);
|
|
201
|
+
if (hit.rows[0] !== undefined)
|
|
202
|
+
return 'changed';
|
|
203
|
+
return horizonSeq > seq ? 'unverifiable' : 'clean';
|
|
204
|
+
}
|
|
127
205
|
function toBase64(bytes) {
|
|
128
206
|
return Buffer.from(bytes).toString('base64');
|
|
129
207
|
}
|
|
130
208
|
function fromBase64(text) {
|
|
131
209
|
return new Uint8Array(Buffer.from(text, 'base64'));
|
|
132
210
|
}
|
|
211
|
+
function toStoredCheckpoint(record) {
|
|
212
|
+
return {
|
|
213
|
+
partition: record.partition,
|
|
214
|
+
name: record.name,
|
|
215
|
+
schemaVersion: asNumber(record.schema_version),
|
|
216
|
+
state: record.state,
|
|
217
|
+
watermark: asNumber(record.watermark),
|
|
218
|
+
ownerEpoch: asNumber(record.owner_epoch),
|
|
219
|
+
observedRows: asNumber(record.observed_rows),
|
|
220
|
+
updatedAtMs: asNumber(record.updated_at_ms),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
133
223
|
function toStoredReaction(record) {
|
|
134
224
|
return {
|
|
135
225
|
idempotencyKey: record.idempotency_key,
|
|
@@ -332,15 +422,31 @@ async function getPushResultOn(q, partition, clientId, clientCommitId) {
|
|
|
332
422
|
class PostgresTransaction {
|
|
333
423
|
#client;
|
|
334
424
|
#partition;
|
|
425
|
+
/** Schema version this transaction writes; rides every appended commit row. */
|
|
426
|
+
#writerVersion;
|
|
335
427
|
#resolveTable;
|
|
336
428
|
#open = true;
|
|
337
429
|
#pushApplySavepoint = false;
|
|
338
430
|
/** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
|
|
339
431
|
#resolve;
|
|
340
432
|
#reject;
|
|
341
|
-
constructor(client, partition, resolveTable, resolve, reject) {
|
|
342
|
-
|
|
433
|
+
constructor(client, partition, writerVersion, resolveTable, resolve, reject) {
|
|
434
|
+
// One pinned connection carries one statement at a time: node-postgres
|
|
435
|
+
// warns about a concurrently issued second query today and rejects it in
|
|
436
|
+
// node-postgres 9. A whole-commit validator that fans independent reads
|
|
437
|
+
// out with Promise.all must not interleave them, so every statement
|
|
438
|
+
// issued through this transaction is chained on one FIFO — the same
|
|
439
|
+
// shape as `SqliteServerStorage.queryAuthoritative`.
|
|
440
|
+
let tail = Promise.resolve();
|
|
441
|
+
this.#client = {
|
|
442
|
+
query: (text, params) => {
|
|
443
|
+
const run = tail.then(() => client.query(text, params));
|
|
444
|
+
tail = run.then(() => undefined, () => undefined);
|
|
445
|
+
return run;
|
|
446
|
+
},
|
|
447
|
+
};
|
|
343
448
|
this.#partition = partition;
|
|
449
|
+
this.#writerVersion = writerVersion;
|
|
344
450
|
this.#resolveTable = resolveTable;
|
|
345
451
|
this.#resolve = resolve;
|
|
346
452
|
this.#reject = reject;
|
|
@@ -417,6 +523,24 @@ class PostgresTransaction {
|
|
|
417
523
|
await this.#client.query('SAVEPOINT syncular_push_candidate');
|
|
418
524
|
this.#pushApplySavepoint = true;
|
|
419
525
|
}
|
|
526
|
+
async advanceCheckpoint(name, ownerEpoch, watermark, observedRows, nowMs) {
|
|
527
|
+
this.#assertOpen();
|
|
528
|
+
// `RETURNING`: the affected-row check must not depend on a driver's
|
|
529
|
+
// `rowCount` for a statement with no result rows. Bun.sql's canonical
|
|
530
|
+
// adapter derives `rowCount` from the returned rows, so a bare UPDATE
|
|
531
|
+
// reports 0 and the CAS would never advance on a real Bun.sql host.
|
|
532
|
+
const result = await this.#client.query(`UPDATE sync_backfill_checkpoints
|
|
533
|
+
SET watermark=$1, observed_rows=$2, updated_at_ms=$3
|
|
534
|
+
WHERE partition=$4 AND name=$5 AND owner_epoch=$6
|
|
535
|
+
RETURNING name`, [watermark, observedRows, nowMs, this.#partition, name, ownerEpoch]);
|
|
536
|
+
return result.rows.length === 1;
|
|
537
|
+
}
|
|
538
|
+
readServeGate(runningSchemaVersion) {
|
|
539
|
+
this.#assertOpen();
|
|
540
|
+
// The transaction's own client: the gate and the write share one
|
|
541
|
+
// transaction, so a migration cannot interleave between them.
|
|
542
|
+
return readServeGateOn(this.#client, this.#partition, runningSchemaVersion);
|
|
543
|
+
}
|
|
420
544
|
async commitRejectedPushResult(clientId, clientCommitId, result) {
|
|
421
545
|
this.#assertOpen();
|
|
422
546
|
if (!this.#pushApplySavepoint) {
|
|
@@ -469,14 +593,15 @@ class PostgresTransaction {
|
|
|
469
593
|
SET max_commit_seq = sync_partitions.max_commit_seq + 1
|
|
470
594
|
RETURNING max_commit_seq
|
|
471
595
|
)
|
|
472
|
-
INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms)
|
|
473
|
-
SELECT $1, max_commit_seq, $2, $3, $4, $5 FROM allocated
|
|
596
|
+
INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms, writer_version)
|
|
597
|
+
SELECT $1, max_commit_seq, $2, $3, $4, $5, $6 FROM allocated
|
|
474
598
|
RETURNING commit_seq`, [
|
|
475
599
|
p,
|
|
476
600
|
commit.clientId,
|
|
477
601
|
commit.clientCommitId,
|
|
478
602
|
commit.actorId,
|
|
479
603
|
commit.createdAtMs,
|
|
604
|
+
this.#writerVersion,
|
|
480
605
|
]);
|
|
481
606
|
const commitSeq = asNumber(rows[0]?.commit_seq);
|
|
482
607
|
for (let idx = 0; idx < commit.changes.length; idx++) {
|
|
@@ -593,6 +718,29 @@ export class PostgresServerStorage {
|
|
|
593
718
|
for (const statement of statements) {
|
|
594
719
|
await this.#exec.query(statement);
|
|
595
720
|
}
|
|
721
|
+
await this.#exec.query('ALTER TABLE sync_commits ADD COLUMN IF NOT EXISTS writer_version BIGINT');
|
|
722
|
+
// Database-side fence: an old binary's INSERT never reaches JS. An absent
|
|
723
|
+
// `sync_writer_fence` row makes the EXISTS false, so a partition with no
|
|
724
|
+
// barrier behaves exactly as before. Sent as separate statements because
|
|
725
|
+
// the dollar-quoted body contains `;`.
|
|
726
|
+
await this.#exec.query(`CREATE OR REPLACE FUNCTION syncular_writer_fence()
|
|
727
|
+
RETURNS trigger AS $$
|
|
728
|
+
BEGIN
|
|
729
|
+
IF EXISTS (
|
|
730
|
+
SELECT 1 FROM sync_writer_fence
|
|
731
|
+
WHERE partition = NEW.partition
|
|
732
|
+
AND (NEW.writer_version IS NULL
|
|
733
|
+
OR NEW.writer_version < required_writer_version)
|
|
734
|
+
) THEN
|
|
735
|
+
RAISE EXCEPTION 'sync.storage.writer_fence_rejected';
|
|
736
|
+
END IF;
|
|
737
|
+
RETURN NEW;
|
|
738
|
+
END;
|
|
739
|
+
$$ LANGUAGE plpgsql`);
|
|
740
|
+
await this.#exec.query('DROP TRIGGER IF EXISTS sync_commits_writer_fence ON sync_commits');
|
|
741
|
+
await this.#exec.query(`CREATE TRIGGER sync_commits_writer_fence
|
|
742
|
+
BEFORE INSERT ON sync_commits
|
|
743
|
+
FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
596
744
|
}
|
|
597
745
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
598
746
|
table(name) {
|
|
@@ -602,11 +750,22 @@ export class PostgresServerStorage {
|
|
|
602
750
|
}
|
|
603
751
|
return table;
|
|
604
752
|
}
|
|
605
|
-
async ensureSchema(schema) {
|
|
753
|
+
async ensureSchema(schema, checkpoints) {
|
|
606
754
|
// Memoized fast path: same instance, same schema version.
|
|
607
755
|
if (this.#schemaVersion === schema.version)
|
|
608
756
|
return;
|
|
609
757
|
await this.migrate();
|
|
758
|
+
// The barrier reads stored state: a checkpoint at this schema version that
|
|
759
|
+
// is not activated and that this caller did not declare means this process
|
|
760
|
+
// must not serve. An omitted or empty declaration set declares nothing and
|
|
761
|
+
// is refused here, never treated as a bypass.
|
|
762
|
+
const declarations = checkpoints ?? [];
|
|
763
|
+
const incomplete = await this.#exec.query(`SELECT partition, name FROM sync_backfill_checkpoints
|
|
764
|
+
WHERE schema_version=$1 AND state<>'activated'`, [schema.version]);
|
|
765
|
+
if (incomplete.rows.some((row) => !declarations.some((declaration) => declaration.partition === row.partition &&
|
|
766
|
+
declaration.name === row.name))) {
|
|
767
|
+
throw new StorageQueryError('sync.storage.checkpoint_incomplete');
|
|
768
|
+
}
|
|
610
769
|
await this.#exec.query(SCHEMA_META_DDL_POSTGRES);
|
|
611
770
|
const marker = await this.#exec.query('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1');
|
|
612
771
|
const stored = marker.rows[0] === undefined
|
|
@@ -615,6 +774,60 @@ export class PostgresServerStorage {
|
|
|
615
774
|
if (stored !== undefined && stored > schema.version) {
|
|
616
775
|
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
776
|
}
|
|
777
|
+
if (stored !== undefined && stored === schema.version) {
|
|
778
|
+
// Version equality is not layout equality: a marker written by another
|
|
779
|
+
// build at the same version describes rows the running codec cannot
|
|
780
|
+
// decode. Compare the stored layouts instead of trusting the number.
|
|
781
|
+
const storedLayouts = parseLayouts(typeof marker.rows[0]?.layouts === 'string'
|
|
782
|
+
? marker.rows[0].layouts
|
|
783
|
+
: undefined);
|
|
784
|
+
const configuredLayouts = parseLayouts(layoutsOf(schema));
|
|
785
|
+
let mismatch;
|
|
786
|
+
for (const [tableName, columns] of Object.entries(configuredLayouts)) {
|
|
787
|
+
const storedTable = storedLayouts[tableName];
|
|
788
|
+
const columnCount = Math.max(columns.length, storedTable?.length ?? 0);
|
|
789
|
+
for (let index = 0; index < columnCount; index++) {
|
|
790
|
+
const expected = columns[index];
|
|
791
|
+
const actual = storedTable?.[index];
|
|
792
|
+
if (expected !== undefined &&
|
|
793
|
+
actual !== undefined &&
|
|
794
|
+
actual.name === expected.name &&
|
|
795
|
+
actual.type === expected.type &&
|
|
796
|
+
actual.nullable === expected.nullable) {
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
mismatch = `table ${JSON.stringify(tableName)} column ${JSON.stringify(expected?.name ?? actual?.name ?? '')}`;
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
if (mismatch !== undefined)
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
if (mismatch === undefined) {
|
|
806
|
+
for (const tableName of Object.keys(storedLayouts)) {
|
|
807
|
+
if (!(tableName in configuredLayouts)) {
|
|
808
|
+
mismatch = `table ${JSON.stringify(tableName)}`;
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
if (mismatch !== undefined) {
|
|
814
|
+
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`);
|
|
815
|
+
}
|
|
816
|
+
// The persisted layouts describe the codec's app columns only: read the
|
|
817
|
+
// physical tables so a same-version database missing a
|
|
818
|
+
// storage-internal column is refused at startup instead of failing at
|
|
819
|
+
// the first write. Resolve through `to_regclass`, which follows the
|
|
820
|
+
// session's `search_path` exactly as the unqualified row queries do —
|
|
821
|
+
// `current_schema()` names only the first existing schema, so it would
|
|
822
|
+
// reject a table that resolves later in the path.
|
|
823
|
+
for (const table of schema.tables.values()) {
|
|
824
|
+
const { rows } = await this.#exec.query(`SELECT attribute.attname AS column_name
|
|
825
|
+
FROM pg_attribute attribute
|
|
826
|
+
WHERE attribute.attrelid = to_regclass($1)
|
|
827
|
+
AND attribute.attnum > 0 AND NOT attribute.attisdropped`, [table.name]);
|
|
828
|
+
assertPhysicalColumns(table, new Set(rows.map((r) => r.column_name)));
|
|
829
|
+
}
|
|
830
|
+
}
|
|
618
831
|
if (stored === undefined || stored < schema.version) {
|
|
619
832
|
// Introspect existing app tables, apply the migration subset
|
|
620
833
|
// (CREATE TABLE / ADD COLUMN / rebuild indexes), then rewrite stored
|
|
@@ -627,6 +840,17 @@ export class PostgresServerStorage {
|
|
|
627
840
|
: undefined);
|
|
628
841
|
const retiredTables = retiredTableNames(schema, layouts);
|
|
629
842
|
await this.#exec.transaction(async (client) => {
|
|
843
|
+
// RFC 0007 migration barrier: `LOCK TABLE ... EXCLUSIVE` is the FIRST
|
|
844
|
+
// statement, before any read, DDL, rewrite, marker or checkpoint
|
|
845
|
+
// install. EXCLUSIVE conflicts with the ROW SHARE that every writer's
|
|
846
|
+
// `lockPartitionOn` takes (`SELECT … FOR UPDATE`) and with the ROW
|
|
847
|
+
// EXCLUSIVE of its allocation (`UPDATE sync_partitions` / `INSERT … ON
|
|
848
|
+
// CONFLICT DO UPDATE`), so an unaware old writer blocks at its first
|
|
849
|
+
// statement with no cooperation; plain `SELECT` (ACCESS SHARE) still
|
|
850
|
+
// works. The lock order is acyclic: the migration holds nothing else
|
|
851
|
+
// when it takes this lock, so a writer already inside completes while
|
|
852
|
+
// the migration waits.
|
|
853
|
+
await client.query('LOCK TABLE sync_partitions IN EXCLUSIVE MODE');
|
|
630
854
|
const existing = new Map();
|
|
631
855
|
const existingIndexes = new Map();
|
|
632
856
|
for (const table of schema.tables.values()) {
|
|
@@ -680,6 +904,13 @@ export class PostgresServerStorage {
|
|
|
680
904
|
await client.query(`INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1, $1, $2)
|
|
681
905
|
ON CONFLICT (id) DO UPDATE
|
|
682
906
|
SET schema_version=EXCLUDED.schema_version, layouts=EXCLUDED.layouts`, [schema.version, layoutsOf(schema)]);
|
|
907
|
+
// The host's declared checkpoints install in the schema-bump
|
|
908
|
+
// transaction, so no observer sees the bumped marker without the
|
|
909
|
+
// fence, or the fence without its declaration row.
|
|
910
|
+
const installedAtMs = Date.now();
|
|
911
|
+
for (const declaration of checkpoints ?? []) {
|
|
912
|
+
await installCheckpointOn(client, declaration.partition, declaration.name, declaration.schemaVersion, installedAtMs);
|
|
913
|
+
}
|
|
683
914
|
});
|
|
684
915
|
}
|
|
685
916
|
this.#tables = schema.tables;
|
|
@@ -759,7 +990,7 @@ export class PostgresServerStorage {
|
|
|
759
990
|
});
|
|
760
991
|
const done = this.#exec
|
|
761
992
|
.transaction(async (client) => {
|
|
762
|
-
const tx = new PostgresTransaction(client, partition, (name) => this.table(name), resolveScope, rejectScope);
|
|
993
|
+
const tx = new PostgresTransaction(client, partition, this.#schemaVersion ?? 0, (name) => this.table(name), resolveScope, rejectScope);
|
|
763
994
|
resolveReady(tx);
|
|
764
995
|
// Hold the transaction open until commit()/rollback() settles `scope`.
|
|
765
996
|
await scope;
|
|
@@ -777,13 +1008,22 @@ export class PostgresServerStorage {
|
|
|
777
1008
|
const { rows } = await this.#exec.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1', [partition]);
|
|
778
1009
|
return rows[0] === undefined ? 0 : asNumber(rows[0].max_commit_seq);
|
|
779
1010
|
}
|
|
780
|
-
async queryAuthoritative(partition, query) {
|
|
1011
|
+
async queryAuthoritative(partition, query, checkpoints) {
|
|
781
1012
|
if (this.#tables === undefined) {
|
|
782
1013
|
throw new Error('ensureSchema(schema) must run before registered queries');
|
|
783
1014
|
}
|
|
784
1015
|
const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
|
|
785
1016
|
return this.#exec.transaction(async (client) => {
|
|
786
1017
|
await client.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
|
1018
|
+
// RFC 0007: evaluate the gate inside this pinned snapshot, so a
|
|
1019
|
+
// migration or an epoch rotation cannot land between the gate read and
|
|
1020
|
+
// the query's own read. The registered-query path uses the pinned
|
|
1021
|
+
// snapshot, not a request-entry check.
|
|
1022
|
+
const runningVersion = this.#schemaVersion ?? 0;
|
|
1023
|
+
const gate = await readServeGateOn(client, partition, runningVersion);
|
|
1024
|
+
const refusal = serveGateRefusal(gate, runningVersion, checkpoints);
|
|
1025
|
+
if (refusal !== undefined)
|
|
1026
|
+
throw serveNotReadyError(refusal);
|
|
787
1027
|
const result = await client.query(postgresPlaceholders(prepared.sql), prepared.params);
|
|
788
1028
|
const cursor = await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1', [partition]);
|
|
789
1029
|
return {
|
|
@@ -834,6 +1074,102 @@ export class PostgresServerStorage {
|
|
|
834
1074
|
const seq = rows[0]?.seq;
|
|
835
1075
|
return seq === null || seq === undefined ? 0 : asNumber(seq);
|
|
836
1076
|
}
|
|
1077
|
+
async readCheckpoints(partition) {
|
|
1078
|
+
const { rows } = await this.#exec.query(`SELECT partition, name, schema_version, state, watermark,
|
|
1079
|
+
owner_epoch, observed_rows, updated_at_ms
|
|
1080
|
+
FROM sync_backfill_checkpoints WHERE partition=$1 ORDER BY name`, [partition]);
|
|
1081
|
+
return rows.map(toStoredCheckpoint);
|
|
1082
|
+
}
|
|
1083
|
+
async readServeGate(partition, runningSchemaVersion) {
|
|
1084
|
+
return readServeGateOn(this.#exec, partition, runningSchemaVersion);
|
|
1085
|
+
}
|
|
1086
|
+
async declareCheckpoint(partition, name, schemaVersion, nowMs) {
|
|
1087
|
+
// One transaction: the checkpoint becomes visible and the fence that
|
|
1088
|
+
// protects it is raised together. The fence is raised at declaration, not
|
|
1089
|
+
// at activation, so old writers are rejected for the whole backfill.
|
|
1090
|
+
//
|
|
1091
|
+
// RFC 0007 lock decision: this transaction writes only the checkpoint and
|
|
1092
|
+
// fence rows, no application rows and no schema marker, so it does not
|
|
1093
|
+
// take the migration table lock or a partition lock. The fence it raises is
|
|
1094
|
+
// enforced by the `sync_commits` trigger on every append from the commit
|
|
1095
|
+
// of this transaction onward; a writer that committed before the fence was
|
|
1096
|
+
// visible is pre-barrier by the design's own "from declaration onward"
|
|
1097
|
+
// rule, not a bypass of it.
|
|
1098
|
+
return this.#exec.transaction(async (client) => {
|
|
1099
|
+
await installCheckpointOn(client, partition, name, schemaVersion, nowMs);
|
|
1100
|
+
const { rows } = await client.query(`SELECT partition, name, schema_version, state, watermark,
|
|
1101
|
+
owner_epoch, observed_rows, updated_at_ms
|
|
1102
|
+
FROM sync_backfill_checkpoints WHERE partition=$1 AND name=$2`, [partition, name]);
|
|
1103
|
+
const row = rows[0];
|
|
1104
|
+
if (row === undefined) {
|
|
1105
|
+
throw new StorageQueryError('sync.storage.checkpoint_not_declared');
|
|
1106
|
+
}
|
|
1107
|
+
return toStoredCheckpoint(row);
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
async activateCheckpoint(partition, name, ownerEpoch, watermark, sources, nowMs) {
|
|
1111
|
+
return this.#exec.transaction(async (client) => {
|
|
1112
|
+
// The same `SELECT … FOR UPDATE` partition row lock that
|
|
1113
|
+
// `lockPartitionForPush` takes; a bare `BEGIN` would race a push.
|
|
1114
|
+
await lockPartitionOn(client, partition);
|
|
1115
|
+
const fence = await client.query('SELECT required_writer_version FROM sync_writer_fence WHERE partition=$1', [partition]);
|
|
1116
|
+
const checkpoint = await client.query('SELECT schema_version FROM sync_backfill_checkpoints WHERE partition=$1 AND name=$2', [partition, name]);
|
|
1117
|
+
const fenceRow = fence.rows[0];
|
|
1118
|
+
const checkpointRow = checkpoint.rows[0];
|
|
1119
|
+
if (fenceRow === undefined ||
|
|
1120
|
+
checkpointRow === undefined ||
|
|
1121
|
+
asNumber(fenceRow.required_writer_version) <
|
|
1122
|
+
asNumber(checkpointRow.schema_version)) {
|
|
1123
|
+
// The barrier is not installed. Activating would certify a state old
|
|
1124
|
+
// writers can still decay; refuse instead of silently proceeding.
|
|
1125
|
+
throw new StorageQueryError('sync.storage.checkpoint_fence_missing');
|
|
1126
|
+
}
|
|
1127
|
+
const coverage = await hasSourceChangesAboveOn(client, partition, sources, watermark);
|
|
1128
|
+
if (coverage !== 'clean') {
|
|
1129
|
+
return coverage === 'changed' ? 'stale' : 'unverifiable';
|
|
1130
|
+
}
|
|
1131
|
+
const updated = await client.query(`UPDATE sync_backfill_checkpoints
|
|
1132
|
+
SET state='activated', watermark=$1, updated_at_ms=$2
|
|
1133
|
+
WHERE partition=$3 AND name=$4 AND owner_epoch=$5
|
|
1134
|
+
AND state<>'activated'
|
|
1135
|
+
RETURNING partition`, [watermark, nowMs, partition, name, ownerEpoch]);
|
|
1136
|
+
return updated.rows.length === 1 ? 'activated' : 'stale';
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
async claimCheckpoint(partition, name, schemaVersion, nowMs) {
|
|
1140
|
+
// The UPDATE's own row lock serializes concurrent claims; owner_epoch+1
|
|
1141
|
+
// is evaluated under it, so two claimers never observe the same epoch.
|
|
1142
|
+
const { rows } = await this.#exec.query(`UPDATE sync_backfill_checkpoints
|
|
1143
|
+
SET owner_epoch=owner_epoch+1,
|
|
1144
|
+
state=CASE WHEN state='declared' THEN 'backfilling' ELSE state END,
|
|
1145
|
+
schema_version=$1, updated_at_ms=$2
|
|
1146
|
+
WHERE partition=$3 AND name=$4 AND state<>'activated'
|
|
1147
|
+
RETURNING partition, name, schema_version, state, watermark,
|
|
1148
|
+
owner_epoch, observed_rows, updated_at_ms`, [schemaVersion, nowMs, partition, name]);
|
|
1149
|
+
if (rows[0] === undefined) {
|
|
1150
|
+
throw new StorageQueryError('sync.storage.checkpoint_not_declared');
|
|
1151
|
+
}
|
|
1152
|
+
return toStoredCheckpoint(rows[0]);
|
|
1153
|
+
}
|
|
1154
|
+
async advanceCheckpoint(partition, name, ownerEpoch, watermark, observedRows, nowMs) {
|
|
1155
|
+
const result = await this.#exec.query(`UPDATE sync_backfill_checkpoints
|
|
1156
|
+
SET watermark=$1, observed_rows=$2, updated_at_ms=$3
|
|
1157
|
+
WHERE partition=$4 AND name=$5 AND owner_epoch=$6
|
|
1158
|
+
RETURNING name`, [watermark, observedRows, nowMs, partition, name, ownerEpoch]);
|
|
1159
|
+
return result.rows.length === 1;
|
|
1160
|
+
}
|
|
1161
|
+
async sourceCoverageSeq(partition, tables) {
|
|
1162
|
+
if (tables.length === 0)
|
|
1163
|
+
return 0;
|
|
1164
|
+
const tableParams = tables.map((_, index) => `$${index + 2}`).join(',');
|
|
1165
|
+
const { rows } = await this.#exec.query(`SELECT max(commit_seq) AS seq FROM sync_changes
|
|
1166
|
+
WHERE partition=$1 AND tbl IN (${tableParams})`, [partition, ...tables]);
|
|
1167
|
+
const seq = rows[0]?.seq;
|
|
1168
|
+
return seq === null || seq === undefined ? 0 : asNumber(seq);
|
|
1169
|
+
}
|
|
1170
|
+
hasSourceChangesAbove(partition, tables, seq) {
|
|
1171
|
+
return hasSourceChangesAboveOn(this.#exec, partition, tables, seq);
|
|
1172
|
+
}
|
|
837
1173
|
getRow(partition, table, rowId) {
|
|
838
1174
|
return getRowOn(this.#exec, this.table(table), partition, rowId);
|
|
839
1175
|
}
|
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
|
-
|
|
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 };
|
package/dist/push.js
CHANGED
|
@@ -24,7 +24,9 @@ import { emitEvent } from './events.js';
|
|
|
24
24
|
import { prepareReactions, toNewReactions, } from './reactions.js';
|
|
25
25
|
import { authorizeWrite, renderScopeValue, storedScopesForRow } from './scopes.js';
|
|
26
26
|
import { decodeColumnVersions, encodeColumnVersions } from './relational-rows.js';
|
|
27
|
+
import { serveGateRefusal } from './storage.js';
|
|
27
28
|
import { StorageConstraintError } from './storage-errors.js';
|
|
29
|
+
import { serveNotReadyError } from './readiness.js';
|
|
28
30
|
import { CommitValidationRejection, toValidateRow, ValidationRejection, } from './validate.js';
|
|
29
31
|
/**
|
|
30
32
|
* Extract the blobIds a decoded row references through its `blob_ref`
|
|
@@ -864,6 +866,15 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
|
|
|
864
866
|
throw new Error('storage transaction does not support serialized push apply, atomic rejection finalization, and configured durable reactions');
|
|
865
867
|
}
|
|
866
868
|
await lockPartitionForPush();
|
|
869
|
+
// RFC 0007: evaluate the gate on this transaction's own connection while
|
|
870
|
+
// the partition lock is held, so the migration check and the write share
|
|
871
|
+
// one transaction and no migration can interleave in between.
|
|
872
|
+
const gate = await tx.readServeGate(schema.version);
|
|
873
|
+
const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
|
|
874
|
+
if (refusal !== undefined) {
|
|
875
|
+
await tx.rollback();
|
|
876
|
+
throw serveNotReadyError(refusal);
|
|
877
|
+
}
|
|
867
878
|
// The optimistic lookup above may have raced another delivery. Re-check
|
|
868
879
|
// only after acquiring partition serialization and before any operation
|
|
869
880
|
// read, validation, merge, or staged write. The re-check runs on the
|
|
@@ -1015,6 +1026,13 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
|
|
|
1015
1026
|
}
|
|
1016
1027
|
}
|
|
1017
1028
|
if (ctx.realtime !== undefined && changes.length > 0) {
|
|
1029
|
+
// RFC 0007 fanout decision: the notification carries the durable commit
|
|
1030
|
+
// entry itself (this commit's `changes`), not a projection read or a pull
|
|
1031
|
+
// window, so fanning it out before the request's buffered read-verify can
|
|
1032
|
+
// never serve a mixed result. Each peer's next pull is gated at the
|
|
1033
|
+
// storage seam; a refused mixed push+pull is safe because the push is
|
|
1034
|
+
// durable and replayable under its commit id. This is commit-entry
|
|
1035
|
+
// durability, not gate inheritance.
|
|
1018
1036
|
await ctx.realtime.notifyCommit(partition, {
|
|
1019
1037
|
commitSeq,
|
|
1020
1038
|
createdAtMs,
|
package/dist/readiness.d.ts
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* a structured failure that cannot be confused with request authentication.
|
|
8
8
|
*/
|
|
9
9
|
import type { SyncServerConfig } from './context.js';
|
|
10
|
+
import { type SyncError } from './errors.js';
|
|
11
|
+
import type { ServeGateRefusal } from './storage.js';
|
|
10
12
|
export declare const SYNC_SERVER_READINESS_ERROR_CODE: "sync.schema_not_ready";
|
|
11
|
-
export type SyncServerReadinessPhase = 'schema_compile' | 'storage_migration';
|
|
13
|
+
export type SyncServerReadinessPhase = 'schema_compile' | 'storage_migration' | 'backfill_checkpoint';
|
|
12
14
|
export declare class SyncServerReadinessError extends Error {
|
|
13
15
|
readonly name = "SyncServerReadinessError";
|
|
14
16
|
readonly code: "sync.schema_not_ready";
|
|
@@ -27,4 +29,9 @@ export declare class SyncServerReadinessError extends Error {
|
|
|
27
29
|
* exposes only a stable code, phase, and schema version; operators can inspect
|
|
28
30
|
* `cause` locally for the table/column or storage diagnostic.
|
|
29
31
|
*/
|
|
30
|
-
export declare function ensureSyncServerReady(config: Pick<SyncServerConfig, 'schema' | 'storage'>): Promise<void>;
|
|
32
|
+
export declare function ensureSyncServerReady(config: Pick<SyncServerConfig, 'schema' | 'storage' | 'checkpoints'>): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* RFC 0007: refuse a serve path that observed a closed gate. The projection
|
|
35
|
+
* name travels in structured details, never in the message.
|
|
36
|
+
*/
|
|
37
|
+
export declare function serveNotReadyError(refusal: ServeGateRefusal): SyncError;
|
package/dist/readiness.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { syncError } from './errors.js';
|
|
1
2
|
import { compileSchema } from './schema.js';
|
|
2
3
|
export const SYNC_SERVER_READINESS_ERROR_CODE = 'sync.schema_not_ready';
|
|
3
4
|
export class SyncServerReadinessError extends Error {
|
|
@@ -31,13 +32,37 @@ export async function ensureSyncServerReady(config) {
|
|
|
31
32
|
});
|
|
32
33
|
}
|
|
33
34
|
try {
|
|
34
|
-
await config.storage.ensureSchema(compiled);
|
|
35
|
+
await config.storage.ensureSchema(compiled, config.checkpoints);
|
|
35
36
|
}
|
|
36
37
|
catch (cause) {
|
|
38
|
+
// RFC 0007: a declared-but-incomplete checkpoint is a barrier refusal, not
|
|
39
|
+
// an ordinary migration failure — hosts distinguish it by `phase` while the
|
|
40
|
+
// client-visible identity stays `sync.schema_not_ready`.
|
|
41
|
+
const phase = cause?.code ===
|
|
42
|
+
'sync.storage.checkpoint_incomplete'
|
|
43
|
+
? 'backfill_checkpoint'
|
|
44
|
+
: 'storage_migration';
|
|
37
45
|
throw new SyncServerReadinessError({
|
|
38
|
-
phase
|
|
46
|
+
phase,
|
|
39
47
|
schemaVersion: config.schema.version,
|
|
40
48
|
cause,
|
|
41
49
|
});
|
|
42
50
|
}
|
|
43
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* RFC 0007: refuse a serve path that observed a closed gate. The projection
|
|
54
|
+
* name travels in structured details, never in the message.
|
|
55
|
+
*/
|
|
56
|
+
export function serveNotReadyError(refusal) {
|
|
57
|
+
return syncError(SYNC_SERVER_READINESS_ERROR_CODE, 'server schema is not ready for this request', refusal.kind === 'checkpoint'
|
|
58
|
+
? JSON.stringify({
|
|
59
|
+
partition: refusal.partition,
|
|
60
|
+
projection: refusal.name,
|
|
61
|
+
})
|
|
62
|
+
: refusal.kind === 'epoch'
|
|
63
|
+
? JSON.stringify({ logEpochChanged: true })
|
|
64
|
+
: JSON.stringify({
|
|
65
|
+
storedSchemaVersion: refusal.stored,
|
|
66
|
+
runningSchemaVersion: refusal.running,
|
|
67
|
+
}));
|
|
68
|
+
}
|