@syncular/server 0.37.0 → 0.39.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/dist/content-encoding.d.ts +24 -23
- package/dist/content-encoding.js +77 -22
- package/dist/d1-storage.js +24 -19
- package/dist/postgres-storage.d.ts +1 -1
- package/dist/postgres-storage.js +240 -74
- package/dist/pull.d.ts +7 -3
- package/dist/pull.js +21 -12
- package/dist/relational-rows.d.ts +49 -23
- package/dist/relational-rows.js +133 -59
- package/dist/schema.d.ts +19 -0
- package/dist/schema.js +22 -0
- package/dist/sqlite-bun-driver.js +1 -1
- package/dist/sqlite-node-driver.d.ts +1 -0
- package/dist/sqlite-node-driver.js +13 -3
- package/dist/sqlite-storage.js +36 -21
- package/dist/storage.d.ts +8 -1
- package/dist/storage.js +3 -0
- package/package.json +2 -2
- package/src/content-encoding.ts +99 -31
- package/src/d1-storage.ts +36 -22
- package/src/postgres-storage.ts +338 -109
- package/src/pull.ts +31 -18
- package/src/relational-rows.ts +182 -66
- package/src/schema.ts +55 -0
- package/src/sqlite-bun-driver.ts +1 -1
- package/src/sqlite-node-driver.ts +18 -3
- package/src/sqlite-storage.ts +64 -34
- package/src/storage.ts +9 -1
package/dist/postgres-storage.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { validateCommitPruneQuery } from './prune.js';
|
|
2
2
|
import { StorageQueryError } from './storage-errors.js';
|
|
3
|
-
import { serveGateRefusal } from './storage.js';
|
|
3
|
+
import { PARTITION_TOUCH_INTERVAL_MS, serveGateRefusal } from './storage.js';
|
|
4
4
|
import { serveNotReadyError } from './readiness.js';
|
|
5
5
|
import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
6
6
|
import { syncError } from './errors.js';
|
|
7
7
|
import { asBytes, asNumber, } from './pg-executor.js';
|
|
8
|
-
import { assertPhysicalColumns, assertStoredLayouts, COMMIT_WINDOW_BATCH_SQL, LATEST_SCOPED_CHANGE_BATCH_SQL, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan,
|
|
8
|
+
import { assertPhysicalColumns, assertStoredLayouts, COMMIT_WINDOW_BATCH_SQL, LATEST_SCOPED_CHANGE_BATCH_SQL, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, tableColumnNames, retiredTableNames, rewritePlan, columnSqlType, rewriteValues, SCHEMA_META_DDL_POSTGRES, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowBatchSql, scanRowPageSql, indexSignature, schemaDdl, selectRowScopesSql, selectRowSql, prefetchRowsSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
9
9
|
import { matchesEffective, sha256Hex } from './scopes.js';
|
|
10
10
|
import { isPostgresConstraintError, StorageConstraintError, } from './storage-errors.js';
|
|
11
11
|
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
@@ -22,7 +22,7 @@ import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js'
|
|
|
22
22
|
* their table.
|
|
23
23
|
*
|
|
24
24
|
* `sync_row_scopes_by_row (partition, tbl, row_id)` is the one secondary
|
|
25
|
-
* scope index: `
|
|
25
|
+
* scope index: `writeRowsOn` and `deleteRow` replace a row's scope entries
|
|
26
26
|
* with a `(partition, tbl, row_id)` predicate, and `row_id` is the LAST
|
|
27
27
|
* column of the inverted PRIMARY KEY, so without this index that predicate
|
|
28
28
|
* scans the table's whole scope range on every row written (a bulk import
|
|
@@ -368,23 +368,52 @@ function toStoredChange(record) {
|
|
|
368
368
|
*/
|
|
369
369
|
async function rewriteRowsOn(q, table, oldLayout) {
|
|
370
370
|
const select = selectRowsForRewriteSql(table, 'postgres');
|
|
371
|
-
|
|
372
|
-
|
|
371
|
+
// One UPDATE per page: behind a network hop (Hyperdrive, a managed
|
|
372
|
+
// database) every statement is a round trip, and the bump holds the
|
|
373
|
+
// migration lock for its whole duration. The page's rows bind as one
|
|
374
|
+
// VALUES list with typed placeholders (`rewriteValues` order: projection
|
|
375
|
+
// columns, payload, partition, row id).
|
|
376
|
+
const types = [
|
|
377
|
+
...(table.materialize
|
|
378
|
+
? table.columns.map((column) => columnSqlType(column, 'postgres'))
|
|
379
|
+
: []),
|
|
380
|
+
'BYTEA',
|
|
381
|
+
'TEXT',
|
|
382
|
+
'TEXT',
|
|
383
|
+
];
|
|
384
|
+
const projected = table.materialize ? table.columns : [];
|
|
385
|
+
const sets = [
|
|
386
|
+
...projected.map((column, i) => `${quoteIdent(column.name)}=v.c${i}`),
|
|
387
|
+
`${quoteIdent(SYNC_PAYLOAD_COLUMN)}=v.c${projected.length}`,
|
|
388
|
+
].join(', ');
|
|
389
|
+
const aliases = types.map((_, i) => `c${i}`).join(', ');
|
|
390
|
+
const partitionAlias = `c${projected.length + 1}`;
|
|
391
|
+
const rowIdAlias = `c${projected.length + 2}`;
|
|
392
|
+
// A page binds types.length parameters per row; PGlite corrupts its state
|
|
393
|
+
// above 32,767 bound parameters (see PG_CHANGES_PER_STATEMENT).
|
|
394
|
+
const batch = Math.max(1, Math.min(500, Math.floor(30_000 / types.length)));
|
|
373
395
|
let afterPartition = '';
|
|
374
396
|
let afterRowId = '';
|
|
375
397
|
for (;;) {
|
|
376
|
-
const { rows } = await q.query(select, [afterPartition, afterRowId,
|
|
398
|
+
const { rows } = await q.query(select, [afterPartition, afterRowId, batch]);
|
|
377
399
|
if (rows.length === 0)
|
|
378
400
|
break;
|
|
379
|
-
|
|
401
|
+
const params = [];
|
|
402
|
+
const values = rows.map((row) => {
|
|
380
403
|
const bytes = asBytes(row.payload);
|
|
381
404
|
const payload = oldLayout !== undefined
|
|
382
405
|
? migratePayload(oldLayout, table, bytes)
|
|
383
406
|
: bytes;
|
|
384
|
-
|
|
385
|
-
|
|
407
|
+
const base = params.length;
|
|
408
|
+
params.push(...rewriteValues(table, row.partition, row.row_id, payload, 'postgres'));
|
|
409
|
+
return `(${types.map((type, i) => `$${base + i + 1}::${type}`).join(',')})`;
|
|
410
|
+
});
|
|
411
|
+
await q.query(`UPDATE ${quoteIdent(table.name)} AS t SET ${sets}
|
|
412
|
+
FROM (VALUES ${values.join(',')}) AS v(${aliases})
|
|
413
|
+
WHERE t.${quoteIdent(SYNC_PARTITION_COLUMN)}=v.${partitionAlias}
|
|
414
|
+
AND t.${quoteIdent(SYNC_ROW_ID_COLUMN)}=v.${rowIdAlias}`, params);
|
|
386
415
|
const last = rows[rows.length - 1];
|
|
387
|
-
if (last === undefined || rows.length <
|
|
416
|
+
if (last === undefined || rows.length < batch)
|
|
388
417
|
break;
|
|
389
418
|
afterPartition = last.partition;
|
|
390
419
|
afterRowId = last.row_id;
|
|
@@ -402,29 +431,54 @@ async function scanRowsByIndexOn(q, compiled, partition, query) {
|
|
|
402
431
|
const { rows } = await q.query(statement.sql, statement.params);
|
|
403
432
|
return rows.map(toStoredRow);
|
|
404
433
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
434
|
+
/**
|
|
435
|
+
* Write rows of one table in one statement per chunk: the upsert, and the
|
|
436
|
+
* scope index synced to each row's scope map (entries outside it deleted,
|
|
437
|
+
* missing ones inserted). Row ids are distinct, so no index entry is touched
|
|
438
|
+
* twice and the writable CTEs cannot conflict. The scopes bind as text
|
|
439
|
+
* before the JSONB cast (see `appendCommit`). Every chunk is handed to `q`
|
|
440
|
+
* before the first one settles, so a statement issued after this call
|
|
441
|
+
* queues behind all of them on the transaction FIFO.
|
|
442
|
+
*/
|
|
443
|
+
function writeRowsOn(q, compiled, partition, rows) {
|
|
444
|
+
const width = tableColumnNames(compiled).length;
|
|
445
|
+
// PGlite corrupts its state above 32,767 bound parameters (see
|
|
446
|
+
// PG_CHANGES_PER_STATEMENT).
|
|
447
|
+
const chunk = Math.max(1, Math.floor(30_000 / width));
|
|
448
|
+
const writes = [];
|
|
449
|
+
for (let first = 0; first < rows.length; first += chunk) {
|
|
450
|
+
const slice = rows.slice(first, first + chunk);
|
|
451
|
+
const params = slice.flatMap((entry) => entry.values);
|
|
452
|
+
const n = params.length;
|
|
453
|
+
writes.push(q.query(`WITH upserted AS (${upsertSql(compiled, 'postgres', slice.length)}),
|
|
454
|
+
batch AS (
|
|
455
|
+
SELECT row_id, scopes
|
|
456
|
+
FROM jsonb_to_recordset($${n + 1}::text::jsonb) AS r(row_id text, scopes jsonb)
|
|
457
|
+
),
|
|
458
|
+
scope AS (
|
|
459
|
+
SELECT batch.row_id, entry.key, entry.value
|
|
460
|
+
FROM batch CROSS JOIN LATERAL jsonb_each_text(batch.scopes) AS entry
|
|
461
|
+
),
|
|
462
|
+
removed AS (
|
|
463
|
+
DELETE FROM sync_row_scopes existing
|
|
464
|
+
USING batch
|
|
465
|
+
WHERE existing.partition=$${n + 2} AND existing.tbl=$${n + 3}
|
|
466
|
+
AND existing.row_id=batch.row_id
|
|
467
|
+
AND NOT EXISTS (
|
|
468
|
+
SELECT 1 FROM scope
|
|
469
|
+
WHERE scope.row_id=existing.row_id
|
|
470
|
+
AND scope.key=existing.var AND scope.value=existing.value)
|
|
471
|
+
)
|
|
472
|
+
INSERT INTO sync_row_scopes(partition, tbl, var, value, row_id)
|
|
473
|
+
SELECT $${n + 2}, $${n + 3}, key, value, row_id FROM scope
|
|
474
|
+
ON CONFLICT DO NOTHING`, [
|
|
475
|
+
...params,
|
|
476
|
+
JSON.stringify(slice.map(({ row }) => ({ row_id: row.rowId, scopes: row.scopes }))),
|
|
477
|
+
partition,
|
|
478
|
+
compiled.name,
|
|
479
|
+
]));
|
|
480
|
+
}
|
|
481
|
+
return Promise.all(writes);
|
|
428
482
|
}
|
|
429
483
|
async function getPushResultOn(q, partition, clientId, clientCommitId) {
|
|
430
484
|
const { rows } = await q.query('SELECT result FROM sync_push_results WHERE partition=$1 AND client_id=$2 AND client_commit_id=$3', [partition, clientId, clientCommitId]);
|
|
@@ -450,6 +504,8 @@ async function queryAuthoritativeOn(client, partition, prepared) {
|
|
|
450
504
|
}
|
|
451
505
|
class PostgresTransaction {
|
|
452
506
|
#client;
|
|
507
|
+
/** The FIFO without the staged-write prelude (see the constructor). */
|
|
508
|
+
#serial;
|
|
453
509
|
#partition;
|
|
454
510
|
/** Storage-owned relation rewrite, bound to this transaction's partition. */
|
|
455
511
|
#prepareQuery;
|
|
@@ -465,6 +521,15 @@ class PostgresTransaction {
|
|
|
465
521
|
*/
|
|
466
522
|
#prefetchedRows = new Map();
|
|
467
523
|
#prefetchedTombstones = new Map();
|
|
524
|
+
/**
|
|
525
|
+
* Upserts staged per table (row id → row and its push operation index).
|
|
526
|
+
* Every statement of this transaction writes them first (see the
|
|
527
|
+
* constructor), so reads and later writes observe them; a commit's rows
|
|
528
|
+
* reach the database as one statement per table instead of one per row.
|
|
529
|
+
* Tables with a unique index write immediately: a violation must name its
|
|
530
|
+
* operation.
|
|
531
|
+
*/
|
|
532
|
+
#staged = new Map();
|
|
468
533
|
/** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
|
|
469
534
|
#resolve;
|
|
470
535
|
#reject;
|
|
@@ -476,13 +541,31 @@ class PostgresTransaction {
|
|
|
476
541
|
// issued through this transaction is chained on one FIFO — the same
|
|
477
542
|
// shape as `SqliteServerStorage.queryAuthoritative`.
|
|
478
543
|
let tail = Promise.resolve();
|
|
479
|
-
|
|
544
|
+
const serial = {
|
|
480
545
|
query: (text, params) => {
|
|
481
546
|
const run = tail.then(() => client.query(text, params));
|
|
482
547
|
tail = run.then(() => undefined, () => undefined);
|
|
483
548
|
return run;
|
|
484
549
|
},
|
|
485
550
|
};
|
|
551
|
+
this.#serial = serial;
|
|
552
|
+
this.#client = {
|
|
553
|
+
query: async (text, params) => {
|
|
554
|
+
// Queue the staged writes ahead of this statement synchronously, so
|
|
555
|
+
// a concurrently issued statement cannot overtake them.
|
|
556
|
+
const staged = this.#writeStaged();
|
|
557
|
+
const run = serial.query(text, params);
|
|
558
|
+
try {
|
|
559
|
+
await staged;
|
|
560
|
+
}
|
|
561
|
+
catch (error) {
|
|
562
|
+
// The aborted transaction fails the queued statement too.
|
|
563
|
+
run.catch(() => undefined);
|
|
564
|
+
throw error;
|
|
565
|
+
}
|
|
566
|
+
return run;
|
|
567
|
+
},
|
|
568
|
+
};
|
|
486
569
|
this.#partition = partition;
|
|
487
570
|
this.#writerVersion = writerVersion;
|
|
488
571
|
this.#resolveTable = resolveTable;
|
|
@@ -494,6 +577,23 @@ class PostgresTransaction {
|
|
|
494
577
|
if (!this.#open)
|
|
495
578
|
throw new Error('transaction already finished');
|
|
496
579
|
}
|
|
580
|
+
#writeStaged() {
|
|
581
|
+
if (this.#staged.size === 0)
|
|
582
|
+
return Promise.resolve();
|
|
583
|
+
const staged = [...this.#staged];
|
|
584
|
+
this.#staged.clear();
|
|
585
|
+
return Promise.all(staged.map(([table, rows]) => {
|
|
586
|
+
const entries = [...rows.values()];
|
|
587
|
+
return writeRowsOn(this.#serial, table, this.#partition, entries).catch((error) => {
|
|
588
|
+
// Staged tables have no unique index, so a violation is a column
|
|
589
|
+
// constraint; it names the first staged operation of the table.
|
|
590
|
+
if (isPostgresConstraintError(error)) {
|
|
591
|
+
throw new StorageConstraintError(error, entries[0]?.opIndex);
|
|
592
|
+
}
|
|
593
|
+
throw error;
|
|
594
|
+
});
|
|
595
|
+
}));
|
|
596
|
+
}
|
|
497
597
|
getRow(table, rowId) {
|
|
498
598
|
this.#assertOpen();
|
|
499
599
|
const prefetched = this.#prefetchedRows.get(`${table}\u0000${rowId}`);
|
|
@@ -619,6 +719,7 @@ class PostgresTransaction {
|
|
|
619
719
|
if (!this.#pushApplySavepoint) {
|
|
620
720
|
throw new Error('push rejection requires its apply savepoint');
|
|
621
721
|
}
|
|
722
|
+
this.#staged.clear();
|
|
622
723
|
await this.#client.query('ROLLBACK TO SAVEPOINT syncular_push_candidate');
|
|
623
724
|
await this.#client.query('RELEASE SAVEPOINT syncular_push_candidate');
|
|
624
725
|
this.#pushApplySavepoint = false;
|
|
@@ -628,8 +729,28 @@ class PostgresTransaction {
|
|
|
628
729
|
async upsertRow(table, row, context) {
|
|
629
730
|
this.#assertOpen();
|
|
630
731
|
this.#prefetchedRows.delete(`${table}\u0000${row.rowId}`);
|
|
732
|
+
const compiled = this.#resolveTable(table);
|
|
733
|
+
// Decodes the payload: an undecodable row fails here, at its operation.
|
|
734
|
+
const values = upsertValues(compiled, this.#partition, row, 'postgres');
|
|
735
|
+
if (!compiled.indexes.some((index) => index.unique === true)) {
|
|
736
|
+
let rows = this.#staged.get(compiled);
|
|
737
|
+
if (rows === undefined) {
|
|
738
|
+
rows = new Map();
|
|
739
|
+
this.#staged.set(compiled, rows);
|
|
740
|
+
}
|
|
741
|
+
// A later upsert of the same row in this transaction replaces it.
|
|
742
|
+
rows.delete(row.rowId);
|
|
743
|
+
rows.set(row.rowId, {
|
|
744
|
+
row,
|
|
745
|
+
values,
|
|
746
|
+
...(context === undefined ? {} : { opIndex: context.opIndex }),
|
|
747
|
+
});
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
631
750
|
try {
|
|
632
|
-
await
|
|
751
|
+
await writeRowsOn(this.#client, compiled, this.#partition, [
|
|
752
|
+
{ row, values },
|
|
753
|
+
]);
|
|
633
754
|
}
|
|
634
755
|
catch (error) {
|
|
635
756
|
if (isPostgresConstraintError(error)) {
|
|
@@ -757,6 +878,7 @@ class PostgresTransaction {
|
|
|
757
878
|
}
|
|
758
879
|
async commit() {
|
|
759
880
|
this.#assertOpen();
|
|
881
|
+
await this.#writeStaged();
|
|
760
882
|
this.#open = false;
|
|
761
883
|
// Signal the transaction wrapper to COMMIT; wait for it to actually land.
|
|
762
884
|
this.#resolve();
|
|
@@ -765,6 +887,7 @@ class PostgresTransaction {
|
|
|
765
887
|
async rollback() {
|
|
766
888
|
if (!this.#open)
|
|
767
889
|
return;
|
|
890
|
+
this.#staged.clear();
|
|
768
891
|
this.#open = false;
|
|
769
892
|
this.#reject(new RollbackSignal());
|
|
770
893
|
// Swallow — a rollback is a normal outcome, not an error to the caller.
|
|
@@ -794,33 +917,39 @@ export class PostgresServerStorage {
|
|
|
794
917
|
#schemaVersion;
|
|
795
918
|
/**
|
|
796
919
|
* Page reads waiting for the end of the current microtask turn, keyed by
|
|
797
|
-
*
|
|
920
|
+
* batch stream and partition. A pull starts the window read (or first
|
|
798
921
|
* snapshot page) of every subscription before awaiting any of them, so the
|
|
799
|
-
*
|
|
800
|
-
*
|
|
801
|
-
*
|
|
922
|
+
* reads of all tables leave as ONE set-based statement per stream instead
|
|
923
|
+
* of one round trip per subscription or table
|
|
924
|
+
* (SYNCULAR-PULL-ROUNDTRIPS-001).
|
|
802
925
|
*/
|
|
803
926
|
#pageBatches = new Map();
|
|
804
927
|
constructor(exec) {
|
|
805
928
|
this.#exec = exec;
|
|
806
929
|
}
|
|
807
930
|
/**
|
|
808
|
-
* Queue one page read into the batch for (`
|
|
809
|
-
*
|
|
810
|
-
*
|
|
811
|
-
*
|
|
931
|
+
* Queue one page read into the batch for (`key`, `partition`). `sql` is the
|
|
932
|
+
* statement every batch of that stream shares, or a builder over the queued
|
|
933
|
+
* specs when the statement names what the batch contains (the snapshot scan
|
|
934
|
+
* joins one branch per table present). The batch runs as a single statement
|
|
935
|
+
* with the queued specs bound as a JSON array (`$2`); each spec gets its
|
|
936
|
+
* position as `i`, and the result rows are split back by their `sub`
|
|
937
|
+
* column.
|
|
812
938
|
*/
|
|
813
|
-
#batchedPage(sql, partition, spec) {
|
|
814
|
-
const
|
|
815
|
-
let batch = this.#pageBatches.get(
|
|
939
|
+
#batchedPage(key, sql, partition, spec) {
|
|
940
|
+
const batchKey = `${key}\u0000${partition}`;
|
|
941
|
+
let batch = this.#pageBatches.get(batchKey);
|
|
816
942
|
if (batch === undefined) {
|
|
817
943
|
const queued = [];
|
|
818
944
|
batch = queued;
|
|
819
|
-
this.#pageBatches.set(
|
|
945
|
+
this.#pageBatches.set(batchKey, queued);
|
|
820
946
|
queueMicrotask(() => {
|
|
821
|
-
this.#pageBatches.delete(
|
|
822
|
-
const specs =
|
|
823
|
-
|
|
947
|
+
this.#pageBatches.delete(batchKey);
|
|
948
|
+
const specs = queued.map((entry, i) => ({ ...entry.spec, i }));
|
|
949
|
+
const statement = typeof sql === 'string' ? sql : sql(specs);
|
|
950
|
+
this.#exec
|
|
951
|
+
.query(statement, [partition, JSON.stringify(specs)])
|
|
952
|
+
.then(({ rows }) => {
|
|
824
953
|
const grouped = queued.map(() => []);
|
|
825
954
|
for (const row of rows)
|
|
826
955
|
grouped[asNumber(row.sub)]?.push(row);
|
|
@@ -987,8 +1116,6 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
987
1116
|
// backfill for flipped-on materialization) — all inside one
|
|
988
1117
|
// transaction (Postgres DDL is transactional — a failed bump leaves
|
|
989
1118
|
// no half-state).
|
|
990
|
-
const layouts = parseLayouts(typeof marker.layouts === 'string' ? marker.layouts : undefined);
|
|
991
|
-
const retiredTables = retiredTableNames(schema, layouts);
|
|
992
1119
|
await this.#exec.transaction(async (client) => {
|
|
993
1120
|
// RFC 0007 migration barrier: `LOCK TABLE ... EXCLUSIVE` is the FIRST
|
|
994
1121
|
// statement, before any read, DDL, rewrite, marker or checkpoint
|
|
@@ -1001,6 +1128,24 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1001
1128
|
// when it takes this lock, so a writer already inside completes while
|
|
1002
1129
|
// the migration waits.
|
|
1003
1130
|
await client.query('LOCK TABLE sync_partitions IN EXCLUSIVE MODE');
|
|
1131
|
+
// The probe ran before the lock. A concurrent `ensureSchema` (another
|
|
1132
|
+
// isolate, the Durable Object's first request) may have bumped while
|
|
1133
|
+
// this one waited; re-running the rewrite would decode its new-layout
|
|
1134
|
+
// payloads with the old layout. The locked marker decides.
|
|
1135
|
+
const { rows: lockedMarker } = await client.query('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1');
|
|
1136
|
+
const lockedVersion = lockedMarker[0]?.schema_version === null ||
|
|
1137
|
+
lockedMarker[0]?.schema_version === undefined
|
|
1138
|
+
? undefined
|
|
1139
|
+
: asNumber(lockedMarker[0].schema_version);
|
|
1140
|
+
if (lockedVersion !== undefined && lockedVersion > schema.version) {
|
|
1141
|
+
throw new Error(`stored schema version ${lockedVersion} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`);
|
|
1142
|
+
}
|
|
1143
|
+
if (lockedVersion === schema.version)
|
|
1144
|
+
return;
|
|
1145
|
+
const layouts = parseLayouts(typeof lockedMarker[0]?.layouts === 'string'
|
|
1146
|
+
? lockedMarker[0].layouts
|
|
1147
|
+
: undefined);
|
|
1148
|
+
const retiredTables = retiredTableNames(schema, layouts);
|
|
1004
1149
|
const existing = new Map();
|
|
1005
1150
|
const existingIndexes = new Map();
|
|
1006
1151
|
for (const table of schema.tables.values()) {
|
|
@@ -1013,7 +1158,19 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1013
1158
|
// indexes (PRIMARY KEY, UNIQUE constraints) can only be removed
|
|
1014
1159
|
// through their constraint, so DROP INDEX on them would abort
|
|
1015
1160
|
// the migration transaction.
|
|
1016
|
-
const indexes = await client.query(`SELECT index_class.relname AS index_name
|
|
1161
|
+
const indexes = await client.query(`SELECT index_class.relname AS index_name,
|
|
1162
|
+
index_meta.indisunique AS is_unique,
|
|
1163
|
+
index_meta.indpred IS NOT NULL AS is_partial,
|
|
1164
|
+
ARRAY(
|
|
1165
|
+
SELECT attribute.attname::text
|
|
1166
|
+
FROM unnest(index_meta.indkey::int2[])
|
|
1167
|
+
WITH ORDINALITY AS key(attnum, ord)
|
|
1168
|
+
LEFT JOIN pg_catalog.pg_attribute AS attribute
|
|
1169
|
+
ON attribute.attrelid = table_class.oid
|
|
1170
|
+
AND attribute.attnum = key.attnum
|
|
1171
|
+
WHERE key.ord <= index_meta.indnkeyatts
|
|
1172
|
+
ORDER BY key.ord
|
|
1173
|
+
) AS columns
|
|
1017
1174
|
FROM pg_catalog.pg_class AS table_class
|
|
1018
1175
|
JOIN pg_catalog.pg_namespace AS namespace
|
|
1019
1176
|
ON namespace.oid = table_class.relnamespace
|
|
@@ -1029,16 +1186,21 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1029
1186
|
FROM pg_catalog.pg_constraint AS owning_constraint
|
|
1030
1187
|
WHERE owning_constraint.conindid = index_meta.indexrelid
|
|
1031
1188
|
)`, [table.name]);
|
|
1032
|
-
existingIndexes.set(table.name, new
|
|
1189
|
+
existingIndexes.set(table.name, new Map(indexes.rows.map((index) => [
|
|
1190
|
+
index.index_name,
|
|
1191
|
+
indexSignature(index.columns, index.is_unique, index.is_partial),
|
|
1192
|
+
])));
|
|
1033
1193
|
}
|
|
1034
1194
|
}
|
|
1195
|
+
if (retiredTables.length > 0) {
|
|
1196
|
+
// Neither index leads with `tbl`: one scan per table, not one per
|
|
1197
|
+
// retired table.
|
|
1198
|
+
await client.query(`DELETE FROM sync_row_scopes
|
|
1199
|
+
WHERE tbl IN (SELECT jsonb_array_elements_text($1::text::jsonb))`, [JSON.stringify(retiredTables)]);
|
|
1200
|
+
await client.query(`DELETE FROM sync_blob_refs
|
|
1201
|
+
WHERE tbl IN (SELECT jsonb_array_elements_text($1::text::jsonb))`, [JSON.stringify(retiredTables)]);
|
|
1202
|
+
}
|
|
1035
1203
|
for (const tableName of retiredTables) {
|
|
1036
|
-
await client.query('DELETE FROM sync_row_scopes WHERE tbl=$1', [
|
|
1037
|
-
tableName,
|
|
1038
|
-
]);
|
|
1039
|
-
await client.query('DELETE FROM sync_blob_refs WHERE tbl=$1', [
|
|
1040
|
-
tableName,
|
|
1041
|
-
]);
|
|
1042
1204
|
await client.query(dropTableDdl(tableName));
|
|
1043
1205
|
}
|
|
1044
1206
|
for (const statement of schemaDdl(schema, existing, 'postgres', existingIndexes)) {
|
|
@@ -1081,13 +1243,18 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1081
1243
|
if (initialLogEpoch.length === 0) {
|
|
1082
1244
|
throw new Error('initial log epoch must be non-empty');
|
|
1083
1245
|
}
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1246
|
+
let row = (await this.#exec.query(`SELECT log_epoch, epoch_required, last_authenticated_at_ms
|
|
1247
|
+
FROM sync_partition_registry WHERE partition=$1`, [partition])).rows[0];
|
|
1248
|
+
if (row === undefined ||
|
|
1249
|
+
authenticatedAtMs - asNumber(row.last_authenticated_at_ms) >=
|
|
1250
|
+
PARTITION_TOUCH_INTERVAL_MS) {
|
|
1251
|
+
row = (await this.#exec.query(`INSERT INTO sync_partition_registry(
|
|
1252
|
+
partition, log_epoch, last_authenticated_at_ms
|
|
1253
|
+
) VALUES ($1,$2,$3)
|
|
1254
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
1255
|
+
last_authenticated_at_ms=EXCLUDED.last_authenticated_at_ms
|
|
1256
|
+
RETURNING log_epoch, epoch_required, last_authenticated_at_ms`, [partition, initialLogEpoch, authenticatedAtMs])).rows[0];
|
|
1257
|
+
}
|
|
1091
1258
|
if (row === undefined)
|
|
1092
1259
|
throw new Error('partition registry write did not persist');
|
|
1093
1260
|
return {
|
|
@@ -1476,7 +1643,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1476
1643
|
if (firstVariable === undefined || values.length === 0)
|
|
1477
1644
|
return 0;
|
|
1478
1645
|
// Concurrent reads of a pull share one statement across tables.
|
|
1479
|
-
const [row] = await this.#batchedPage(LATEST_SCOPED_CHANGE_BATCH_SQL, partition, { tbl: query.table, var: firstVariable, vals: values });
|
|
1646
|
+
const [row] = await this.#batchedPage('latestScopedChange', LATEST_SCOPED_CHANGE_BATCH_SQL, partition, { tbl: query.table, var: firstVariable, vals: values });
|
|
1480
1647
|
return row === undefined ? 0 : asNumber(row.seq);
|
|
1481
1648
|
}
|
|
1482
1649
|
async readCommitWindow(partition, query) {
|
|
@@ -1499,7 +1666,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1499
1666
|
let afterSeq = query.afterSeq;
|
|
1500
1667
|
const batchSize = Math.max(64, query.limitChanges);
|
|
1501
1668
|
while (deliveredChanges < query.limitChanges) {
|
|
1502
|
-
const records = await this.#batchedPage(COMMIT_WINDOW_BATCH_SQL, partition, {
|
|
1669
|
+
const records = await this.#batchedPage('commitWindow', COMMIT_WINDOW_BATCH_SQL, partition, {
|
|
1503
1670
|
tbl: query.table,
|
|
1504
1671
|
var: firstVariable,
|
|
1505
1672
|
vals: firstValues,
|
|
@@ -1572,15 +1739,14 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1572
1739
|
return [];
|
|
1573
1740
|
// Candidates via the inverted index (ordered + LIMITed at the covering
|
|
1574
1741
|
// PK) LEFT JOINed to the row table — one round trip per page, never one
|
|
1575
|
-
// per row, and
|
|
1576
|
-
//
|
|
1577
|
-
//
|
|
1578
|
-
const sql = scanRowBatchSql(this.table(query.table));
|
|
1742
|
+
// per row, and concurrent scans across tables share one statement (see
|
|
1743
|
+
// `scanRowBatchSql`). Exact multi-variable verification against the
|
|
1744
|
+
// stored scope map happens below.
|
|
1579
1745
|
const rows = [];
|
|
1580
1746
|
let afterRowId = query.afterRowId ?? '';
|
|
1581
1747
|
const batchSize = Math.max(64, query.limit);
|
|
1582
1748
|
while (rows.length < query.limit) {
|
|
1583
|
-
const records = await this.#batchedPage(
|
|
1749
|
+
const records = await this.#batchedPage('scanRows', (specs) => scanRowBatchSql([...new Set(specs.map((spec) => String(spec.tbl)))].map((table) => this.table(table))), partition, {
|
|
1584
1750
|
tbl: query.table,
|
|
1585
1751
|
var: firstVariable,
|
|
1586
1752
|
vals: firstValues,
|
package/dist/pull.d.ts
CHANGED
|
@@ -42,8 +42,8 @@ export interface PullSectionTrace {
|
|
|
42
42
|
/**
|
|
43
43
|
* The storage reads a section needs before its first frame. A pull starts
|
|
44
44
|
* them for every subscription before awaiting any, so a storage that batches
|
|
45
|
-
* concurrent page reads (PostgreSQL, D1) answers
|
|
46
|
-
*
|
|
45
|
+
* concurrent page reads (PostgreSQL, D1) answers the first pages of every
|
|
46
|
+
* subscription in one statement, and the horizon re-check runs once per pull.
|
|
47
47
|
*/
|
|
48
48
|
export interface PreparedSection {
|
|
49
49
|
/** §4.5 window read for an incremental cursor; empty when none was read. */
|
|
@@ -57,7 +57,11 @@ export interface PreparedSection {
|
|
|
57
57
|
readonly firstPage: StoredRow[] | undefined;
|
|
58
58
|
/** §4.7 pin of a fresh bootstrap; absent ⇒ `maxCommitSeq`. */
|
|
59
59
|
readonly pin: number | undefined;
|
|
60
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* §5.3 stored image for a sqlite-lane bootstrap's reuse key. Read only when
|
|
62
|
+
* the eligibility probe shows a snapshot larger than one rows page, since a
|
|
63
|
+
* smaller table can never match a stored image.
|
|
64
|
+
*/
|
|
61
65
|
readonly image: SegmentRecord | undefined;
|
|
62
66
|
}
|
|
63
67
|
/** Run the pre-frame storage reads of every subscription concurrently. */
|
package/dist/pull.js
CHANGED
|
@@ -375,18 +375,27 @@ export async function prepareSections(ctx, schema, limits, plans, maxSeq, horizo
|
|
|
375
375
|
const pin = Math.min(maxSeq, Math.max(latest, horizonSeq));
|
|
376
376
|
return { asOf: pin, pin };
|
|
377
377
|
});
|
|
378
|
-
const lane = pinned.then(async ({ asOf, pin }) =>
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
378
|
+
const lane = pinned.then(async ({ asOf, pin }) => {
|
|
379
|
+
// §5.3 eligibility: a stored image only matches a snapshot larger than
|
|
380
|
+
// one rows page, so the probe decides before the reuse lookup; a remote
|
|
381
|
+
// `find` on a small table would be a wasted round trip.
|
|
382
|
+
const probe = await firstPage;
|
|
383
|
+
if (probe.length <= limits.limitSnapshotRows) {
|
|
384
|
+
return { pin, image: undefined };
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
pin,
|
|
388
|
+
image: await ctx.segments.find({
|
|
389
|
+
partition: ctx.partition,
|
|
390
|
+
logEpoch,
|
|
391
|
+
table: plan.table.name,
|
|
392
|
+
schemaVersion: schema.version,
|
|
393
|
+
mediaType: 'sqlite',
|
|
394
|
+
scopeDigest: await scopeDigest(plan.effective),
|
|
395
|
+
asOfCommitSeq: asOf,
|
|
396
|
+
}, clockOf(ctx)()),
|
|
397
|
+
};
|
|
398
|
+
});
|
|
390
399
|
return { firstPage, lane };
|
|
391
400
|
});
|
|
392
401
|
const settled = await Promise.all(reads.map(async (read) => ({
|