@syncular/server 0.22.0 → 0.24.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 +14 -2
- 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 +186 -36
- package/dist/errors.js +10 -0
- package/dist/handler.js +50 -4
- package/dist/operations.d.ts +7 -1
- package/dist/operations.js +5 -3
- package/dist/postgres-storage.d.ts +12 -4
- package/dist/postgres-storage.js +310 -59
- package/dist/push.js +31 -7
- package/dist/readiness.d.ts +9 -2
- package/dist/readiness.js +27 -2
- package/dist/realtime.js +20 -1
- package/dist/relational-rows.d.ts +31 -12
- package/dist/relational-rows.js +121 -26
- package/dist/segment-download.js +8 -0
- package/dist/sqlite-dialect.d.ts +1 -1
- package/dist/sqlite-dialect.js +15 -0
- package/dist/sqlite-storage.d.ts +18 -3
- package/dist/sqlite-storage.js +327 -82
- package/dist/storage-errors.d.ts +5 -3
- package/dist/storage-errors.js +13 -2
- package/dist/storage-query.d.ts +8 -1
- package/dist/storage-query.js +12 -0
- package/dist/storage.d.ts +153 -14
- package/dist/storage.js +40 -1
- package/dist/validate.d.ts +33 -15
- 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 +282 -37
- package/src/errors.ts +10 -0
- package/src/handler.ts +62 -4
- package/src/operations.ts +32 -14
- package/src/postgres-storage.ts +515 -68
- package/src/push.ts +31 -1
- package/src/readiness.ts +39 -4
- package/src/realtime.ts +22 -1
- package/src/relational-rows.ts +145 -30
- package/src/segment-download.ts +16 -0
- package/src/sqlite-dialect.ts +15 -0
- package/src/sqlite-storage.ts +491 -96
- package/src/storage-errors.ts +33 -3
- package/src/storage-query.ts +23 -1
- package/src/storage.ts +245 -12
- package/src/validate.ts +40 -15
package/dist/sqlite-storage.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
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
|
/**
|
|
4
6
|
* SQLite server storage over the shared synchronous driver.
|
|
5
7
|
*
|
|
@@ -10,12 +12,24 @@ import { StorageQueryError } from './storage-errors.js';
|
|
|
10
12
|
*/
|
|
11
13
|
import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
12
14
|
import { syncError } from './errors.js';
|
|
13
|
-
import { assertPhysicalColumns, commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
15
|
+
import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
14
16
|
import { matchesEffective } from './scopes.js';
|
|
15
|
-
import { collectCommitWindowPage, deserializePushResult, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
|
|
17
|
+
import { collectCommitWindowPage, deserializePushResult, placeholders, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
|
|
16
18
|
import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
|
|
17
19
|
import { isSqliteConstraintError, StorageConstraintError, } from './storage-errors.js';
|
|
18
20
|
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
21
|
+
function toStoredCheckpoint(record) {
|
|
22
|
+
return {
|
|
23
|
+
partition: record.partition,
|
|
24
|
+
name: record.name,
|
|
25
|
+
schemaVersion: record.schema_version,
|
|
26
|
+
state: record.state,
|
|
27
|
+
watermark: record.watermark,
|
|
28
|
+
ownerEpoch: record.owner_epoch,
|
|
29
|
+
observedRows: record.observed_rows,
|
|
30
|
+
updatedAtMs: record.updated_at_ms,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
19
33
|
function toStoredReaction(record) {
|
|
20
34
|
return {
|
|
21
35
|
idempotencyKey: record.idempotency_key,
|
|
@@ -45,12 +59,15 @@ function toStoredReaction(record) {
|
|
|
45
59
|
class SqliteTransaction {
|
|
46
60
|
#storage;
|
|
47
61
|
#partition;
|
|
62
|
+
/** Schema version this transaction writes; rides every appended commit row. */
|
|
63
|
+
#writerVersion;
|
|
48
64
|
#open = true;
|
|
49
65
|
#pushApplySavepoint = false;
|
|
50
66
|
#release;
|
|
51
|
-
constructor(storage, partition, release) {
|
|
67
|
+
constructor(storage, partition, writerVersion, release) {
|
|
52
68
|
this.#storage = storage;
|
|
53
69
|
this.#partition = partition;
|
|
70
|
+
this.#writerVersion = writerVersion;
|
|
54
71
|
this.#release = release;
|
|
55
72
|
storage.db.exec('BEGIN IMMEDIATE');
|
|
56
73
|
}
|
|
@@ -89,12 +106,33 @@ class SqliteTransaction {
|
|
|
89
106
|
this.#assertOpen();
|
|
90
107
|
return this.#storage.scanRowsByIndex(this.#partition, query);
|
|
91
108
|
}
|
|
109
|
+
async queryAuthoritative(query) {
|
|
110
|
+
this.#assertOpen();
|
|
111
|
+
// Same shared connection: the query runs inside this transaction's
|
|
112
|
+
// BEGIN IMMEDIATE and observes every row staged so far.
|
|
113
|
+
return this.#storage.runAuthoritativeQuery(this.#partition, query);
|
|
114
|
+
}
|
|
92
115
|
async lockPartitionForPush() {
|
|
93
116
|
this.#assertOpen();
|
|
94
117
|
// BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
|
|
95
118
|
this.#storage.db.exec('SAVEPOINT syncular_push_candidate');
|
|
96
119
|
this.#pushApplySavepoint = true;
|
|
97
120
|
}
|
|
121
|
+
async advanceCheckpoint(name, ownerEpoch, watermark, observedRows, nowMs) {
|
|
122
|
+
this.#assertOpen();
|
|
123
|
+
const result = this.#storage.db
|
|
124
|
+
.query(`UPDATE sync_backfill_checkpoints
|
|
125
|
+
SET watermark=?, observed_rows=?, updated_at_ms=?
|
|
126
|
+
WHERE partition=? AND name=? AND owner_epoch=?`)
|
|
127
|
+
.run(watermark, observedRows, nowMs, this.#partition, name, ownerEpoch);
|
|
128
|
+
return Number(result.changes) === 1;
|
|
129
|
+
}
|
|
130
|
+
readServeGate(runningSchemaVersion) {
|
|
131
|
+
this.#assertOpen();
|
|
132
|
+
// Same shared connection: this read runs inside this transaction's
|
|
133
|
+
// BEGIN IMMEDIATE, so the gate and the write share one transaction.
|
|
134
|
+
return this.#storage.readServeGate(this.#partition, runningSchemaVersion);
|
|
135
|
+
}
|
|
98
136
|
async commitRejectedPushResult(clientId, clientCommitId, result) {
|
|
99
137
|
this.#assertOpen();
|
|
100
138
|
if (!this.#pushApplySavepoint) {
|
|
@@ -146,7 +184,7 @@ class SqliteTransaction {
|
|
|
146
184
|
.get(p);
|
|
147
185
|
const commitSeq = (row?.max_commit_seq ?? 0) + 1;
|
|
148
186
|
db.query('UPDATE sync_partitions SET max_commit_seq=? WHERE partition=?').run(commitSeq, p);
|
|
149
|
-
db.query('INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms) VALUES (
|
|
187
|
+
db.query('INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms, writer_version) VALUES (?,?,?,?,?,?,?)').run(p, commitSeq, commit.clientId, commit.clientCommitId, commit.actorId, commit.createdAtMs, this.#writerVersion);
|
|
150
188
|
commit.changes.forEach((change, idx) => {
|
|
151
189
|
db.query('INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload) VALUES (?,?,?,?,?,?,?,?,?)').run(p, commitSeq, idx, change.table, change.rowId, change.op === 'upsert' ? 1 : 2, change.rowVersion ?? null, JSON.stringify(change.scopes), change.payload ?? null);
|
|
152
190
|
if (change.op === 'delete') {
|
|
@@ -247,6 +285,30 @@ export class SqliteServerStorage {
|
|
|
247
285
|
if (!clientColumns.some((column) => column.name === 'wire_version')) {
|
|
248
286
|
this.db.exec('ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1');
|
|
249
287
|
}
|
|
288
|
+
const commitColumns = this.db
|
|
289
|
+
.query('PRAGMA table_info("sync_commits")')
|
|
290
|
+
.all();
|
|
291
|
+
if (!commitColumns.some((column) => column.name === 'writer_version')) {
|
|
292
|
+
// Nullable by design: an old writer omits the column and the trigger's
|
|
293
|
+
// explicit NULL test rejects it. `ADD COLUMN NOT NULL` is unavailable in
|
|
294
|
+
// SQLite, and a default would hand old writers a passing value.
|
|
295
|
+
this.db.exec('ALTER TABLE sync_commits ADD COLUMN writer_version INTEGER');
|
|
296
|
+
}
|
|
297
|
+
// The fence is database-side: an old binary's INSERT never reaches JS. An
|
|
298
|
+
// absent `sync_writer_fence` row makes the WHEN clause false, so a
|
|
299
|
+
// partition with no barrier behaves exactly as before.
|
|
300
|
+
this.db.exec(`CREATE TRIGGER IF NOT EXISTS sync_commits_writer_fence
|
|
301
|
+
BEFORE INSERT ON sync_commits
|
|
302
|
+
FOR EACH ROW
|
|
303
|
+
WHEN EXISTS (
|
|
304
|
+
SELECT 1 FROM sync_writer_fence
|
|
305
|
+
WHERE partition = NEW.partition
|
|
306
|
+
AND (NEW.writer_version IS NULL
|
|
307
|
+
OR NEW.writer_version < required_writer_version)
|
|
308
|
+
)
|
|
309
|
+
BEGIN
|
|
310
|
+
SELECT RAISE(ABORT, 'sync.storage.writer_fence_rejected');
|
|
311
|
+
END`);
|
|
250
312
|
}
|
|
251
313
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
252
314
|
table(name) {
|
|
@@ -256,10 +318,24 @@ export class SqliteServerStorage {
|
|
|
256
318
|
}
|
|
257
319
|
return table;
|
|
258
320
|
}
|
|
259
|
-
async ensureSchema(schema) {
|
|
321
|
+
async ensureSchema(schema, checkpoints) {
|
|
260
322
|
// Memoized fast path: same instance, same schema version.
|
|
261
323
|
if (this.#schemaVersion === schema.version)
|
|
262
324
|
return;
|
|
325
|
+
// The barrier reads stored state: a checkpoint at this schema version that
|
|
326
|
+
// is not activated and that this caller did not declare means this process
|
|
327
|
+
// must not serve. An omitted or empty declaration set declares nothing and
|
|
328
|
+
// is refused here, never treated as a bypass.
|
|
329
|
+
const declarations = checkpoints ?? [];
|
|
330
|
+
const incomplete = this.db
|
|
331
|
+
.query(`SELECT partition, name FROM sync_backfill_checkpoints
|
|
332
|
+
WHERE schema_version=? AND state<>'activated'`)
|
|
333
|
+
.all(schema.version)
|
|
334
|
+
.filter((row) => !declarations.some((declaration) => declaration.partition === row.partition &&
|
|
335
|
+
declaration.name === row.name));
|
|
336
|
+
if (incomplete.length > 0) {
|
|
337
|
+
throw new StorageQueryError('sync.storage.checkpoint_incomplete');
|
|
338
|
+
}
|
|
263
339
|
this.db.exec(SCHEMA_META_DDL_SQLITE);
|
|
264
340
|
const marker = this.db
|
|
265
341
|
.query('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1')
|
|
@@ -268,52 +344,18 @@ export class SqliteServerStorage {
|
|
|
268
344
|
throw new Error(`stored schema version ${marker.schema_version} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`);
|
|
269
345
|
}
|
|
270
346
|
if (marker !== null && marker.schema_version === schema.version) {
|
|
271
|
-
|
|
272
|
-
// build at the same version describes rows the running codec cannot
|
|
273
|
-
// decode. Compare the stored layouts instead of trusting the number.
|
|
274
|
-
const storedLayouts = parseLayouts(marker.layouts);
|
|
275
|
-
const configuredLayouts = parseLayouts(layoutsOf(schema));
|
|
276
|
-
let mismatch;
|
|
277
|
-
for (const [tableName, columns] of Object.entries(configuredLayouts)) {
|
|
278
|
-
const stored = storedLayouts[tableName];
|
|
279
|
-
const columnCount = Math.max(columns.length, stored?.length ?? 0);
|
|
280
|
-
for (let index = 0; index < columnCount; index++) {
|
|
281
|
-
const expected = columns[index];
|
|
282
|
-
const actual = stored?.[index];
|
|
283
|
-
if (expected !== undefined &&
|
|
284
|
-
actual !== undefined &&
|
|
285
|
-
actual.name === expected.name &&
|
|
286
|
-
actual.type === expected.type &&
|
|
287
|
-
actual.nullable === expected.nullable) {
|
|
288
|
-
continue;
|
|
289
|
-
}
|
|
290
|
-
mismatch = `table ${JSON.stringify(tableName)} column ${JSON.stringify(expected?.name ?? actual?.name ?? '')}`;
|
|
291
|
-
break;
|
|
292
|
-
}
|
|
293
|
-
if (mismatch !== undefined)
|
|
294
|
-
break;
|
|
295
|
-
}
|
|
296
|
-
if (mismatch === undefined) {
|
|
297
|
-
for (const tableName of Object.keys(storedLayouts)) {
|
|
298
|
-
if (!(tableName in configuredLayouts)) {
|
|
299
|
-
mismatch = `table ${JSON.stringify(tableName)}`;
|
|
300
|
-
break;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
if (mismatch !== undefined) {
|
|
305
|
-
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`);
|
|
306
|
-
}
|
|
307
|
-
// The persisted layouts describe the codec's app columns only: read the
|
|
308
|
-
// physical tables so a same-version database missing a
|
|
309
|
-
// storage-internal column is refused at startup instead of failing at
|
|
310
|
-
// the first write.
|
|
347
|
+
assertStoredLayouts(schema, marker.layouts);
|
|
311
348
|
for (const table of schema.tables.values()) {
|
|
312
349
|
const escapedTableName = table.name.replaceAll('"', '""');
|
|
313
350
|
const columns = this.db
|
|
314
351
|
.query(`PRAGMA table_info("${escapedTableName}")`)
|
|
315
352
|
.all();
|
|
316
|
-
assertPhysicalColumns(table,
|
|
353
|
+
assertPhysicalColumns(table, columns.map((column) => ({
|
|
354
|
+
name: column.name,
|
|
355
|
+
type: column.type,
|
|
356
|
+
notNull: column.notnull === 1,
|
|
357
|
+
primaryKeyPosition: column.pk,
|
|
358
|
+
})), 'sqlite');
|
|
317
359
|
}
|
|
318
360
|
}
|
|
319
361
|
if (marker === null || marker.schema_version < schema.version) {
|
|
@@ -340,36 +382,50 @@ export class SqliteServerStorage {
|
|
|
340
382
|
existingIndexes.set(table.name, new Set(indexes.map((index) => index.name)));
|
|
341
383
|
}
|
|
342
384
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
385
|
+
// The migration rewrites application rows (`#rewriteRows`) with no
|
|
386
|
+
// commit log entry. Serialize it through the same writer queue as
|
|
387
|
+
// pushes and prune (`#serializeWrite`) rather than trusting the
|
|
388
|
+
// migration as privileged; `BEGIN IMMEDIATE` then owns SQLite's writer
|
|
389
|
+
// lock for the whole rewrite.
|
|
390
|
+
await this.#serializeWrite(() => {
|
|
391
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
392
|
+
try {
|
|
393
|
+
for (const tableName of retiredTables) {
|
|
394
|
+
this.db
|
|
395
|
+
.query('DELETE FROM sync_row_scopes WHERE tbl=?')
|
|
396
|
+
.run(tableName);
|
|
397
|
+
this.db
|
|
398
|
+
.query('DELETE FROM sync_blob_refs WHERE tbl=?')
|
|
399
|
+
.run(tableName);
|
|
400
|
+
this.db.exec(dropTableDdl(tableName));
|
|
401
|
+
}
|
|
402
|
+
for (const statement of schemaDdl(schema, existing, 'sqlite', existingIndexes)) {
|
|
403
|
+
this.db.exec(statement);
|
|
404
|
+
}
|
|
405
|
+
for (const table of schema.tables.values()) {
|
|
406
|
+
const oldLayout = layouts[table.name];
|
|
407
|
+
const plan = rewritePlan(table, oldLayout, existing.get(table.name));
|
|
408
|
+
if (!plan.migrate && !plan.backfill)
|
|
409
|
+
continue;
|
|
410
|
+
this.#rewriteRows(table, plan.migrate ? oldLayout : undefined);
|
|
411
|
+
}
|
|
349
412
|
this.db
|
|
350
|
-
.query('
|
|
351
|
-
.run(
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
413
|
+
.query('INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET schema_version=excluded.schema_version, layouts=excluded.layouts')
|
|
414
|
+
.run(schema.version, layoutsOf(schema));
|
|
415
|
+
// The host's declared checkpoints install in the schema-bump
|
|
416
|
+
// transaction, so no observer sees the bumped marker without the
|
|
417
|
+
// fence, or the fence without its declaration row.
|
|
418
|
+
const installedAtMs = Date.now();
|
|
419
|
+
for (const declaration of checkpoints ?? []) {
|
|
420
|
+
this.#installCheckpoint(declaration.partition, declaration.name, declaration.schemaVersion, installedAtMs);
|
|
421
|
+
}
|
|
422
|
+
this.db.exec('COMMIT');
|
|
356
423
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
if (!plan.migrate && !plan.backfill)
|
|
361
|
-
continue;
|
|
362
|
-
this.#rewriteRows(table, plan.migrate ? oldLayout : undefined);
|
|
424
|
+
catch (error) {
|
|
425
|
+
this.db.exec('ROLLBACK');
|
|
426
|
+
throw error;
|
|
363
427
|
}
|
|
364
|
-
|
|
365
|
-
.query('INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET schema_version=excluded.schema_version, layouts=excluded.layouts')
|
|
366
|
-
.run(schema.version, layoutsOf(schema));
|
|
367
|
-
this.db.exec('COMMIT');
|
|
368
|
-
}
|
|
369
|
-
catch (error) {
|
|
370
|
-
this.db.exec('ROLLBACK');
|
|
371
|
-
throw error;
|
|
372
|
-
}
|
|
428
|
+
});
|
|
373
429
|
}
|
|
374
430
|
this.#tables = schema.tables;
|
|
375
431
|
this.#schemaVersion = schema.version;
|
|
@@ -485,7 +541,7 @@ export class SqliteServerStorage {
|
|
|
485
541
|
});
|
|
486
542
|
await previous;
|
|
487
543
|
try {
|
|
488
|
-
return new SqliteTransaction(this, partition, release);
|
|
544
|
+
return new SqliteTransaction(this, partition, this.#schemaVersion ?? 0, release);
|
|
489
545
|
}
|
|
490
546
|
catch (error) {
|
|
491
547
|
release();
|
|
@@ -513,11 +569,26 @@ export class SqliteServerStorage {
|
|
|
513
569
|
.get(partition);
|
|
514
570
|
return row?.max_commit_seq ?? 0;
|
|
515
571
|
}
|
|
516
|
-
|
|
572
|
+
/**
|
|
573
|
+
* Internal: rewrite and run one registered query on the shared connection.
|
|
574
|
+
* The caller owns the transaction: `queryAuthoritative` opens a read
|
|
575
|
+
* transaction behind the FIFO; `SqliteTransaction.queryAuthoritative` runs
|
|
576
|
+
* inside the push transaction's `BEGIN IMMEDIATE`.
|
|
577
|
+
*/
|
|
578
|
+
runAuthoritativeQuery(partition, query) {
|
|
517
579
|
if (this.#tables === undefined) {
|
|
518
580
|
throw new Error('ensureSchema(schema) must run before registered queries');
|
|
519
581
|
}
|
|
520
582
|
const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
|
|
583
|
+
const rows = this.db
|
|
584
|
+
.query(prepared.sql)
|
|
585
|
+
.all(...prepared.params);
|
|
586
|
+
const cursor = this.db
|
|
587
|
+
.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
|
|
588
|
+
.get(partition);
|
|
589
|
+
return { rows, maxCommitSeq: cursor?.max_commit_seq ?? 0 };
|
|
590
|
+
}
|
|
591
|
+
async queryAuthoritative(partition, query, checkpoints) {
|
|
521
592
|
const previous = this.#transactionTail;
|
|
522
593
|
let release;
|
|
523
594
|
this.#transactionTail = new Promise((resolve) => {
|
|
@@ -528,15 +599,18 @@ export class SqliteServerStorage {
|
|
|
528
599
|
try {
|
|
529
600
|
this.db.exec('BEGIN');
|
|
530
601
|
open = true;
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
602
|
+
// RFC 0007: evaluate the gate on this connection after BEGIN. SQLite is
|
|
603
|
+
// single-writer and the shared connection serializes writers, so the
|
|
604
|
+
// gate read and the query read cannot straddle a migration.
|
|
605
|
+
const runningVersion = this.#schemaVersion ?? 0;
|
|
606
|
+
const gate = await this.readServeGate(partition, runningVersion);
|
|
607
|
+
const refusal = serveGateRefusal(gate, runningVersion, checkpoints);
|
|
608
|
+
if (refusal !== undefined)
|
|
609
|
+
throw serveNotReadyError(refusal);
|
|
610
|
+
const result = this.runAuthoritativeQuery(partition, query);
|
|
537
611
|
this.db.exec('COMMIT');
|
|
538
612
|
open = false;
|
|
539
|
-
return
|
|
613
|
+
return result;
|
|
540
614
|
}
|
|
541
615
|
catch (error) {
|
|
542
616
|
if (open)
|
|
@@ -616,6 +690,177 @@ export class SqliteServerStorage {
|
|
|
616
690
|
.get(partition, createdBeforeMs);
|
|
617
691
|
return row?.seq ?? 0;
|
|
618
692
|
}
|
|
693
|
+
async readServeGate(partition, runningSchemaVersion) {
|
|
694
|
+
const marker = this.db
|
|
695
|
+
.query('SELECT schema_version FROM sync_schema_meta WHERE id=1')
|
|
696
|
+
.get();
|
|
697
|
+
const epoch = this.db
|
|
698
|
+
.query('SELECT log_epoch FROM sync_partition_registry WHERE partition=?')
|
|
699
|
+
.get(partition);
|
|
700
|
+
const pending = this.db
|
|
701
|
+
.query(`SELECT partition, name FROM sync_backfill_checkpoints
|
|
702
|
+
WHERE schema_version=? AND state<>'activated'`)
|
|
703
|
+
.all(runningSchemaVersion);
|
|
704
|
+
return {
|
|
705
|
+
storedSchemaVersion: marker?.schema_version ?? 0,
|
|
706
|
+
logEpoch: epoch?.log_epoch,
|
|
707
|
+
pending: pending.map((row) => ({
|
|
708
|
+
partition: row.partition,
|
|
709
|
+
name: row.name,
|
|
710
|
+
})),
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
async readCheckpoints(partition) {
|
|
714
|
+
return this.db
|
|
715
|
+
.query(`SELECT partition, name, schema_version, state, watermark,
|
|
716
|
+
owner_epoch, observed_rows, updated_at_ms
|
|
717
|
+
FROM sync_backfill_checkpoints WHERE partition=? ORDER BY name`)
|
|
718
|
+
.all(partition)
|
|
719
|
+
.map(toStoredCheckpoint);
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Insert the `declared` row and raise the fence, inside the caller's open
|
|
723
|
+
* transaction. Shared by `declareCheckpoint` and the `ensureSchema`
|
|
724
|
+
* migration so the schema bump and the fence commit together. Never lowers a
|
|
725
|
+
* fence and never re-declares an existing row.
|
|
726
|
+
*/
|
|
727
|
+
#installCheckpoint(partition, name, schemaVersion, nowMs) {
|
|
728
|
+
this.db
|
|
729
|
+
.query(`INSERT INTO sync_backfill_checkpoints(
|
|
730
|
+
partition, name, schema_version, state, watermark,
|
|
731
|
+
owner_epoch, observed_rows, updated_at_ms)
|
|
732
|
+
VALUES (?,?,?,'declared',0,0,0,?)
|
|
733
|
+
ON CONFLICT(partition, name) DO NOTHING`)
|
|
734
|
+
.run(partition, name, schemaVersion, nowMs);
|
|
735
|
+
this.db
|
|
736
|
+
.query(`INSERT INTO sync_writer_fence(partition, required_writer_version)
|
|
737
|
+
VALUES (?,?)
|
|
738
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
739
|
+
required_writer_version=max(
|
|
740
|
+
sync_writer_fence.required_writer_version,
|
|
741
|
+
excluded.required_writer_version)`)
|
|
742
|
+
.run(partition, schemaVersion);
|
|
743
|
+
}
|
|
744
|
+
async declareCheckpoint(partition, name, schemaVersion, nowMs) {
|
|
745
|
+
// One transaction: the checkpoint becomes visible and the fence that
|
|
746
|
+
// protects it is raised together. The fence is raised at declaration, not
|
|
747
|
+
// at activation, so old writers are rejected for the whole backfill.
|
|
748
|
+
return this.#serializeWrite(() => {
|
|
749
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
750
|
+
try {
|
|
751
|
+
this.#installCheckpoint(partition, name, schemaVersion, nowMs);
|
|
752
|
+
const row = this.db
|
|
753
|
+
.query(`SELECT partition, name, schema_version, state, watermark,
|
|
754
|
+
owner_epoch, observed_rows, updated_at_ms
|
|
755
|
+
FROM sync_backfill_checkpoints WHERE partition=? AND name=?`)
|
|
756
|
+
.get(partition, name);
|
|
757
|
+
if (row === null)
|
|
758
|
+
throw new StorageQueryError('sync.storage.checkpoint_not_declared');
|
|
759
|
+
this.db.exec('COMMIT');
|
|
760
|
+
return toStoredCheckpoint(row);
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
this.db.exec('ROLLBACK');
|
|
764
|
+
throw error;
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
async activateCheckpoint(partition, name, ownerEpoch, watermark, sources, nowMs) {
|
|
769
|
+
return this.#serializeWrite(() => {
|
|
770
|
+
// `BEGIN IMMEDIATE` is SQLite's partition write lock; the same writer
|
|
771
|
+
// lock a push takes. Nothing may observe activation without the fence.
|
|
772
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
773
|
+
try {
|
|
774
|
+
const fence = this.db
|
|
775
|
+
.query('SELECT required_writer_version FROM sync_writer_fence WHERE partition=?')
|
|
776
|
+
.get(partition);
|
|
777
|
+
const checkpoint = this.db
|
|
778
|
+
.query('SELECT schema_version FROM sync_backfill_checkpoints WHERE partition=? AND name=?')
|
|
779
|
+
.get(partition, name);
|
|
780
|
+
if (fence === null ||
|
|
781
|
+
checkpoint === null ||
|
|
782
|
+
fence.required_writer_version < checkpoint.schema_version) {
|
|
783
|
+
// The barrier is not installed. Activating would certify a state old
|
|
784
|
+
// writers can still decay; refuse instead of silently proceeding.
|
|
785
|
+
throw new StorageQueryError('sync.storage.checkpoint_fence_missing');
|
|
786
|
+
}
|
|
787
|
+
const coverage = this.#hasSourceChangesAbove(partition, sources, watermark);
|
|
788
|
+
if (coverage !== 'clean') {
|
|
789
|
+
this.db.exec('ROLLBACK');
|
|
790
|
+
return coverage === 'changed' ? 'stale' : 'unverifiable';
|
|
791
|
+
}
|
|
792
|
+
const updated = this.db
|
|
793
|
+
.query(`UPDATE sync_backfill_checkpoints
|
|
794
|
+
SET state='activated', watermark=?, updated_at_ms=?
|
|
795
|
+
WHERE partition=? AND name=? AND owner_epoch=?
|
|
796
|
+
AND state<>'activated'`)
|
|
797
|
+
.run(watermark, nowMs, partition, name, ownerEpoch);
|
|
798
|
+
this.db.exec('COMMIT');
|
|
799
|
+
return Number(updated.changes) === 1 ? 'activated' : 'stale';
|
|
800
|
+
}
|
|
801
|
+
catch (error) {
|
|
802
|
+
this.db.exec('ROLLBACK');
|
|
803
|
+
throw error;
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
async claimCheckpoint(partition, name, schemaVersion, nowMs) {
|
|
808
|
+
return this.#serializeWrite(() => {
|
|
809
|
+
const claimed = this.db
|
|
810
|
+
.query(`UPDATE sync_backfill_checkpoints
|
|
811
|
+
SET owner_epoch=owner_epoch+1,
|
|
812
|
+
state=CASE WHEN state='declared' THEN 'backfilling' ELSE state END,
|
|
813
|
+
schema_version=?, updated_at_ms=?
|
|
814
|
+
WHERE partition=? AND name=? AND state<>'activated'
|
|
815
|
+
RETURNING partition, name, schema_version, state, watermark,
|
|
816
|
+
owner_epoch, observed_rows, updated_at_ms`)
|
|
817
|
+
.get(schemaVersion, nowMs, partition, name);
|
|
818
|
+
if (claimed === null) {
|
|
819
|
+
throw new StorageQueryError('sync.storage.checkpoint_not_declared');
|
|
820
|
+
}
|
|
821
|
+
return toStoredCheckpoint(claimed);
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
async advanceCheckpoint(partition, name, ownerEpoch, watermark, observedRows, nowMs) {
|
|
825
|
+
return this.#serializeWrite(() => {
|
|
826
|
+
const result = this.db
|
|
827
|
+
.query(`UPDATE sync_backfill_checkpoints
|
|
828
|
+
SET watermark=?, observed_rows=?, updated_at_ms=?
|
|
829
|
+
WHERE partition=? AND name=? AND owner_epoch=?`)
|
|
830
|
+
.run(watermark, observedRows, nowMs, partition, name, ownerEpoch);
|
|
831
|
+
return Number(result.changes) === 1;
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
async sourceCoverageSeq(partition, tables) {
|
|
835
|
+
if (tables.length === 0)
|
|
836
|
+
return 0;
|
|
837
|
+
const row = this.db
|
|
838
|
+
.query(`SELECT max(commit_seq) AS seq FROM sync_changes
|
|
839
|
+
WHERE partition=? AND tbl IN (${placeholders(tables.length)})`)
|
|
840
|
+
.get(partition, ...tables);
|
|
841
|
+
return row?.seq ?? 0;
|
|
842
|
+
}
|
|
843
|
+
#hasSourceChangesAbove(partition, tables, seq) {
|
|
844
|
+
// Read the horizon before the window scan: a horizon past `seq` means the
|
|
845
|
+
// history that would answer the question is gone, so an empty scan proves
|
|
846
|
+
// nothing and must not be reported as `clean`.
|
|
847
|
+
const horizon = this.db
|
|
848
|
+
.query('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
|
|
849
|
+
.get(partition)?.horizon_seq ?? 0;
|
|
850
|
+
if (tables.length === 0)
|
|
851
|
+
return 'clean';
|
|
852
|
+
const hit = this.db
|
|
853
|
+
.query(`SELECT 1 AS hit FROM sync_changes
|
|
854
|
+
WHERE partition=? AND tbl IN (${placeholders(tables.length)})
|
|
855
|
+
AND commit_seq>? LIMIT 1`)
|
|
856
|
+
.get(partition, ...tables, seq);
|
|
857
|
+
if (hit !== null && hit !== undefined)
|
|
858
|
+
return 'changed';
|
|
859
|
+
return horizon > seq ? 'unverifiable' : 'clean';
|
|
860
|
+
}
|
|
861
|
+
async hasSourceChangesAbove(partition, tables, seq) {
|
|
862
|
+
return this.#hasSourceChangesAbove(partition, tables, seq);
|
|
863
|
+
}
|
|
619
864
|
async getRow(partition, table, rowId) {
|
|
620
865
|
const record = this.db
|
|
621
866
|
.query(selectRowSql(this.table(table), 'sqlite'))
|
package/dist/storage-errors.d.ts
CHANGED
|
@@ -9,15 +9,17 @@ export declare class StorageConstraintError extends Error {
|
|
|
9
9
|
constructor(cause: unknown, opIndex?: number);
|
|
10
10
|
}
|
|
11
11
|
/** Stable, privacy-safe failures for trusted server storage queries. */
|
|
12
|
-
export type StorageQueryErrorCode = 'sync.storage.schema_migration_pending' | 'sync.storage.schema_migration_conflict' | 'sync.storage.schema_changed' | 'sync.storage.invalid_migration_budget' | 'sync.storage.scan_requires_scope' | 'sync.storage.index_not_found' | 'sync.storage.index_not_materialized' | 'sync.storage.index_value_count_mismatch' | 'sync.storage.invalid_limit' | 'sync.storage.prune_epoch_mismatch' | 'sync.storage.partition_unregistered' | 'sync.storage.invalid_prune_cursor';
|
|
12
|
+
export type StorageQueryErrorCode = 'sync.storage.schema_migration_pending' | 'sync.storage.schema_migration_conflict' | 'sync.storage.schema_changed' | 'sync.storage.invalid_migration_budget' | 'sync.storage.scan_requires_scope' | 'sync.storage.index_not_found' | 'sync.storage.index_not_materialized' | 'sync.storage.index_value_count_mismatch' | 'sync.storage.invalid_limit' | 'sync.storage.prune_epoch_mismatch' | 'sync.storage.partition_unregistered' | 'sync.storage.invalid_prune_cursor' | 'sync.storage.checkpoint_not_declared' | 'sync.storage.checkpoint_unsupported' | 'sync.storage.checkpoint_fence_missing' | 'sync.storage.checkpoint_incomplete' | 'sync.storage.stored_layout_mismatch' | 'sync.storage.physical_layout_mismatch' | 'sync.storage.transaction_query_unsupported' | 'sync.storage.query_over_staged_writes';
|
|
13
13
|
/**
|
|
14
14
|
* Host-only query error. Messages never include identifiers, values, SQL,
|
|
15
|
-
* paths, or row data; callers branch on `code`, never message text.
|
|
15
|
+
* paths, or row data; callers branch on `code`, never message text. The
|
|
16
|
+
* schema-readiness codes name the offending table and column in `details`.
|
|
16
17
|
*/
|
|
17
18
|
export declare class StorageQueryError extends Error {
|
|
18
19
|
readonly name = "StorageQueryError";
|
|
19
20
|
readonly code: StorageQueryErrorCode;
|
|
20
|
-
|
|
21
|
+
readonly details: Readonly<Record<string, string>> | undefined;
|
|
22
|
+
constructor(code: StorageQueryErrorCode, details?: Readonly<Record<string, string>>);
|
|
21
23
|
}
|
|
22
24
|
/** SQLite primary/extended constraint result codes (`SQLITE_CONSTRAINT*`). */
|
|
23
25
|
export declare function isSqliteConstraintError(error: unknown): boolean;
|
package/dist/storage-errors.js
CHANGED
|
@@ -19,6 +19,14 @@ const STORAGE_QUERY_MESSAGES = {
|
|
|
19
19
|
'sync.storage.prune_epoch_mismatch': 'partition log epoch changed; recompute retention inputs',
|
|
20
20
|
'sync.storage.partition_unregistered': 'pruning requires a registered partition',
|
|
21
21
|
'sync.storage.invalid_prune_cursor': 'pruning requires a non-negative safe integer cursor and a non-empty log epoch',
|
|
22
|
+
'sync.storage.checkpoint_not_declared': 'checkpoint claim requires a declared, not yet activated row',
|
|
23
|
+
'sync.storage.checkpoint_unsupported': 'this storage backend cannot declare backfill checkpoints',
|
|
24
|
+
'sync.storage.checkpoint_fence_missing': 'checkpoint activation requires the writer fence raised at declaration',
|
|
25
|
+
'sync.storage.checkpoint_incomplete': 'a declared backfill checkpoint is not activated and this caller did not declare it',
|
|
26
|
+
'sync.storage.stored_layout_mismatch': 'stored schema layouts disagree with the configured schema at the same version',
|
|
27
|
+
'sync.storage.physical_layout_mismatch': 'a synced table does not match the storage layout the running code reads and writes',
|
|
28
|
+
'sync.storage.transaction_query_unsupported': 'this storage transaction cannot run registered queries',
|
|
29
|
+
'sync.storage.query_over_staged_writes': 'registered query reads a table this transaction has buffered writes for',
|
|
22
30
|
'sync.storage.scan_requires_scope': 'scope-indexed row scans require at least one scope variable',
|
|
23
31
|
'sync.storage.index_not_found': 'trusted row lookup requires a declared relational index',
|
|
24
32
|
'sync.storage.index_not_materialized': 'trusted row lookup requires a materialized relational table',
|
|
@@ -27,14 +35,17 @@ const STORAGE_QUERY_MESSAGES = {
|
|
|
27
35
|
};
|
|
28
36
|
/**
|
|
29
37
|
* Host-only query error. Messages never include identifiers, values, SQL,
|
|
30
|
-
* paths, or row data; callers branch on `code`, never message text.
|
|
38
|
+
* paths, or row data; callers branch on `code`, never message text. The
|
|
39
|
+
* schema-readiness codes name the offending table and column in `details`.
|
|
31
40
|
*/
|
|
32
41
|
export class StorageQueryError extends Error {
|
|
33
42
|
name = 'StorageQueryError';
|
|
34
43
|
code;
|
|
35
|
-
|
|
44
|
+
details;
|
|
45
|
+
constructor(code, details) {
|
|
36
46
|
super(STORAGE_QUERY_MESSAGES[code]);
|
|
37
47
|
this.code = code;
|
|
48
|
+
this.details = details;
|
|
38
49
|
}
|
|
39
50
|
}
|
|
40
51
|
function driverError(error) {
|
package/dist/storage-query.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CompiledTable, IndexSchema } from './schema.js';
|
|
2
|
-
import type { IndexRowScanQuery, RowScanQuery } from './storage.js';
|
|
2
|
+
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, IndexRowScanQuery, RowScanQuery, StorageTransaction } from './storage.js';
|
|
3
3
|
/**
|
|
4
4
|
* Fail loudly instead of making an unsupported unscoped scan look empty.
|
|
5
5
|
* Returns the first scope variable in sorted order — the one every adapter
|
|
@@ -8,3 +8,10 @@ import type { IndexRowScanQuery, RowScanQuery } from './storage.js';
|
|
|
8
8
|
export declare function assertScopeIndexedScan(query: RowScanQuery): string;
|
|
9
9
|
/** Validate and resolve one exact trusted-host relational index lookup. */
|
|
10
10
|
export declare function resolveIndexRowScan(table: CompiledTable, query: IndexRowScanQuery): IndexSchema;
|
|
11
|
+
/**
|
|
12
|
+
* §6.7/§6.8 transaction-bound registered query for every host hook that runs
|
|
13
|
+
* inside a push transaction. A storage transaction without the capability
|
|
14
|
+
* fails; the call never falls back to `ServerStorage.queryAuthoritative`,
|
|
15
|
+
* which reads outside the transaction.
|
|
16
|
+
*/
|
|
17
|
+
export declare function transactionQuery(tx: StorageTransaction, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
|
package/dist/storage-query.js
CHANGED
|
@@ -34,3 +34,15 @@ export function resolveIndexRowScan(table, query) {
|
|
|
34
34
|
}
|
|
35
35
|
return index;
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* §6.7/§6.8 transaction-bound registered query for every host hook that runs
|
|
39
|
+
* inside a push transaction. A storage transaction without the capability
|
|
40
|
+
* fails; the call never falls back to `ServerStorage.queryAuthoritative`,
|
|
41
|
+
* which reads outside the transaction.
|
|
42
|
+
*/
|
|
43
|
+
export async function transactionQuery(tx, query) {
|
|
44
|
+
if (tx.queryAuthoritative === undefined) {
|
|
45
|
+
throw new StorageQueryError('sync.storage.transaction_query_unsupported');
|
|
46
|
+
}
|
|
47
|
+
return tx.queryAuthoritative(query);
|
|
48
|
+
}
|