@syncular/server 0.43.0 → 0.44.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/d1-storage.js +8 -1
- package/dist/postgres-storage.js +385 -119
- package/dist/push.d.ts +4 -1
- package/dist/push.js +47 -72
- package/dist/relational-rows.d.ts +9 -5
- package/dist/relational-rows.js +29 -11
- package/dist/sqlite-storage.js +8 -7
- package/dist/storage.d.ts +39 -32
- package/package.json +2 -2
- package/src/d1-storage.ts +18 -2
- package/src/postgres-storage.ts +552 -187
- package/src/push.ts +63 -88
- package/src/relational-rows.ts +31 -10
- package/src/sqlite-storage.ts +25 -16
- package/src/storage.ts +48 -36
package/dist/postgres-storage.js
CHANGED
|
@@ -5,7 +5,7 @@ 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, 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, serverIndexDdl, selectRowScopesSql, selectRowSql,
|
|
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, serverIndexDdl, selectRowScopesSql, selectRowSql, prefetchRowsBatchSql, 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';
|
|
@@ -225,16 +225,17 @@ async function readIndexSignatures(client, tableNames) {
|
|
|
225
225
|
const { rows } = await client.query(`SELECT ${indexesSubquery('$1')} AS indexes`, [JSON.stringify(tableNames)]);
|
|
226
226
|
return parseIndexSignatures(rows[0]?.indexes ?? '[]');
|
|
227
227
|
}
|
|
228
|
-
/**
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
228
|
+
/**
|
|
229
|
+
* The serve-gate projection shared by every read: the entry/verify gate and
|
|
230
|
+
* the push admission statement select exactly these columns. Params:
|
|
231
|
+
* [partition, running schema version].
|
|
232
|
+
*/
|
|
233
|
+
const SERVE_GATE_PROJECTION = `(SELECT schema_version FROM sync_schema_meta WHERE id=1) AS schema_version,
|
|
233
234
|
(SELECT log_epoch FROM sync_partition_registry WHERE partition=$1) AS log_epoch,
|
|
234
235
|
(SELECT json_agg(json_build_object('partition', partition, 'name', name))
|
|
235
236
|
FROM sync_backfill_checkpoints
|
|
236
|
-
WHERE schema_version=$2 AND state<>'activated') AS pending
|
|
237
|
-
|
|
237
|
+
WHERE schema_version=$2 AND state<>'activated') AS pending`;
|
|
238
|
+
function toServeGate(row) {
|
|
238
239
|
return {
|
|
239
240
|
// asNumber maps the NULL of a missing marker row to 0.
|
|
240
241
|
storedSchemaVersion: asNumber(row?.schema_version),
|
|
@@ -244,6 +245,63 @@ async function readServeGateOn(client, partition, runningSchemaVersion) {
|
|
|
244
245
|
: asJson(row.pending),
|
|
245
246
|
};
|
|
246
247
|
}
|
|
248
|
+
/** RFC 0007 serve gate on either the pool or a transaction's client. */
|
|
249
|
+
async function readServeGateOn(client, partition, runningSchemaVersion) {
|
|
250
|
+
// One statement: every request reads the gate twice (entry and verify),
|
|
251
|
+
// so its parts share one round trip and one snapshot.
|
|
252
|
+
const { rows } = await client.query(`SELECT ${SERVE_GATE_PROJECTION}`, [partition, runningSchemaVersion]);
|
|
253
|
+
return toServeGate(rows[0]);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* The push admission read: the RFC 0007 serve gate, the §2.3 idempotency
|
|
257
|
+
* record, and (when the caller already knows the commit's row targets) the
|
|
258
|
+
* read-ahead rows, in one statement with one snapshot. The caller takes the
|
|
259
|
+
* partition lock first: under READ COMMITTED a statement's snapshot is fixed
|
|
260
|
+
* at its start, so the read must be the statement *after* the lock to observe
|
|
261
|
+
* a duplicate that committed while this transaction waited for it.
|
|
262
|
+
*/
|
|
263
|
+
async function readAdmissionOn(client, partition, clientId, clientCommitId, runningSchemaVersion, prefetch) {
|
|
264
|
+
const params = [
|
|
265
|
+
partition,
|
|
266
|
+
runningSchemaVersion,
|
|
267
|
+
clientId,
|
|
268
|
+
clientCommitId,
|
|
269
|
+
];
|
|
270
|
+
let statement = `SELECT ${SERVE_GATE_PROJECTION},
|
|
271
|
+
(SELECT result FROM sync_push_results
|
|
272
|
+
WHERE partition=$1 AND client_id=$3 AND client_commit_id=$4) AS result`;
|
|
273
|
+
if (prefetch !== undefined && prefetch.specs.length > 0) {
|
|
274
|
+
params.push(JSON.stringify(prefetch.specs));
|
|
275
|
+
// The gate row pads the read-ahead columns with typed NULLs so both
|
|
276
|
+
// branches share one result shape; `tbl` is NULL only for the gate row.
|
|
277
|
+
statement += `,
|
|
278
|
+
NULL::text AS tbl, NULL::text AS lookup_id, NULL::text AS row_id,
|
|
279
|
+
NULL::bigint AS server_version, NULL::jsonb AS scopes,
|
|
280
|
+
NULL::bytea AS payload, NULL::bytea AS column_versions,
|
|
281
|
+
NULL::bigint AS tombstone_seq
|
|
282
|
+
UNION ALL
|
|
283
|
+
SELECT NULL::bigint, NULL::text, NULL::json, NULL::jsonb,
|
|
284
|
+
p.tbl, p.lookup_id, p.row_id, p.server_version, p.scopes,
|
|
285
|
+
p.payload, p.column_versions, p.tombstone_seq
|
|
286
|
+
FROM (${prefetchRowsBatchSql(prefetch.tables, {
|
|
287
|
+
partition: '$1',
|
|
288
|
+
specs: `$${params.length}`,
|
|
289
|
+
})}) p`;
|
|
290
|
+
}
|
|
291
|
+
const { rows } = await client.query(statement, params);
|
|
292
|
+
const gateRow = rows.find((row) => row.tbl === null) ?? rows[0];
|
|
293
|
+
return {
|
|
294
|
+
gate: toServeGate(gateRow),
|
|
295
|
+
persisted: toPersistedPushResult(gateRow?.result),
|
|
296
|
+
prefetched: rows.filter((row) => row.tbl !== null && row.tbl !== undefined),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Insert the `declared` row and raise the fence, on the caller's open
|
|
301
|
+
* transaction. Shared by `declareCheckpoint` and the `ensureSchema`
|
|
302
|
+
* migration so the schema bump and the fence commit together. Never lowers a
|
|
303
|
+
* fence and never re-declares an existing row.
|
|
304
|
+
*/
|
|
247
305
|
async function hasSourceChangesAboveOn(client, partition, tables, seq) {
|
|
248
306
|
// Read the horizon before the window scan: a horizon past `seq` means the
|
|
249
307
|
// history that would answer the question is gone, so an empty scan proves
|
|
@@ -488,24 +546,36 @@ async function scanRowsByIndexOn(q, compiled, partition, query) {
|
|
|
488
546
|
return rows.map(toStoredRow);
|
|
489
547
|
}
|
|
490
548
|
/**
|
|
491
|
-
* Write rows of one table in one statement per chunk: the upsert,
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
549
|
+
* Write rows of one table in one statement per chunk: the upsert, the scope
|
|
550
|
+
* index synced to each row's scope map (entries outside it deleted, missing
|
|
551
|
+
* ones inserted), and the §5.9.4 blob-reference index synced to the ids the
|
|
552
|
+
* row now references (entries outside the new set deleted, missing ones
|
|
553
|
+
* inserted). Row ids are distinct, so no index entry is touched twice and the
|
|
554
|
+
* writable CTEs cannot conflict. The scopes and blob ids bind as text before
|
|
555
|
+
* the JSONB cast (see `appendCommit`); the refs set is part of the same
|
|
556
|
+
* statement, so a blob-referencing commit costs no extra round trip per row.
|
|
557
|
+
* Every chunk is handed to `q` before the first one settles, so a statement
|
|
558
|
+
* issued after this call queues behind all of them on the transaction FIFO.
|
|
498
559
|
*/
|
|
499
|
-
function writeRowsOn(q, compiled, partition, rows) {
|
|
560
|
+
function writeRowsOn(q, compiled, partition, rows, blobRefs = []) {
|
|
500
561
|
const width = tableColumnNames(compiled).length;
|
|
501
562
|
// PGlite corrupts its state above 32,767 bound parameters (see
|
|
502
563
|
// PG_CHANGES_PER_STATEMENT).
|
|
503
564
|
const chunk = Math.max(1, Math.floor(30_000 / width));
|
|
565
|
+
const pendingRefs = new Map(blobRefs.map((entry) => [entry.rowId, entry.blobIds]));
|
|
504
566
|
const writes = [];
|
|
505
567
|
for (let first = 0; first < rows.length; first += chunk) {
|
|
506
568
|
const slice = rows.slice(first, first + chunk);
|
|
507
569
|
const params = slice.flatMap((entry) => entry.values);
|
|
508
570
|
const n = params.length;
|
|
571
|
+
const refs = [];
|
|
572
|
+
for (const entry of slice) {
|
|
573
|
+
const blobIds = pendingRefs.get(entry.row.rowId);
|
|
574
|
+
if (blobIds === undefined)
|
|
575
|
+
continue;
|
|
576
|
+
pendingRefs.delete(entry.row.rowId);
|
|
577
|
+
refs.push({ row_id: entry.row.rowId, blob_ids: blobIds });
|
|
578
|
+
}
|
|
509
579
|
writes.push(q.query(`WITH upserted AS (${upsertSql(compiled, 'postgres', slice.length)}),
|
|
510
580
|
batch AS (
|
|
511
581
|
SELECT row_id, scopes
|
|
@@ -521,6 +591,28 @@ function writeRowsOn(q, compiled, partition, rows) {
|
|
|
521
591
|
WHERE existing.partition=$${n + 2} AND existing.tbl=$${n + 3}
|
|
522
592
|
AND existing.row_id=batch.row_id
|
|
523
593
|
AND batch.scopes->>existing.var IS DISTINCT FROM existing.value
|
|
594
|
+
),
|
|
595
|
+
changed_refs AS (
|
|
596
|
+
SELECT DISTINCT row_id
|
|
597
|
+
FROM jsonb_to_recordset($${n + 4}::text::jsonb) AS r(row_id text, blob_ids jsonb)
|
|
598
|
+
),
|
|
599
|
+
removed_refs AS (
|
|
600
|
+
DELETE FROM sync_blob_refs existing
|
|
601
|
+
USING changed_refs
|
|
602
|
+
WHERE existing.partition=$${n + 2} AND existing.tbl=$${n + 3}
|
|
603
|
+
AND existing.row_id=changed_refs.row_id
|
|
604
|
+
AND NOT EXISTS (
|
|
605
|
+
SELECT 1
|
|
606
|
+
FROM jsonb_to_recordset($${n + 4}::text::jsonb) AS r(row_id text, blob_ids jsonb)
|
|
607
|
+
CROSS JOIN LATERAL jsonb_array_elements_text(r.blob_ids) AS entry(value)
|
|
608
|
+
WHERE r.row_id=existing.row_id AND entry.value=existing.blob_id)
|
|
609
|
+
),
|
|
610
|
+
inserted_refs AS (
|
|
611
|
+
INSERT INTO sync_blob_refs(partition, tbl, row_id, blob_id)
|
|
612
|
+
SELECT $${n + 2}, $${n + 3}, r.row_id, entry.value
|
|
613
|
+
FROM jsonb_to_recordset($${n + 4}::text::jsonb) AS r(row_id text, blob_ids jsonb)
|
|
614
|
+
CROSS JOIN LATERAL jsonb_array_elements_text(r.blob_ids) AS entry(value)
|
|
615
|
+
ON CONFLICT DO NOTHING
|
|
524
616
|
)
|
|
525
617
|
INSERT INTO sync_row_scopes(partition, tbl, var, value, row_id)
|
|
526
618
|
SELECT $${n + 2}, $${n + 3}, key, value, row_id FROM scope
|
|
@@ -529,16 +621,52 @@ function writeRowsOn(q, compiled, partition, rows) {
|
|
|
529
621
|
JSON.stringify(slice.map(({ row }) => ({ row_id: row.rowId, scopes: row.scopes }))),
|
|
530
622
|
partition,
|
|
531
623
|
compiled.name,
|
|
624
|
+
JSON.stringify(refs),
|
|
625
|
+
]));
|
|
626
|
+
}
|
|
627
|
+
if (pendingRefs.size > 0) {
|
|
628
|
+
// Refs staged for a row this call did not carry (the row was written by an
|
|
629
|
+
// earlier flush): replace the reference set in one statement of its own.
|
|
630
|
+
writes.push(q.query(`WITH changed_refs AS (
|
|
631
|
+
SELECT DISTINCT row_id
|
|
632
|
+
FROM jsonb_to_recordset($3::text::jsonb) AS r(row_id text, blob_ids jsonb)
|
|
633
|
+
),
|
|
634
|
+
removed_refs AS (
|
|
635
|
+
DELETE FROM sync_blob_refs existing
|
|
636
|
+
USING changed_refs
|
|
637
|
+
WHERE existing.partition=$1 AND existing.tbl=$2
|
|
638
|
+
AND existing.row_id=changed_refs.row_id
|
|
639
|
+
AND NOT EXISTS (
|
|
640
|
+
SELECT 1
|
|
641
|
+
FROM jsonb_to_recordset($3::text::jsonb) AS r(row_id text, blob_ids jsonb)
|
|
642
|
+
CROSS JOIN LATERAL jsonb_array_elements_text(r.blob_ids) AS entry(value)
|
|
643
|
+
WHERE r.row_id=existing.row_id AND entry.value=existing.blob_id)
|
|
644
|
+
)
|
|
645
|
+
INSERT INTO sync_blob_refs(partition, tbl, row_id, blob_id)
|
|
646
|
+
SELECT $1, $2, r.row_id, entry.value
|
|
647
|
+
FROM jsonb_to_recordset($3::text::jsonb) AS r(row_id text, blob_ids jsonb)
|
|
648
|
+
CROSS JOIN LATERAL jsonb_array_elements_text(r.blob_ids) AS entry(value)
|
|
649
|
+
ON CONFLICT DO NOTHING`, [
|
|
650
|
+
partition,
|
|
651
|
+
compiled.name,
|
|
652
|
+
JSON.stringify([...pendingRefs].map(([row_id, blob_ids]) => ({
|
|
653
|
+
row_id,
|
|
654
|
+
blob_ids,
|
|
655
|
+
}))),
|
|
532
656
|
]));
|
|
533
657
|
}
|
|
534
658
|
return Promise.all(writes);
|
|
535
659
|
}
|
|
536
660
|
async function getPushResultOn(q, partition, clientId, clientCommitId) {
|
|
537
661
|
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]);
|
|
538
|
-
|
|
662
|
+
return toPersistedPushResult(rows[0]?.result);
|
|
663
|
+
}
|
|
664
|
+
/** Decode a persisted §2.3 outcome; an unreadable record is a cache miss. */
|
|
665
|
+
function toPersistedPushResult(value) {
|
|
666
|
+
if (value === null || value === undefined)
|
|
539
667
|
return undefined;
|
|
540
668
|
try {
|
|
541
|
-
return deserializePushResult(asJson(
|
|
669
|
+
return deserializePushResult(asJson(value));
|
|
542
670
|
}
|
|
543
671
|
catch {
|
|
544
672
|
throw syncError('sync.idempotency_cache_miss', 'persisted push result unreadable (§6.3)');
|
|
@@ -583,6 +711,20 @@ class PostgresTransaction {
|
|
|
583
711
|
* operation.
|
|
584
712
|
*/
|
|
585
713
|
#staged = new Map();
|
|
714
|
+
/**
|
|
715
|
+
* §5.9.4 reference sets staged per table (row id → blob ids). They flush
|
|
716
|
+
* with the table's row statement, so a blob-referencing row costs no extra
|
|
717
|
+
* round trip; a ref set staged for an already-written row flushes on the
|
|
718
|
+
* next statement of its table.
|
|
719
|
+
*/
|
|
720
|
+
#stagedBlobRefs = new Map();
|
|
721
|
+
/**
|
|
722
|
+
* The partition cursor (`max_commit_seq`) read by the first registered
|
|
723
|
+
* query in this transaction. The partition lock freezes it, so later
|
|
724
|
+
* registered queries in the same transaction reuse the value instead of
|
|
725
|
+
* paying a round trip each.
|
|
726
|
+
*/
|
|
727
|
+
#cursorSeq;
|
|
586
728
|
/** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
|
|
587
729
|
#resolve;
|
|
588
730
|
#reject;
|
|
@@ -631,21 +773,53 @@ class PostgresTransaction {
|
|
|
631
773
|
throw new Error('transaction already finished');
|
|
632
774
|
}
|
|
633
775
|
#writeStaged() {
|
|
634
|
-
if (this.#staged.size === 0)
|
|
776
|
+
if (this.#staged.size === 0 && this.#stagedBlobRefs.size === 0) {
|
|
635
777
|
return Promise.resolve();
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
778
|
+
}
|
|
779
|
+
const tables = new Set([
|
|
780
|
+
...[...this.#staged.keys()].map((table) => table.name),
|
|
781
|
+
...this.#stagedBlobRefs.keys(),
|
|
782
|
+
]);
|
|
783
|
+
const batches = [...tables].map((table) => {
|
|
784
|
+
const compiled = this.#resolveTable(table);
|
|
785
|
+
const rows = [...(this.#staged.get(compiled)?.values() ?? [])];
|
|
786
|
+
const blobRefs = [
|
|
787
|
+
...(this.#stagedBlobRefs.get(table)?.entries() ?? []),
|
|
788
|
+
].map(([rowId, blobIds]) => ({ rowId, blobIds }));
|
|
789
|
+
this.#staged.delete(compiled);
|
|
790
|
+
this.#stagedBlobRefs.delete(table);
|
|
791
|
+
return { table, compiled, rows, blobRefs };
|
|
792
|
+
});
|
|
793
|
+
const writes = [];
|
|
794
|
+
// The candidate savepoint lands before the first candidate write, and
|
|
795
|
+
// only when a write actually reaches the database: a commit refused
|
|
796
|
+
// before its first write has no candidate rows to roll back, so its
|
|
797
|
+
// rejection finalization skips the savepoint round trips.
|
|
798
|
+
const savepoint = this.#ensurePushSavepoint();
|
|
799
|
+
if (savepoint !== undefined)
|
|
800
|
+
writes.push(savepoint);
|
|
801
|
+
for (const { compiled, rows, blobRefs } of batches) {
|
|
802
|
+
writes.push(writeRowsOn(this.#serial, compiled, this.#partition, rows, blobRefs).catch((error) => {
|
|
641
803
|
// Staged tables have no unique index, so a violation is a column
|
|
642
804
|
// constraint; it names the first staged operation of the table.
|
|
643
805
|
if (isPostgresConstraintError(error)) {
|
|
644
|
-
throw new StorageConstraintError(error,
|
|
806
|
+
throw new StorageConstraintError(error, rows[0]?.opIndex);
|
|
645
807
|
}
|
|
646
808
|
throw error;
|
|
647
|
-
});
|
|
648
|
-
}
|
|
809
|
+
}));
|
|
810
|
+
}
|
|
811
|
+
return Promise.all(writes);
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* The candidate savepoint, taken before the transaction's first write.
|
|
815
|
+
* Every candidate write must be undoable by `commitRejectedPushResult`, and
|
|
816
|
+
* a refused commit that wrote nothing must not pay to create one.
|
|
817
|
+
*/
|
|
818
|
+
#ensurePushSavepoint() {
|
|
819
|
+
if (this.#pushApplySavepoint)
|
|
820
|
+
return undefined;
|
|
821
|
+
this.#pushApplySavepoint = true;
|
|
822
|
+
return this.#serial.query('SAVEPOINT syncular_push_candidate');
|
|
649
823
|
}
|
|
650
824
|
getRow(table, rowId) {
|
|
651
825
|
this.#assertOpen();
|
|
@@ -655,19 +829,27 @@ class PostgresTransaction {
|
|
|
655
829
|
}
|
|
656
830
|
return getRowOn(this.#client, this.#resolveTable(table), this.#partition, rowId);
|
|
657
831
|
}
|
|
658
|
-
async prefetchRows(
|
|
832
|
+
async prefetchRows(targets) {
|
|
659
833
|
this.#assertOpen();
|
|
660
|
-
const
|
|
661
|
-
|
|
834
|
+
const tables = [];
|
|
835
|
+
const specs = [];
|
|
836
|
+
for (const target of targets) {
|
|
837
|
+
const ids = [...new Set(target.rowIds)].filter((rowId) => !this.#prefetchedRows.has(`${target.table}\u0000${rowId}`));
|
|
838
|
+
if (ids.length === 0)
|
|
839
|
+
continue;
|
|
840
|
+
tables.push(this.#resolveTable(target.table));
|
|
841
|
+
specs.push({ tbl: target.table, ids });
|
|
842
|
+
}
|
|
843
|
+
if (specs.length === 0)
|
|
662
844
|
return;
|
|
663
|
-
const { rows } = await this.#client.query(
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
for (const record of
|
|
669
|
-
const key = `${
|
|
670
|
-
this.#prefetchedRows.set(key, record.row_id === null
|
|
845
|
+
const { rows } = await this.#client.query(prefetchRowsBatchSql(tables), [this.#partition, JSON.stringify(specs)]);
|
|
846
|
+
this.#recordPrefetched(rows);
|
|
847
|
+
}
|
|
848
|
+
/** Answer the following `getRow`/`getTombstoneSeq` calls from a read snapshot. */
|
|
849
|
+
#recordPrefetched(records) {
|
|
850
|
+
for (const record of records) {
|
|
851
|
+
const key = `${record.tbl}\u0000${record.lookup_id}`;
|
|
852
|
+
this.#prefetchedRows.set(key, record.row_id === null || record.row_id === undefined
|
|
671
853
|
? null
|
|
672
854
|
: toStoredRow({ ...record, row_id: record.row_id }));
|
|
673
855
|
this.#prefetchedTombstones.set(key, record.tombstone_seq === null || record.tombstone_seq === undefined
|
|
@@ -687,15 +869,9 @@ class PostgresTransaction {
|
|
|
687
869
|
async clearTombstone(table, rowId) {
|
|
688
870
|
this.#assertOpen();
|
|
689
871
|
this.#prefetchedTombstones.delete(`${table}\u0000${rowId}`);
|
|
872
|
+
await this.#ensurePushSavepoint();
|
|
690
873
|
await this.#client.query('DELETE FROM sync_tombstones WHERE partition=$1 AND tbl=$2 AND row_id=$3', [this.#partition, table, rowId]);
|
|
691
874
|
}
|
|
692
|
-
getPushResult(clientId, clientCommitId) {
|
|
693
|
-
this.#assertOpen();
|
|
694
|
-
// Runs on this transaction's pinned client: the push layer's duplicate
|
|
695
|
-
// re-check happens while the partition lock is held, and a pool-level
|
|
696
|
-
// read there would wait for a second connection.
|
|
697
|
-
return getPushResultOn(this.#client, this.#partition, clientId, clientCommitId);
|
|
698
|
-
}
|
|
699
875
|
async scanRows(query) {
|
|
700
876
|
this.#assertOpen();
|
|
701
877
|
const firstVariable = assertScopeIndexedScan(query);
|
|
@@ -737,20 +913,49 @@ class PostgresTransaction {
|
|
|
737
913
|
this.#assertOpen();
|
|
738
914
|
return scanRowsByIndexOn(this.#client, this.#resolveTable(query.table), this.#partition, query);
|
|
739
915
|
}
|
|
740
|
-
queryAuthoritative(query) {
|
|
916
|
+
async queryAuthoritative(query) {
|
|
741
917
|
this.#assertOpen();
|
|
742
918
|
// Runs on this transaction's pinned client, so the read observes every
|
|
743
919
|
// staged row and never waits for a second pool connection.
|
|
744
|
-
|
|
920
|
+
const prepared = this.#prepareQuery(query);
|
|
921
|
+
const result = await this.#client.query(postgresPlaceholders(prepared.sql), prepared.params);
|
|
922
|
+
// The partition lock freezes `max_commit_seq` for the whole transaction,
|
|
923
|
+
// so one read answers every registered query in it.
|
|
924
|
+
if (this.#cursorSeq === undefined) {
|
|
925
|
+
const cursor = await this.#client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1', [this.#partition]);
|
|
926
|
+
this.#cursorSeq =
|
|
927
|
+
cursor.rows[0] === undefined
|
|
928
|
+
? 0
|
|
929
|
+
: asNumber(cursor.rows[0].max_commit_seq);
|
|
930
|
+
}
|
|
931
|
+
return { rows: result.rows, maxCommitSeq: this.#cursorSeq };
|
|
745
932
|
}
|
|
746
|
-
async
|
|
933
|
+
async preparePushApply(clientId, clientCommitId, runningSchemaVersion, targets = []) {
|
|
747
934
|
this.#assertOpen();
|
|
935
|
+
// The lock is its own statement: a READ COMMITTED statement's snapshot is
|
|
936
|
+
// fixed at its start, so the admission read must run *after* a duplicate's
|
|
937
|
+
// commit has released the partition lock to observe it. Folding the lock
|
|
938
|
+
// and the read into one statement would miss a duplicate that committed
|
|
939
|
+
// while this transaction waited.
|
|
748
940
|
await lockPartitionOn(this.#client, this.#partition);
|
|
749
|
-
|
|
750
|
-
|
|
941
|
+
const tables = [];
|
|
942
|
+
const specs = [];
|
|
943
|
+
for (const target of targets) {
|
|
944
|
+
const ids = [...new Set(target.rowIds)].filter((rowId) => !this.#prefetchedRows.has(`${target.table}\u0000${rowId}`));
|
|
945
|
+
if (ids.length === 0)
|
|
946
|
+
continue;
|
|
947
|
+
tables.push(this.#resolveTable(target.table));
|
|
948
|
+
specs.push({ tbl: target.table, ids });
|
|
949
|
+
}
|
|
950
|
+
// One statement: the serve gate, the idempotency record, and the commit's
|
|
951
|
+
// read-ahead (when the caller already knows its targets).
|
|
952
|
+
const admission = await readAdmissionOn(this.#client, this.#partition, clientId, clientCommitId, runningSchemaVersion, specs.length === 0 ? undefined : { tables, specs });
|
|
953
|
+
this.#recordPrefetched(admission.prefetched);
|
|
954
|
+
return { gate: admission.gate, persisted: admission.persisted };
|
|
751
955
|
}
|
|
752
956
|
async advanceCheckpoint(name, ownerEpoch, watermark, observedRows, nowMs) {
|
|
753
957
|
this.#assertOpen();
|
|
958
|
+
await this.#ensurePushSavepoint();
|
|
754
959
|
// `RETURNING`: the affected-row check must not depend on a driver's
|
|
755
960
|
// `rowCount` for a statement with no result rows. Bun.sql's canonical
|
|
756
961
|
// adapter derives `rowCount` from the returned rows, so a bare UPDATE
|
|
@@ -769,15 +974,40 @@ class PostgresTransaction {
|
|
|
769
974
|
}
|
|
770
975
|
async commitRejectedPushResult(clientId, clientCommitId, result) {
|
|
771
976
|
this.#assertOpen();
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
}
|
|
977
|
+
// Candidate rows that never reached the database are discarded with the
|
|
978
|
+
// staging map; a savepoint exists only when one did.
|
|
775
979
|
this.#staged.clear();
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
980
|
+
this.#stagedBlobRefs.clear();
|
|
981
|
+
if (this.#pushApplySavepoint) {
|
|
982
|
+
await this.#client.query('ROLLBACK TO SAVEPOINT syncular_push_candidate');
|
|
983
|
+
await this.#client.query('RELEASE SAVEPOINT syncular_push_candidate');
|
|
984
|
+
this.#pushApplySavepoint = false;
|
|
985
|
+
}
|
|
986
|
+
const canonical = await this.#insertPushResult(clientId, clientCommitId, result);
|
|
780
987
|
await this.commit();
|
|
988
|
+
return canonical;
|
|
989
|
+
}
|
|
990
|
+
/** Insert the §2.3 outcome and return the canonical persisted value. */
|
|
991
|
+
async #insertPushResult(clientId, clientCommitId, result) {
|
|
992
|
+
const { rows } = await this.#client.query(`INSERT INTO sync_push_results(partition, client_id, client_commit_id, result)
|
|
993
|
+
VALUES ($1,$2,$3,$4)
|
|
994
|
+
ON CONFLICT (partition, client_id, client_commit_id) DO NOTHING
|
|
995
|
+
RETURNING result`, [
|
|
996
|
+
this.#partition,
|
|
997
|
+
clientId,
|
|
998
|
+
clientCommitId,
|
|
999
|
+
JSON.stringify(serializePushResult(result)),
|
|
1000
|
+
]);
|
|
1001
|
+
if (rows[0] !== undefined) {
|
|
1002
|
+
return toPersistedPushResult(rows[0].result) ?? result;
|
|
1003
|
+
}
|
|
1004
|
+
// `ON CONFLICT DO NOTHING` means a previously committed outcome won; read
|
|
1005
|
+
// it rather than claiming this caller's candidate.
|
|
1006
|
+
const existing = await getPushResultOn(this.#client, this.#partition, clientId, clientCommitId);
|
|
1007
|
+
if (existing === undefined) {
|
|
1008
|
+
throw new Error('push result insert did not persist an outcome');
|
|
1009
|
+
}
|
|
1010
|
+
return existing;
|
|
781
1011
|
}
|
|
782
1012
|
async upsertRow(table, row, context) {
|
|
783
1013
|
this.#assertOpen();
|
|
@@ -800,10 +1030,16 @@ class PostgresTransaction {
|
|
|
800
1030
|
});
|
|
801
1031
|
return;
|
|
802
1032
|
}
|
|
1033
|
+
const refs = this.#stagedBlobRefs.get(table);
|
|
1034
|
+
const blobIds = refs?.get(row.rowId);
|
|
1035
|
+
if (blobIds !== undefined && refs !== undefined) {
|
|
1036
|
+
refs.delete(row.rowId);
|
|
1037
|
+
if (refs.size === 0)
|
|
1038
|
+
this.#stagedBlobRefs.delete(table);
|
|
1039
|
+
}
|
|
1040
|
+
await this.#ensurePushSavepoint();
|
|
803
1041
|
try {
|
|
804
|
-
await writeRowsOn(this.#client, compiled, this.#partition, [
|
|
805
|
-
{ row, values },
|
|
806
|
-
]);
|
|
1042
|
+
await writeRowsOn(this.#client, compiled, this.#partition, [{ row, values }], blobIds === undefined ? [] : [{ rowId: row.rowId, blobIds }]);
|
|
807
1043
|
}
|
|
808
1044
|
catch (error) {
|
|
809
1045
|
if (isPostgresConstraintError(error)) {
|
|
@@ -815,6 +1051,11 @@ class PostgresTransaction {
|
|
|
815
1051
|
async deleteRow(table, rowId) {
|
|
816
1052
|
this.#assertOpen();
|
|
817
1053
|
this.#prefetchedRows.delete(`${table}\u0000${rowId}`);
|
|
1054
|
+
const refs = this.#stagedBlobRefs.get(table);
|
|
1055
|
+
refs?.delete(rowId);
|
|
1056
|
+
if (refs?.size === 0)
|
|
1057
|
+
this.#stagedBlobRefs.delete(table);
|
|
1058
|
+
await this.#ensurePushSavepoint();
|
|
818
1059
|
// One statement: the row, its scope-index entries, and (§5.9.4: a
|
|
819
1060
|
// deleted row references no blobs) its blob references.
|
|
820
1061
|
await this.#client.query(`WITH deleted AS (${deleteRowSql(this.#resolveTable(table), 'postgres')}),
|
|
@@ -823,93 +1064,118 @@ class PostgresTransaction {
|
|
|
823
1064
|
)
|
|
824
1065
|
DELETE FROM sync_blob_refs WHERE partition=$1 AND tbl=$3 AND row_id=$2`, [this.#partition, rowId, table]);
|
|
825
1066
|
}
|
|
826
|
-
|
|
1067
|
+
setBlobRefs(table, rowId, blobIds) {
|
|
827
1068
|
this.#assertOpen();
|
|
828
|
-
//
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
1069
|
+
// Staged with the row: the reference set is replaced in the same
|
|
1070
|
+
// statement that writes the row (§5.9.4), inside the commit tx.
|
|
1071
|
+
let rows = this.#stagedBlobRefs.get(table);
|
|
1072
|
+
if (rows === undefined) {
|
|
1073
|
+
rows = new Map();
|
|
1074
|
+
this.#stagedBlobRefs.set(table, rows);
|
|
833
1075
|
}
|
|
1076
|
+
rows.set(rowId, blobIds);
|
|
1077
|
+
return Promise.resolve();
|
|
834
1078
|
}
|
|
835
1079
|
async appendCommit(commit) {
|
|
836
1080
|
this.#assertOpen();
|
|
837
1081
|
// The commit writes its delete tombstones below.
|
|
838
1082
|
this.#prefetchedTombstones.clear();
|
|
1083
|
+
// The append advances the partition cursor; a registered query that runs
|
|
1084
|
+
// after it must read the new value.
|
|
1085
|
+
this.#cursorSeq = undefined;
|
|
839
1086
|
const q = this.#client;
|
|
840
1087
|
const p = this.#partition;
|
|
841
1088
|
// Allocate the next dense commitSeq under a per-partition row lock: the
|
|
842
1089
|
// UPDATE … RETURNING serializes concurrent pushes to this partition and
|
|
843
1090
|
// never leaves a gap on rollback (see the file header).
|
|
844
|
-
const
|
|
845
|
-
INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 1)
|
|
1091
|
+
const allocate = `INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 1)
|
|
846
1092
|
ON CONFLICT (partition) DO UPDATE
|
|
847
1093
|
SET max_commit_seq = sync_partitions.max_commit_seq + 1
|
|
848
|
-
RETURNING max_commit_seq
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1094
|
+
RETURNING max_commit_seq`;
|
|
1095
|
+
const commitRow = `INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms, writer_version)
|
|
1096
|
+
SELECT $1, allocated.max_commit_seq, $2, $3, $4, $5, $6 FROM allocated
|
|
1097
|
+
RETURNING commit_seq`;
|
|
1098
|
+
const head = [
|
|
853
1099
|
p,
|
|
854
1100
|
commit.clientId,
|
|
855
1101
|
commit.clientCommitId,
|
|
856
1102
|
commit.actorId,
|
|
857
1103
|
commit.createdAtMs,
|
|
858
1104
|
this.#writerVersion,
|
|
859
|
-
]
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
return
|
|
1105
|
+
];
|
|
1106
|
+
if (commit.changes.length === 0) {
|
|
1107
|
+
const { rows } = await q.query(`WITH allocated AS (${allocate}) ${commitRow}`, head);
|
|
1108
|
+
return asNumber(rows[0]?.commit_seq);
|
|
1109
|
+
}
|
|
1110
|
+
// §2.2: a delete whose commit follows it with an upsert is a scope move.
|
|
1111
|
+
// The row is live, so the tombstone insert below skips it.
|
|
1112
|
+
const moved = JSON.stringify([...rowsLiveAfterDelete(commit.changes)].map((key) => {
|
|
1113
|
+
const at = key.indexOf('\u0000');
|
|
1114
|
+
return { tbl: key.slice(0, at), row_id: key.slice(at + 1) };
|
|
1115
|
+
}));
|
|
863
1116
|
// One writable-CTE chain per chunk of changes: the changes, their
|
|
864
1117
|
// inverted scope entries, and the §5 delete tombstones. A statement binds
|
|
865
1118
|
// at most a 16-bit count of parameters, so a chunk carries
|
|
866
|
-
// PG_CHANGES_PER_STATEMENT changes
|
|
867
|
-
// commit
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
// one row
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
const
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
return `($1,$2::bigint,$${base + 1}::int,$${base + 2},$${base + 3},$${base + 4}::smallint,$${base + 5}::bigint,$${base + 6}::text::jsonb,$${base + 7}::bytea)`;
|
|
880
|
-
});
|
|
881
|
-
await q.query(`WITH inserted AS (
|
|
882
|
-
INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
|
|
883
|
-
VALUES ${values.join(',')}
|
|
884
|
-
RETURNING partition, tbl, row_id, op, commit_seq, scopes
|
|
885
|
-
),
|
|
886
|
-
scoped AS (
|
|
1119
|
+
// PG_CHANGES_PER_STATEMENT changes. The first chunk folds the allocation,
|
|
1120
|
+
// the commit row, and the change-log write into one statement; later
|
|
1121
|
+
// chunks run under the same commitSeq with their global idx. Serialized
|
|
1122
|
+
// scopes bind as text before parsing JSONB; drivers that encode JSONB
|
|
1123
|
+
// parameters would otherwise store the string as a scalar. A commit can
|
|
1124
|
+
// delete one row twice, so a chunk's tombstone insert keeps one row per
|
|
1125
|
+
// key and a later chunk updates it to the same commit_seq.
|
|
1126
|
+
const valuesOf = (params, slice, firstIdx) => slice.map((change, offset) => {
|
|
1127
|
+
const base = params.length;
|
|
1128
|
+
params.push(firstIdx + offset, change.table, change.rowId, change.op === 'upsert' ? 1 : 2, change.rowVersion ?? null, JSON.stringify(change.scopes), change.payload ?? null);
|
|
1129
|
+
return `($${base + 1}::int,$${base + 2}::text,$${base + 3}::text,$${base + 4}::smallint,$${base + 5}::bigint,$${base + 6}::text::jsonb,$${base + 7}::bytea)`;
|
|
1130
|
+
});
|
|
1131
|
+
const scopesCte = `scoped AS (
|
|
887
1132
|
INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
|
|
888
1133
|
SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
|
|
889
1134
|
FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
|
|
890
1135
|
ON CONFLICT DO NOTHING
|
|
891
|
-
)
|
|
892
|
-
|
|
1136
|
+
)`;
|
|
1137
|
+
const tombstones = (movedParam) => `INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq)
|
|
893
1138
|
SELECT DISTINCT ON (tbl, row_id) partition, tbl, row_id, commit_seq
|
|
894
|
-
FROM inserted
|
|
1139
|
+
FROM inserted
|
|
1140
|
+
WHERE op = 2
|
|
1141
|
+
AND NOT EXISTS (
|
|
1142
|
+
SELECT 1
|
|
1143
|
+
FROM jsonb_to_recordset(${movedParam}::text::jsonb) AS moved(tbl text, row_id text)
|
|
1144
|
+
WHERE moved.tbl = inserted.tbl AND moved.row_id = inserted.row_id)
|
|
895
1145
|
ON CONFLICT (partition, tbl, row_id)
|
|
896
|
-
DO UPDATE SET commit_seq = excluded.commit_seq
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1146
|
+
DO UPDATE SET commit_seq = excluded.commit_seq`;
|
|
1147
|
+
const firstSlice = commit.changes.slice(0, PG_CHANGES_PER_STATEMENT);
|
|
1148
|
+
const params = [...head];
|
|
1149
|
+
const values = valuesOf(params, firstSlice, 0);
|
|
1150
|
+
params.push(moved);
|
|
1151
|
+
const movedParam = `$${params.length}`;
|
|
1152
|
+
const { rows } = await q.query(`WITH allocated AS (${allocate}),
|
|
1153
|
+
allocated_commit AS (${commitRow}),
|
|
1154
|
+
inserted AS (
|
|
1155
|
+
INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
|
|
1156
|
+
SELECT $1, allocated.max_commit_seq, v.idx, v.tbl, v.row_id, v.op, v.row_version, v.scopes, v.payload
|
|
1157
|
+
FROM allocated
|
|
1158
|
+
CROSS JOIN (VALUES ${values.join(',')}) AS v(idx, tbl, row_id, op, row_version, scopes, payload)
|
|
1159
|
+
RETURNING partition, tbl, row_id, op, commit_seq, scopes
|
|
1160
|
+
),
|
|
1161
|
+
${scopesCte},
|
|
1162
|
+
tombstoned AS (${tombstones(movedParam)})
|
|
1163
|
+
SELECT max_commit_seq FROM allocated`, params);
|
|
1164
|
+
const commitSeq = asNumber(rows[0]?.max_commit_seq);
|
|
1165
|
+
for (let first = PG_CHANGES_PER_STATEMENT; first < commit.changes.length; first += PG_CHANGES_PER_STATEMENT) {
|
|
1166
|
+
const chunkParams = [p, commitSeq];
|
|
1167
|
+
const chunkValues = valuesOf(chunkParams, commit.changes.slice(first, first + PG_CHANGES_PER_STATEMENT), first);
|
|
1168
|
+
chunkParams.push(moved);
|
|
1169
|
+
const chunkMovedParam = `$${chunkParams.length}`;
|
|
1170
|
+
await q.query(`WITH inserted AS (
|
|
1171
|
+
INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
|
|
1172
|
+
SELECT $1, $2, v.idx, v.tbl, v.row_id, v.op, v.row_version, v.scopes, v.payload
|
|
1173
|
+
FROM (VALUES ${chunkValues.join(',')}) AS v(idx, tbl, row_id, op, row_version, scopes, payload)
|
|
1174
|
+
RETURNING partition, tbl, row_id, op, commit_seq, scopes
|
|
1175
|
+
),
|
|
1176
|
+
${scopesCte},
|
|
1177
|
+
tombstoned AS (${tombstones(chunkMovedParam)})
|
|
1178
|
+
SELECT 1`, chunkParams);
|
|
913
1179
|
}
|
|
914
1180
|
return commitSeq;
|
|
915
1181
|
}
|
|
@@ -1484,7 +1750,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1484
1750
|
async activateCheckpoint(partition, name, ownerEpoch, watermark, sources, nowMs) {
|
|
1485
1751
|
return this.#exec.transaction(async (client) => {
|
|
1486
1752
|
// The same `SELECT … FOR UPDATE` partition row lock that
|
|
1487
|
-
// `
|
|
1753
|
+
// `preparePushApply` takes; a bare `BEGIN` would race a push.
|
|
1488
1754
|
await lockPartitionOn(client, partition);
|
|
1489
1755
|
const fence = await client.query('SELECT required_writer_version FROM sync_writer_fence WHERE partition=$1', [partition]);
|
|
1490
1756
|
const checkpoint = await client.query('SELECT schema_version FROM sync_backfill_checkpoints WHERE partition=$1 AND name=$2', [partition, name]);
|