@syncular/server 0.43.0 → 0.45.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/src/postgres-storage.ts
CHANGED
|
@@ -92,7 +92,7 @@ import {
|
|
|
92
92
|
serverIndexDdl,
|
|
93
93
|
selectRowScopesSql,
|
|
94
94
|
selectRowSql,
|
|
95
|
-
|
|
95
|
+
prefetchRowsBatchSql,
|
|
96
96
|
selectRowsForRewriteSql,
|
|
97
97
|
upsertSql,
|
|
98
98
|
upsertValues,
|
|
@@ -115,9 +115,11 @@ import type {
|
|
|
115
115
|
CommitWindowQuery,
|
|
116
116
|
DurableJsonValue,
|
|
117
117
|
IndexRowScanQuery,
|
|
118
|
+
NewChange,
|
|
118
119
|
NewCommit,
|
|
119
120
|
NewReaction,
|
|
120
121
|
PartitionRegistryEntry,
|
|
122
|
+
PreparedPushApply,
|
|
121
123
|
PrunedReactionCounts,
|
|
122
124
|
ReactionClaimQuery,
|
|
123
125
|
ReactionFailure,
|
|
@@ -422,27 +424,25 @@ async function readIndexSignatures(
|
|
|
422
424
|
return parseIndexSignatures(rows[0]?.indexes ?? '[]');
|
|
423
425
|
}
|
|
424
426
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
`SELECT (SELECT schema_version FROM sync_schema_meta WHERE id=1) AS schema_version,
|
|
427
|
+
interface ServeGateRow {
|
|
428
|
+
schema_version: unknown;
|
|
429
|
+
log_epoch: string | null;
|
|
430
|
+
pending: unknown;
|
|
431
|
+
result?: unknown;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The serve-gate projection shared by every read: the entry/verify gate and
|
|
436
|
+
* the push admission statement select exactly these columns. Params:
|
|
437
|
+
* [partition, running schema version].
|
|
438
|
+
*/
|
|
439
|
+
const SERVE_GATE_PROJECTION = `(SELECT schema_version FROM sync_schema_meta WHERE id=1) AS schema_version,
|
|
439
440
|
(SELECT log_epoch FROM sync_partition_registry WHERE partition=$1) AS log_epoch,
|
|
440
441
|
(SELECT json_agg(json_build_object('partition', partition, 'name', name))
|
|
441
442
|
FROM sync_backfill_checkpoints
|
|
442
|
-
WHERE schema_version=$2 AND state<>'activated') AS pending
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
const row = rows[0];
|
|
443
|
+
WHERE schema_version=$2 AND state<>'activated') AS pending`;
|
|
444
|
+
|
|
445
|
+
function toServeGate(row: ServeGateRow | undefined): ServeGate {
|
|
446
446
|
return {
|
|
447
447
|
// asNumber maps the NULL of a missing marker row to 0.
|
|
448
448
|
storedSchemaVersion: asNumber(row?.schema_version),
|
|
@@ -454,6 +454,108 @@ async function readServeGateOn(
|
|
|
454
454
|
};
|
|
455
455
|
}
|
|
456
456
|
|
|
457
|
+
/** RFC 0007 serve gate on either the pool or a transaction's client. */
|
|
458
|
+
async function readServeGateOn(
|
|
459
|
+
client: PgQueryable,
|
|
460
|
+
partition: string,
|
|
461
|
+
runningSchemaVersion: number,
|
|
462
|
+
): Promise<ServeGate> {
|
|
463
|
+
// One statement: every request reads the gate twice (entry and verify),
|
|
464
|
+
// so its parts share one round trip and one snapshot.
|
|
465
|
+
const { rows } = await client.query<ServeGateRow>(
|
|
466
|
+
`SELECT ${SERVE_GATE_PROJECTION}`,
|
|
467
|
+
[partition, runningSchemaVersion],
|
|
468
|
+
);
|
|
469
|
+
return toServeGate(rows[0]);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** One read-ahead row of the admission statement (the gate row has no `tbl`). */
|
|
473
|
+
interface PrefetchRow {
|
|
474
|
+
readonly tbl: string;
|
|
475
|
+
readonly lookup_id: string;
|
|
476
|
+
readonly row_id: string | null;
|
|
477
|
+
readonly server_version: unknown;
|
|
478
|
+
readonly scopes: unknown;
|
|
479
|
+
readonly payload: unknown;
|
|
480
|
+
readonly column_versions: unknown;
|
|
481
|
+
readonly tombstone_seq: unknown;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
type AdmissionRow = ServeGateRow & Partial<PrefetchRow>;
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* The push admission read: the RFC 0007 serve gate, the §2.3 idempotency
|
|
488
|
+
* record, and (when the caller already knows the commit's row targets) the
|
|
489
|
+
* read-ahead rows, in one statement with one snapshot. The caller takes the
|
|
490
|
+
* partition lock first: under READ COMMITTED a statement's snapshot is fixed
|
|
491
|
+
* at its start, so the read must be the statement *after* the lock to observe
|
|
492
|
+
* a duplicate that committed while this transaction waited for it.
|
|
493
|
+
*/
|
|
494
|
+
async function readAdmissionOn(
|
|
495
|
+
client: PgQueryable,
|
|
496
|
+
partition: string,
|
|
497
|
+
clientId: string,
|
|
498
|
+
clientCommitId: string,
|
|
499
|
+
runningSchemaVersion: number,
|
|
500
|
+
prefetch:
|
|
501
|
+
| {
|
|
502
|
+
readonly tables: readonly CompiledTable[];
|
|
503
|
+
readonly specs: readonly {
|
|
504
|
+
readonly tbl: string;
|
|
505
|
+
readonly ids: string[];
|
|
506
|
+
}[];
|
|
507
|
+
}
|
|
508
|
+
| undefined,
|
|
509
|
+
): Promise<{
|
|
510
|
+
gate: ServeGate;
|
|
511
|
+
persisted: StoredPushResult | undefined;
|
|
512
|
+
prefetched: readonly PrefetchRow[];
|
|
513
|
+
}> {
|
|
514
|
+
const params: unknown[] = [
|
|
515
|
+
partition,
|
|
516
|
+
runningSchemaVersion,
|
|
517
|
+
clientId,
|
|
518
|
+
clientCommitId,
|
|
519
|
+
];
|
|
520
|
+
let statement = `SELECT ${SERVE_GATE_PROJECTION},
|
|
521
|
+
(SELECT result FROM sync_push_results
|
|
522
|
+
WHERE partition=$1 AND client_id=$3 AND client_commit_id=$4) AS result`;
|
|
523
|
+
if (prefetch !== undefined && prefetch.specs.length > 0) {
|
|
524
|
+
params.push(JSON.stringify(prefetch.specs));
|
|
525
|
+
// The gate row pads the read-ahead columns with typed NULLs so both
|
|
526
|
+
// branches share one result shape; `tbl` is NULL only for the gate row.
|
|
527
|
+
statement += `,
|
|
528
|
+
NULL::text AS tbl, NULL::text AS lookup_id, NULL::text AS row_id,
|
|
529
|
+
NULL::bigint AS server_version, NULL::jsonb AS scopes,
|
|
530
|
+
NULL::bytea AS payload, NULL::bytea AS column_versions,
|
|
531
|
+
NULL::bigint AS tombstone_seq
|
|
532
|
+
UNION ALL
|
|
533
|
+
SELECT NULL::bigint, NULL::text, NULL::json, NULL::jsonb,
|
|
534
|
+
p.tbl, p.lookup_id, p.row_id, p.server_version, p.scopes,
|
|
535
|
+
p.payload, p.column_versions, p.tombstone_seq
|
|
536
|
+
FROM (${prefetchRowsBatchSql(prefetch.tables, {
|
|
537
|
+
partition: '$1',
|
|
538
|
+
specs: `$${params.length}`,
|
|
539
|
+
})}) p`;
|
|
540
|
+
}
|
|
541
|
+
const { rows } = await client.query<AdmissionRow>(statement, params);
|
|
542
|
+
const gateRow = rows.find((row) => row.tbl === null) ?? rows[0];
|
|
543
|
+
return {
|
|
544
|
+
gate: toServeGate(gateRow),
|
|
545
|
+
persisted: toPersistedPushResult(gateRow?.result),
|
|
546
|
+
prefetched: rows.filter(
|
|
547
|
+
(row): row is AdmissionRow & PrefetchRow =>
|
|
548
|
+
row.tbl !== null && row.tbl !== undefined,
|
|
549
|
+
),
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Insert the `declared` row and raise the fence, on the caller's open
|
|
555
|
+
* transaction. Shared by `declareCheckpoint` and the `ensureSchema`
|
|
556
|
+
* migration so the schema bump and the fence commit together. Never lowers a
|
|
557
|
+
* fence and never re-declares an existing row.
|
|
558
|
+
*/
|
|
457
559
|
async function hasSourceChangesAboveOn(
|
|
458
560
|
client: PgQueryable,
|
|
459
561
|
partition: string,
|
|
@@ -825,13 +927,16 @@ async function scanRowsByIndexOn(
|
|
|
825
927
|
}
|
|
826
928
|
|
|
827
929
|
/**
|
|
828
|
-
* Write rows of one table in one statement per chunk: the upsert,
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
930
|
+
* Write rows of one table in one statement per chunk: the upsert, the scope
|
|
931
|
+
* index synced to each row's scope map (entries outside it deleted, missing
|
|
932
|
+
* ones inserted), and the §5.9.4 blob-reference index synced to the ids the
|
|
933
|
+
* row now references (entries outside the new set deleted, missing ones
|
|
934
|
+
* inserted). Row ids are distinct, so no index entry is touched twice and the
|
|
935
|
+
* writable CTEs cannot conflict. The scopes and blob ids bind as text before
|
|
936
|
+
* the JSONB cast (see `appendCommit`); the refs set is part of the same
|
|
937
|
+
* statement, so a blob-referencing commit costs no extra round trip per row.
|
|
938
|
+
* Every chunk is handed to `q` before the first one settles, so a statement
|
|
939
|
+
* issued after this call queues behind all of them on the transaction FIFO.
|
|
835
940
|
*/
|
|
836
941
|
function writeRowsOn(
|
|
837
942
|
q: PgQueryable,
|
|
@@ -842,16 +947,30 @@ function writeRowsOn(
|
|
|
842
947
|
/** `upsertValues` of the row, computed when it was staged. */
|
|
843
948
|
readonly values: readonly unknown[];
|
|
844
949
|
}[],
|
|
950
|
+
blobRefs: readonly {
|
|
951
|
+
readonly rowId: string;
|
|
952
|
+
readonly blobIds: readonly string[];
|
|
953
|
+
}[] = [],
|
|
845
954
|
): Promise<unknown> {
|
|
846
955
|
const width = tableColumnNames(compiled).length;
|
|
847
956
|
// PGlite corrupts its state above 32,767 bound parameters (see
|
|
848
957
|
// PG_CHANGES_PER_STATEMENT).
|
|
849
958
|
const chunk = Math.max(1, Math.floor(30_000 / width));
|
|
959
|
+
const pendingRefs = new Map(
|
|
960
|
+
blobRefs.map((entry) => [entry.rowId, entry.blobIds]),
|
|
961
|
+
);
|
|
850
962
|
const writes: Promise<unknown>[] = [];
|
|
851
963
|
for (let first = 0; first < rows.length; first += chunk) {
|
|
852
964
|
const slice = rows.slice(first, first + chunk);
|
|
853
965
|
const params = slice.flatMap((entry) => entry.values);
|
|
854
966
|
const n = params.length;
|
|
967
|
+
const refs: { row_id: string; blob_ids: readonly string[] }[] = [];
|
|
968
|
+
for (const entry of slice) {
|
|
969
|
+
const blobIds = pendingRefs.get(entry.row.rowId);
|
|
970
|
+
if (blobIds === undefined) continue;
|
|
971
|
+
pendingRefs.delete(entry.row.rowId);
|
|
972
|
+
refs.push({ row_id: entry.row.rowId, blob_ids: blobIds });
|
|
973
|
+
}
|
|
855
974
|
writes.push(
|
|
856
975
|
q.query(
|
|
857
976
|
`WITH upserted AS (${upsertSql(compiled, 'postgres', slice.length)}),
|
|
@@ -869,6 +988,28 @@ function writeRowsOn(
|
|
|
869
988
|
WHERE existing.partition=$${n + 2} AND existing.tbl=$${n + 3}
|
|
870
989
|
AND existing.row_id=batch.row_id
|
|
871
990
|
AND batch.scopes->>existing.var IS DISTINCT FROM existing.value
|
|
991
|
+
),
|
|
992
|
+
changed_refs AS (
|
|
993
|
+
SELECT DISTINCT row_id
|
|
994
|
+
FROM jsonb_to_recordset($${n + 4}::text::jsonb) AS r(row_id text, blob_ids jsonb)
|
|
995
|
+
),
|
|
996
|
+
removed_refs AS (
|
|
997
|
+
DELETE FROM sync_blob_refs existing
|
|
998
|
+
USING changed_refs
|
|
999
|
+
WHERE existing.partition=$${n + 2} AND existing.tbl=$${n + 3}
|
|
1000
|
+
AND existing.row_id=changed_refs.row_id
|
|
1001
|
+
AND NOT EXISTS (
|
|
1002
|
+
SELECT 1
|
|
1003
|
+
FROM jsonb_to_recordset($${n + 4}::text::jsonb) AS r(row_id text, blob_ids jsonb)
|
|
1004
|
+
CROSS JOIN LATERAL jsonb_array_elements_text(r.blob_ids) AS entry(value)
|
|
1005
|
+
WHERE r.row_id=existing.row_id AND entry.value=existing.blob_id)
|
|
1006
|
+
),
|
|
1007
|
+
inserted_refs AS (
|
|
1008
|
+
INSERT INTO sync_blob_refs(partition, tbl, row_id, blob_id)
|
|
1009
|
+
SELECT $${n + 2}, $${n + 3}, r.row_id, entry.value
|
|
1010
|
+
FROM jsonb_to_recordset($${n + 4}::text::jsonb) AS r(row_id text, blob_ids jsonb)
|
|
1011
|
+
CROSS JOIN LATERAL jsonb_array_elements_text(r.blob_ids) AS entry(value)
|
|
1012
|
+
ON CONFLICT DO NOTHING
|
|
872
1013
|
)
|
|
873
1014
|
INSERT INTO sync_row_scopes(partition, tbl, var, value, row_id)
|
|
874
1015
|
SELECT $${n + 2}, $${n + 3}, key, value, row_id FROM scope
|
|
@@ -880,6 +1021,45 @@ function writeRowsOn(
|
|
|
880
1021
|
),
|
|
881
1022
|
partition,
|
|
882
1023
|
compiled.name,
|
|
1024
|
+
JSON.stringify(refs),
|
|
1025
|
+
],
|
|
1026
|
+
),
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
if (pendingRefs.size > 0) {
|
|
1030
|
+
// Refs staged for a row this call did not carry (the row was written by an
|
|
1031
|
+
// earlier flush): replace the reference set in one statement of its own.
|
|
1032
|
+
writes.push(
|
|
1033
|
+
q.query(
|
|
1034
|
+
`WITH changed_refs AS (
|
|
1035
|
+
SELECT DISTINCT row_id
|
|
1036
|
+
FROM jsonb_to_recordset($3::text::jsonb) AS r(row_id text, blob_ids jsonb)
|
|
1037
|
+
),
|
|
1038
|
+
removed_refs AS (
|
|
1039
|
+
DELETE FROM sync_blob_refs existing
|
|
1040
|
+
USING changed_refs
|
|
1041
|
+
WHERE existing.partition=$1 AND existing.tbl=$2
|
|
1042
|
+
AND existing.row_id=changed_refs.row_id
|
|
1043
|
+
AND NOT EXISTS (
|
|
1044
|
+
SELECT 1
|
|
1045
|
+
FROM jsonb_to_recordset($3::text::jsonb) AS r(row_id text, blob_ids jsonb)
|
|
1046
|
+
CROSS JOIN LATERAL jsonb_array_elements_text(r.blob_ids) AS entry(value)
|
|
1047
|
+
WHERE r.row_id=existing.row_id AND entry.value=existing.blob_id)
|
|
1048
|
+
)
|
|
1049
|
+
INSERT INTO sync_blob_refs(partition, tbl, row_id, blob_id)
|
|
1050
|
+
SELECT $1, $2, r.row_id, entry.value
|
|
1051
|
+
FROM jsonb_to_recordset($3::text::jsonb) AS r(row_id text, blob_ids jsonb)
|
|
1052
|
+
CROSS JOIN LATERAL jsonb_array_elements_text(r.blob_ids) AS entry(value)
|
|
1053
|
+
ON CONFLICT DO NOTHING`,
|
|
1054
|
+
[
|
|
1055
|
+
partition,
|
|
1056
|
+
compiled.name,
|
|
1057
|
+
JSON.stringify(
|
|
1058
|
+
[...pendingRefs].map(([row_id, blob_ids]) => ({
|
|
1059
|
+
row_id,
|
|
1060
|
+
blob_ids,
|
|
1061
|
+
})),
|
|
1062
|
+
),
|
|
883
1063
|
],
|
|
884
1064
|
),
|
|
885
1065
|
);
|
|
@@ -897,9 +1077,14 @@ async function getPushResultOn(
|
|
|
897
1077
|
'SELECT result FROM sync_push_results WHERE partition=$1 AND client_id=$2 AND client_commit_id=$3',
|
|
898
1078
|
[partition, clientId, clientCommitId],
|
|
899
1079
|
);
|
|
900
|
-
|
|
1080
|
+
return toPersistedPushResult(rows[0]?.result);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/** Decode a persisted §2.3 outcome; an unreadable record is a cache miss. */
|
|
1084
|
+
function toPersistedPushResult(value: unknown): StoredPushResult | undefined {
|
|
1085
|
+
if (value === null || value === undefined) return undefined;
|
|
901
1086
|
try {
|
|
902
|
-
return deserializePushResult(asJson(
|
|
1087
|
+
return deserializePushResult(asJson(value));
|
|
903
1088
|
} catch {
|
|
904
1089
|
throw syncError(
|
|
905
1090
|
'sync.idempotency_cache_miss',
|
|
@@ -971,6 +1156,20 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
971
1156
|
}
|
|
972
1157
|
>
|
|
973
1158
|
>();
|
|
1159
|
+
/**
|
|
1160
|
+
* §5.9.4 reference sets staged per table (row id → blob ids). They flush
|
|
1161
|
+
* with the table's row statement, so a blob-referencing row costs no extra
|
|
1162
|
+
* round trip; a ref set staged for an already-written row flushes on the
|
|
1163
|
+
* next statement of its table.
|
|
1164
|
+
*/
|
|
1165
|
+
readonly #stagedBlobRefs = new Map<string, Map<string, readonly string[]>>();
|
|
1166
|
+
/**
|
|
1167
|
+
* The partition cursor (`max_commit_seq`) read by the first registered
|
|
1168
|
+
* query in this transaction. The partition lock freezes it, so later
|
|
1169
|
+
* registered queries in the same transaction reuse the value instead of
|
|
1170
|
+
* paying a round trip each.
|
|
1171
|
+
*/
|
|
1172
|
+
#cursorSeq: number | undefined;
|
|
974
1173
|
/** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
|
|
975
1174
|
#resolve: () => void;
|
|
976
1175
|
#reject: (error: unknown) => void;
|
|
@@ -1031,24 +1230,60 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
1031
1230
|
}
|
|
1032
1231
|
|
|
1033
1232
|
#writeStaged(): Promise<unknown> {
|
|
1034
|
-
if (this.#staged.size === 0
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
staged.map((
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1233
|
+
if (this.#staged.size === 0 && this.#stagedBlobRefs.size === 0) {
|
|
1234
|
+
return Promise.resolve();
|
|
1235
|
+
}
|
|
1236
|
+
const tables = new Set<string>([
|
|
1237
|
+
...[...this.#staged.keys()].map((table) => table.name),
|
|
1238
|
+
...this.#stagedBlobRefs.keys(),
|
|
1239
|
+
]);
|
|
1240
|
+
const batches = [...tables].map((table) => {
|
|
1241
|
+
const compiled = this.#resolveTable(table);
|
|
1242
|
+
const rows = [...(this.#staged.get(compiled)?.values() ?? [])];
|
|
1243
|
+
const blobRefs = [
|
|
1244
|
+
...(this.#stagedBlobRefs.get(table)?.entries() ?? []),
|
|
1245
|
+
].map(([rowId, blobIds]) => ({ rowId, blobIds }));
|
|
1246
|
+
this.#staged.delete(compiled);
|
|
1247
|
+
this.#stagedBlobRefs.delete(table);
|
|
1248
|
+
return { table, compiled, rows, blobRefs };
|
|
1249
|
+
});
|
|
1250
|
+
const writes: Promise<unknown>[] = [];
|
|
1251
|
+
// The candidate savepoint lands before the first candidate write, and
|
|
1252
|
+
// only when a write actually reaches the database: a commit refused
|
|
1253
|
+
// before its first write has no candidate rows to roll back, so its
|
|
1254
|
+
// rejection finalization skips the savepoint round trips.
|
|
1255
|
+
const savepoint = this.#ensurePushSavepoint();
|
|
1256
|
+
if (savepoint !== undefined) writes.push(savepoint);
|
|
1257
|
+
for (const { compiled, rows, blobRefs } of batches) {
|
|
1258
|
+
writes.push(
|
|
1259
|
+
writeRowsOn(
|
|
1260
|
+
this.#serial,
|
|
1261
|
+
compiled,
|
|
1262
|
+
this.#partition,
|
|
1263
|
+
rows,
|
|
1264
|
+
blobRefs,
|
|
1265
|
+
).catch((error: unknown) => {
|
|
1266
|
+
// Staged tables have no unique index, so a violation is a column
|
|
1267
|
+
// constraint; it names the first staged operation of the table.
|
|
1268
|
+
if (isPostgresConstraintError(error)) {
|
|
1269
|
+
throw new StorageConstraintError(error, rows[0]?.opIndex);
|
|
1270
|
+
}
|
|
1271
|
+
throw error;
|
|
1272
|
+
}),
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
return Promise.all(writes);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* The candidate savepoint, taken before the transaction's first write.
|
|
1280
|
+
* Every candidate write must be undoable by `commitRejectedPushResult`, and
|
|
1281
|
+
* a refused commit that wrote nothing must not pay to create one.
|
|
1282
|
+
*/
|
|
1283
|
+
#ensurePushSavepoint(): Promise<unknown> | undefined {
|
|
1284
|
+
if (this.#pushApplySavepoint) return undefined;
|
|
1285
|
+
this.#pushApplySavepoint = true;
|
|
1286
|
+
return this.#serial.query('SAVEPOINT syncular_push_candidate');
|
|
1052
1287
|
}
|
|
1053
1288
|
|
|
1054
1289
|
getRow(table: string, rowId: string): Promise<StoredRow | undefined> {
|
|
@@ -1065,28 +1300,38 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
1065
1300
|
);
|
|
1066
1301
|
}
|
|
1067
1302
|
|
|
1068
|
-
async prefetchRows(
|
|
1303
|
+
async prefetchRows(
|
|
1304
|
+
targets: readonly {
|
|
1305
|
+
readonly table: string;
|
|
1306
|
+
readonly rowIds: readonly string[];
|
|
1307
|
+
}[],
|
|
1308
|
+
): Promise<void> {
|
|
1069
1309
|
this.#assertOpen();
|
|
1070
|
-
const
|
|
1071
|
-
|
|
1310
|
+
const tables: CompiledTable[] = [];
|
|
1311
|
+
const specs: { tbl: string; ids: string[] }[] = [];
|
|
1312
|
+
for (const target of targets) {
|
|
1313
|
+
const ids = [...new Set(target.rowIds)].filter(
|
|
1314
|
+
(rowId) => !this.#prefetchedRows.has(`${target.table}\u0000${rowId}`),
|
|
1315
|
+
);
|
|
1316
|
+
if (ids.length === 0) continue;
|
|
1317
|
+
tables.push(this.#resolveTable(target.table));
|
|
1318
|
+
specs.push({ tbl: target.table, ids });
|
|
1319
|
+
}
|
|
1320
|
+
if (specs.length === 0) return;
|
|
1321
|
+
const { rows } = await this.#client.query<PrefetchRow>(
|
|
1322
|
+
prefetchRowsBatchSql(tables),
|
|
1323
|
+
[this.#partition, JSON.stringify(specs)],
|
|
1072
1324
|
);
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
}
|
|
1080
|
-
>(prefetchRowsSql(this.#resolveTable(table)), [
|
|
1081
|
-
this.#partition,
|
|
1082
|
-
JSON.stringify(ids),
|
|
1083
|
-
table,
|
|
1084
|
-
]);
|
|
1085
|
-
for (const record of rows) {
|
|
1086
|
-
const key = `${table}\u0000${record.lookup_id}`;
|
|
1325
|
+
this.#recordPrefetched(rows);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/** Answer the following `getRow`/`getTombstoneSeq` calls from a read snapshot. */
|
|
1329
|
+
#recordPrefetched(records: readonly PrefetchRow[]): void {
|
|
1330
|
+
for (const record of records) {
|
|
1331
|
+
const key = `${record.tbl}\u0000${record.lookup_id}`;
|
|
1087
1332
|
this.#prefetchedRows.set(
|
|
1088
1333
|
key,
|
|
1089
|
-
record.row_id === null
|
|
1334
|
+
record.row_id === null || record.row_id === undefined
|
|
1090
1335
|
? null
|
|
1091
1336
|
: toStoredRow({ ...record, row_id: record.row_id }),
|
|
1092
1337
|
);
|
|
@@ -1117,28 +1362,13 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
1117
1362
|
async clearTombstone(table: string, rowId: string): Promise<void> {
|
|
1118
1363
|
this.#assertOpen();
|
|
1119
1364
|
this.#prefetchedTombstones.delete(`${table}\u0000${rowId}`);
|
|
1365
|
+
await this.#ensurePushSavepoint();
|
|
1120
1366
|
await this.#client.query(
|
|
1121
1367
|
'DELETE FROM sync_tombstones WHERE partition=$1 AND tbl=$2 AND row_id=$3',
|
|
1122
1368
|
[this.#partition, table, rowId],
|
|
1123
1369
|
);
|
|
1124
1370
|
}
|
|
1125
1371
|
|
|
1126
|
-
getPushResult(
|
|
1127
|
-
clientId: string,
|
|
1128
|
-
clientCommitId: string,
|
|
1129
|
-
): Promise<StoredPushResult | undefined> {
|
|
1130
|
-
this.#assertOpen();
|
|
1131
|
-
// Runs on this transaction's pinned client: the push layer's duplicate
|
|
1132
|
-
// re-check happens while the partition lock is held, and a pool-level
|
|
1133
|
-
// read there would wait for a second connection.
|
|
1134
|
-
return getPushResultOn(
|
|
1135
|
-
this.#client,
|
|
1136
|
-
this.#partition,
|
|
1137
|
-
clientId,
|
|
1138
|
-
clientCommitId,
|
|
1139
|
-
);
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
1372
|
async scanRows(query: RowScanQuery): Promise<StoredRow[]> {
|
|
1143
1373
|
this.#assertOpen();
|
|
1144
1374
|
const firstVariable = assertScopeIndexedScan(query);
|
|
@@ -1185,24 +1415,70 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
1185
1415
|
);
|
|
1186
1416
|
}
|
|
1187
1417
|
|
|
1188
|
-
queryAuthoritative(
|
|
1418
|
+
async queryAuthoritative(
|
|
1189
1419
|
query: AuthoritativeQueryRequest,
|
|
1190
1420
|
): Promise<AuthoritativeQueryResult> {
|
|
1191
1421
|
this.#assertOpen();
|
|
1192
1422
|
// Runs on this transaction's pinned client, so the read observes every
|
|
1193
1423
|
// staged row and never waits for a second pool connection.
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1424
|
+
const prepared = this.#prepareQuery(query);
|
|
1425
|
+
const result = await this.#client.query<Readonly<Record<string, unknown>>>(
|
|
1426
|
+
postgresPlaceholders(prepared.sql),
|
|
1427
|
+
prepared.params,
|
|
1198
1428
|
);
|
|
1429
|
+
// The partition lock freezes `max_commit_seq` for the whole transaction,
|
|
1430
|
+
// so one read answers every registered query in it.
|
|
1431
|
+
if (this.#cursorSeq === undefined) {
|
|
1432
|
+
const cursor = await this.#client.query<{ max_commit_seq: unknown }>(
|
|
1433
|
+
'SELECT max_commit_seq FROM sync_partitions WHERE partition=$1',
|
|
1434
|
+
[this.#partition],
|
|
1435
|
+
);
|
|
1436
|
+
this.#cursorSeq =
|
|
1437
|
+
cursor.rows[0] === undefined
|
|
1438
|
+
? 0
|
|
1439
|
+
: asNumber(cursor.rows[0].max_commit_seq);
|
|
1440
|
+
}
|
|
1441
|
+
return { rows: result.rows, maxCommitSeq: this.#cursorSeq };
|
|
1199
1442
|
}
|
|
1200
1443
|
|
|
1201
|
-
async
|
|
1444
|
+
async preparePushApply(
|
|
1445
|
+
clientId: string,
|
|
1446
|
+
clientCommitId: string,
|
|
1447
|
+
runningSchemaVersion: number,
|
|
1448
|
+
targets: readonly {
|
|
1449
|
+
readonly table: string;
|
|
1450
|
+
readonly rowIds: readonly string[];
|
|
1451
|
+
}[] = [],
|
|
1452
|
+
): Promise<PreparedPushApply> {
|
|
1202
1453
|
this.#assertOpen();
|
|
1454
|
+
// The lock is its own statement: a READ COMMITTED statement's snapshot is
|
|
1455
|
+
// fixed at its start, so the admission read must run *after* a duplicate's
|
|
1456
|
+
// commit has released the partition lock to observe it. Folding the lock
|
|
1457
|
+
// and the read into one statement would miss a duplicate that committed
|
|
1458
|
+
// while this transaction waited.
|
|
1203
1459
|
await lockPartitionOn(this.#client, this.#partition);
|
|
1204
|
-
|
|
1205
|
-
|
|
1460
|
+
const tables: CompiledTable[] = [];
|
|
1461
|
+
const specs: { tbl: string; ids: string[] }[] = [];
|
|
1462
|
+
for (const target of targets) {
|
|
1463
|
+
const ids = [...new Set(target.rowIds)].filter(
|
|
1464
|
+
(rowId) => !this.#prefetchedRows.has(`${target.table}\u0000${rowId}`),
|
|
1465
|
+
);
|
|
1466
|
+
if (ids.length === 0) continue;
|
|
1467
|
+
tables.push(this.#resolveTable(target.table));
|
|
1468
|
+
specs.push({ tbl: target.table, ids });
|
|
1469
|
+
}
|
|
1470
|
+
// One statement: the serve gate, the idempotency record, and the commit's
|
|
1471
|
+
// read-ahead (when the caller already knows its targets).
|
|
1472
|
+
const admission = await readAdmissionOn(
|
|
1473
|
+
this.#client,
|
|
1474
|
+
this.#partition,
|
|
1475
|
+
clientId,
|
|
1476
|
+
clientCommitId,
|
|
1477
|
+
runningSchemaVersion,
|
|
1478
|
+
specs.length === 0 ? undefined : { tables, specs },
|
|
1479
|
+
);
|
|
1480
|
+
this.#recordPrefetched(admission.prefetched);
|
|
1481
|
+
return { gate: admission.gate, persisted: admission.persisted };
|
|
1206
1482
|
}
|
|
1207
1483
|
|
|
1208
1484
|
async advanceCheckpoint(
|
|
@@ -1213,6 +1489,7 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
1213
1489
|
nowMs: number,
|
|
1214
1490
|
): Promise<boolean> {
|
|
1215
1491
|
this.#assertOpen();
|
|
1492
|
+
await this.#ensurePushSavepoint();
|
|
1216
1493
|
// `RETURNING`: the affected-row check must not depend on a driver's
|
|
1217
1494
|
// `rowCount` for a statement with no result rows. Bun.sql's canonical
|
|
1218
1495
|
// adapter derives `rowCount` from the returned rows, so a bare UPDATE
|
|
@@ -1238,17 +1515,59 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
1238
1515
|
clientId: string,
|
|
1239
1516
|
clientCommitId: string,
|
|
1240
1517
|
result: StoredPushResult,
|
|
1241
|
-
): Promise<
|
|
1518
|
+
): Promise<StoredPushResult> {
|
|
1242
1519
|
this.#assertOpen();
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
}
|
|
1520
|
+
// Candidate rows that never reached the database are discarded with the
|
|
1521
|
+
// staging map; a savepoint exists only when one did.
|
|
1246
1522
|
this.#staged.clear();
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1523
|
+
this.#stagedBlobRefs.clear();
|
|
1524
|
+
if (this.#pushApplySavepoint) {
|
|
1525
|
+
await this.#client.query('ROLLBACK TO SAVEPOINT syncular_push_candidate');
|
|
1526
|
+
await this.#client.query('RELEASE SAVEPOINT syncular_push_candidate');
|
|
1527
|
+
this.#pushApplySavepoint = false;
|
|
1528
|
+
}
|
|
1529
|
+
const canonical = await this.#insertPushResult(
|
|
1530
|
+
clientId,
|
|
1531
|
+
clientCommitId,
|
|
1532
|
+
result,
|
|
1533
|
+
);
|
|
1251
1534
|
await this.commit();
|
|
1535
|
+
return canonical;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/** Insert the §2.3 outcome and return the canonical persisted value. */
|
|
1539
|
+
async #insertPushResult(
|
|
1540
|
+
clientId: string,
|
|
1541
|
+
clientCommitId: string,
|
|
1542
|
+
result: StoredPushResult,
|
|
1543
|
+
): Promise<StoredPushResult> {
|
|
1544
|
+
const { rows } = await this.#client.query<{ result: unknown }>(
|
|
1545
|
+
`INSERT INTO sync_push_results(partition, client_id, client_commit_id, result)
|
|
1546
|
+
VALUES ($1,$2,$3,$4)
|
|
1547
|
+
ON CONFLICT (partition, client_id, client_commit_id) DO NOTHING
|
|
1548
|
+
RETURNING result`,
|
|
1549
|
+
[
|
|
1550
|
+
this.#partition,
|
|
1551
|
+
clientId,
|
|
1552
|
+
clientCommitId,
|
|
1553
|
+
JSON.stringify(serializePushResult(result)),
|
|
1554
|
+
],
|
|
1555
|
+
);
|
|
1556
|
+
if (rows[0] !== undefined) {
|
|
1557
|
+
return toPersistedPushResult(rows[0].result) ?? result;
|
|
1558
|
+
}
|
|
1559
|
+
// `ON CONFLICT DO NOTHING` means a previously committed outcome won; read
|
|
1560
|
+
// it rather than claiming this caller's candidate.
|
|
1561
|
+
const existing = await getPushResultOn(
|
|
1562
|
+
this.#client,
|
|
1563
|
+
this.#partition,
|
|
1564
|
+
clientId,
|
|
1565
|
+
clientCommitId,
|
|
1566
|
+
);
|
|
1567
|
+
if (existing === undefined) {
|
|
1568
|
+
throw new Error('push result insert did not persist an outcome');
|
|
1569
|
+
}
|
|
1570
|
+
return existing;
|
|
1252
1571
|
}
|
|
1253
1572
|
|
|
1254
1573
|
async upsertRow(
|
|
@@ -1276,10 +1595,21 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
1276
1595
|
});
|
|
1277
1596
|
return;
|
|
1278
1597
|
}
|
|
1598
|
+
const refs = this.#stagedBlobRefs.get(table);
|
|
1599
|
+
const blobIds = refs?.get(row.rowId);
|
|
1600
|
+
if (blobIds !== undefined && refs !== undefined) {
|
|
1601
|
+
refs.delete(row.rowId);
|
|
1602
|
+
if (refs.size === 0) this.#stagedBlobRefs.delete(table);
|
|
1603
|
+
}
|
|
1604
|
+
await this.#ensurePushSavepoint();
|
|
1279
1605
|
try {
|
|
1280
|
-
await writeRowsOn(
|
|
1281
|
-
|
|
1282
|
-
|
|
1606
|
+
await writeRowsOn(
|
|
1607
|
+
this.#client,
|
|
1608
|
+
compiled,
|
|
1609
|
+
this.#partition,
|
|
1610
|
+
[{ row, values }],
|
|
1611
|
+
blobIds === undefined ? [] : [{ rowId: row.rowId, blobIds }],
|
|
1612
|
+
);
|
|
1283
1613
|
} catch (error) {
|
|
1284
1614
|
if (isPostgresConstraintError(error)) {
|
|
1285
1615
|
throw new StorageConstraintError(error, context?.opIndex);
|
|
@@ -1291,6 +1621,10 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
1291
1621
|
async deleteRow(table: string, rowId: string): Promise<void> {
|
|
1292
1622
|
this.#assertOpen();
|
|
1293
1623
|
this.#prefetchedRows.delete(`${table}\u0000${rowId}`);
|
|
1624
|
+
const refs = this.#stagedBlobRefs.get(table);
|
|
1625
|
+
refs?.delete(rowId);
|
|
1626
|
+
if (refs?.size === 0) this.#stagedBlobRefs.delete(table);
|
|
1627
|
+
await this.#ensurePushSavepoint();
|
|
1294
1628
|
// One statement: the row, its scope-index entries, and (§5.9.4: a
|
|
1295
1629
|
// deleted row references no blobs) its blob references.
|
|
1296
1630
|
await this.#client.query(
|
|
@@ -1303,125 +1637,156 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
1303
1637
|
);
|
|
1304
1638
|
}
|
|
1305
1639
|
|
|
1306
|
-
|
|
1640
|
+
setBlobRefs(
|
|
1307
1641
|
table: string,
|
|
1308
1642
|
rowId: string,
|
|
1309
1643
|
blobIds: readonly string[],
|
|
1310
1644
|
): Promise<void> {
|
|
1311
1645
|
this.#assertOpen();
|
|
1312
|
-
//
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
await this.#client.query(
|
|
1319
|
-
`INSERT INTO sync_blob_refs(partition, tbl, row_id, blob_id)
|
|
1320
|
-
VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING`,
|
|
1321
|
-
[this.#partition, table, rowId, blobId],
|
|
1322
|
-
);
|
|
1646
|
+
// Staged with the row: the reference set is replaced in the same
|
|
1647
|
+
// statement that writes the row (§5.9.4), inside the commit tx.
|
|
1648
|
+
let rows = this.#stagedBlobRefs.get(table);
|
|
1649
|
+
if (rows === undefined) {
|
|
1650
|
+
rows = new Map();
|
|
1651
|
+
this.#stagedBlobRefs.set(table, rows);
|
|
1323
1652
|
}
|
|
1653
|
+
rows.set(rowId, blobIds);
|
|
1654
|
+
return Promise.resolve();
|
|
1324
1655
|
}
|
|
1325
1656
|
|
|
1326
1657
|
async appendCommit(commit: NewCommit): Promise<number> {
|
|
1327
1658
|
this.#assertOpen();
|
|
1328
1659
|
// The commit writes its delete tombstones below.
|
|
1329
1660
|
this.#prefetchedTombstones.clear();
|
|
1661
|
+
// The append advances the partition cursor; a registered query that runs
|
|
1662
|
+
// after it must read the new value.
|
|
1663
|
+
this.#cursorSeq = undefined;
|
|
1330
1664
|
const q = this.#client;
|
|
1331
1665
|
const p = this.#partition;
|
|
1332
1666
|
// Allocate the next dense commitSeq under a per-partition row lock: the
|
|
1333
1667
|
// UPDATE … RETURNING serializes concurrent pushes to this partition and
|
|
1334
1668
|
// never leaves a gap on rollback (see the file header).
|
|
1335
|
-
const
|
|
1336
|
-
`WITH allocated AS (
|
|
1337
|
-
INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 1)
|
|
1669
|
+
const allocate = `INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 1)
|
|
1338
1670
|
ON CONFLICT (partition) DO UPDATE
|
|
1339
1671
|
SET max_commit_seq = sync_partitions.max_commit_seq + 1
|
|
1340
|
-
RETURNING max_commit_seq
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1672
|
+
RETURNING max_commit_seq`;
|
|
1673
|
+
const commitRow = `INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms, writer_version)
|
|
1674
|
+
SELECT $1, allocated.max_commit_seq, $2, $3, $4, $5, $6 FROM allocated
|
|
1675
|
+
RETURNING commit_seq`;
|
|
1676
|
+
const head = [
|
|
1677
|
+
p,
|
|
1678
|
+
commit.clientId,
|
|
1679
|
+
commit.clientCommitId,
|
|
1680
|
+
commit.actorId,
|
|
1681
|
+
commit.createdAtMs,
|
|
1682
|
+
this.#writerVersion,
|
|
1683
|
+
];
|
|
1684
|
+
if (commit.changes.length === 0) {
|
|
1685
|
+
const { rows } = await q.query<{ commit_seq: unknown }>(
|
|
1686
|
+
`WITH allocated AS (${allocate}) ${commitRow}`,
|
|
1687
|
+
head,
|
|
1688
|
+
);
|
|
1689
|
+
return asNumber(rows[0]?.commit_seq);
|
|
1690
|
+
}
|
|
1691
|
+
// §2.2: a delete whose commit follows it with an upsert is a scope move.
|
|
1692
|
+
// The row is live, so the tombstone insert below skips it.
|
|
1693
|
+
const moved = JSON.stringify(
|
|
1694
|
+
[...rowsLiveAfterDelete(commit.changes)].map((key) => {
|
|
1695
|
+
const at = key.indexOf('\u0000');
|
|
1696
|
+
return { tbl: key.slice(0, at), row_id: key.slice(at + 1) };
|
|
1697
|
+
}),
|
|
1353
1698
|
);
|
|
1354
|
-
const commitSeq = asNumber(rows[0]?.commit_seq);
|
|
1355
|
-
if (commit.changes.length === 0) return commitSeq;
|
|
1356
1699
|
// One writable-CTE chain per chunk of changes: the changes, their
|
|
1357
1700
|
// inverted scope entries, and the §5 delete tombstones. A statement binds
|
|
1358
1701
|
// at most a 16-bit count of parameters, so a chunk carries
|
|
1359
|
-
// PG_CHANGES_PER_STATEMENT changes
|
|
1360
|
-
// commit
|
|
1361
|
-
//
|
|
1362
|
-
//
|
|
1363
|
-
//
|
|
1364
|
-
// one row
|
|
1702
|
+
// PG_CHANGES_PER_STATEMENT changes. The first chunk folds the allocation,
|
|
1703
|
+
// the commit row, and the change-log write into one statement; later
|
|
1704
|
+
// chunks run under the same commitSeq with their global idx. Serialized
|
|
1705
|
+
// scopes bind as text before parsing JSONB; drivers that encode JSONB
|
|
1706
|
+
// parameters would otherwise store the string as a scalar. A commit can
|
|
1707
|
+
// delete one row twice, so a chunk's tombstone insert keeps one row per
|
|
1708
|
+
// key and a later chunk updates it to the same commit_seq.
|
|
1709
|
+
const valuesOf = (
|
|
1710
|
+
params: unknown[],
|
|
1711
|
+
slice: readonly NewChange[],
|
|
1712
|
+
firstIdx: number,
|
|
1713
|
+
): string[] =>
|
|
1714
|
+
slice.map((change, offset) => {
|
|
1715
|
+
const base = params.length;
|
|
1716
|
+
params.push(
|
|
1717
|
+
firstIdx + offset,
|
|
1718
|
+
change.table,
|
|
1719
|
+
change.rowId,
|
|
1720
|
+
change.op === 'upsert' ? 1 : 2,
|
|
1721
|
+
change.rowVersion ?? null,
|
|
1722
|
+
JSON.stringify(change.scopes),
|
|
1723
|
+
change.payload ?? null,
|
|
1724
|
+
);
|
|
1725
|
+
return `($${base + 1}::int,$${base + 2}::text,$${base + 3}::text,$${base + 4}::smallint,$${base + 5}::bigint,$${base + 6}::text::jsonb,$${base + 7}::bytea)`;
|
|
1726
|
+
});
|
|
1727
|
+
const scopesCte = `scoped AS (
|
|
1728
|
+
INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
|
|
1729
|
+
SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
|
|
1730
|
+
FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
|
|
1731
|
+
ON CONFLICT DO NOTHING
|
|
1732
|
+
)`;
|
|
1733
|
+
const tombstones = (
|
|
1734
|
+
movedParam: string,
|
|
1735
|
+
) => `INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq)
|
|
1736
|
+
SELECT DISTINCT ON (tbl, row_id) partition, tbl, row_id, commit_seq
|
|
1737
|
+
FROM inserted
|
|
1738
|
+
WHERE op = 2
|
|
1739
|
+
AND NOT EXISTS (
|
|
1740
|
+
SELECT 1
|
|
1741
|
+
FROM jsonb_to_recordset(${movedParam}::text::jsonb) AS moved(tbl text, row_id text)
|
|
1742
|
+
WHERE moved.tbl = inserted.tbl AND moved.row_id = inserted.row_id)
|
|
1743
|
+
ON CONFLICT (partition, tbl, row_id)
|
|
1744
|
+
DO UPDATE SET commit_seq = excluded.commit_seq`;
|
|
1745
|
+
const firstSlice = commit.changes.slice(0, PG_CHANGES_PER_STATEMENT);
|
|
1746
|
+
const params = [...head];
|
|
1747
|
+
const values = valuesOf(params, firstSlice, 0);
|
|
1748
|
+
params.push(moved);
|
|
1749
|
+
const movedParam = `$${params.length}`;
|
|
1750
|
+
const { rows } = await q.query<{ max_commit_seq: unknown }>(
|
|
1751
|
+
`WITH allocated AS (${allocate}),
|
|
1752
|
+
allocated_commit AS (${commitRow}),
|
|
1753
|
+
inserted AS (
|
|
1754
|
+
INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
|
|
1755
|
+
SELECT $1, allocated.max_commit_seq, v.idx, v.tbl, v.row_id, v.op, v.row_version, v.scopes, v.payload
|
|
1756
|
+
FROM allocated
|
|
1757
|
+
CROSS JOIN (VALUES ${values.join(',')}) AS v(idx, tbl, row_id, op, row_version, scopes, payload)
|
|
1758
|
+
RETURNING partition, tbl, row_id, op, commit_seq, scopes
|
|
1759
|
+
),
|
|
1760
|
+
${scopesCte},
|
|
1761
|
+
tombstoned AS (${tombstones(movedParam)})
|
|
1762
|
+
SELECT max_commit_seq FROM allocated`,
|
|
1763
|
+
params,
|
|
1764
|
+
);
|
|
1765
|
+
const commitSeq = asNumber(rows[0]?.max_commit_seq);
|
|
1365
1766
|
for (
|
|
1366
|
-
let first =
|
|
1767
|
+
let first = PG_CHANGES_PER_STATEMENT;
|
|
1367
1768
|
first < commit.changes.length;
|
|
1368
1769
|
first += PG_CHANGES_PER_STATEMENT
|
|
1369
1770
|
) {
|
|
1370
|
-
const
|
|
1371
|
-
const
|
|
1372
|
-
|
|
1373
|
-
.
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
change.rowId,
|
|
1379
|
-
change.op === 'upsert' ? 1 : 2,
|
|
1380
|
-
change.rowVersion ?? null,
|
|
1381
|
-
JSON.stringify(change.scopes),
|
|
1382
|
-
change.payload ?? null,
|
|
1383
|
-
);
|
|
1384
|
-
return `($1,$2::bigint,$${base + 1}::int,$${base + 2},$${base + 3},$${base + 4}::smallint,$${base + 5}::bigint,$${base + 6}::text::jsonb,$${base + 7}::bytea)`;
|
|
1385
|
-
});
|
|
1771
|
+
const chunkParams: unknown[] = [p, commitSeq];
|
|
1772
|
+
const chunkValues = valuesOf(
|
|
1773
|
+
chunkParams,
|
|
1774
|
+
commit.changes.slice(first, first + PG_CHANGES_PER_STATEMENT),
|
|
1775
|
+
first,
|
|
1776
|
+
);
|
|
1777
|
+
chunkParams.push(moved);
|
|
1778
|
+
const chunkMovedParam = `$${chunkParams.length}`;
|
|
1386
1779
|
await q.query(
|
|
1387
1780
|
`WITH inserted AS (
|
|
1388
1781
|
INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
|
|
1389
|
-
|
|
1782
|
+
SELECT $1, $2, v.idx, v.tbl, v.row_id, v.op, v.row_version, v.scopes, v.payload
|
|
1783
|
+
FROM (VALUES ${chunkValues.join(',')}) AS v(idx, tbl, row_id, op, row_version, scopes, payload)
|
|
1390
1784
|
RETURNING partition, tbl, row_id, op, commit_seq, scopes
|
|
1391
1785
|
),
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
ON CONFLICT DO NOTHING
|
|
1397
|
-
)
|
|
1398
|
-
INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq)
|
|
1399
|
-
SELECT DISTINCT ON (tbl, row_id) partition, tbl, row_id, commit_seq
|
|
1400
|
-
FROM inserted WHERE op = 2
|
|
1401
|
-
ON CONFLICT (partition, tbl, row_id)
|
|
1402
|
-
DO UPDATE SET commit_seq = excluded.commit_seq`,
|
|
1403
|
-
params,
|
|
1404
|
-
);
|
|
1405
|
-
}
|
|
1406
|
-
// §2.2: a delete whose commit follows it with an upsert is a scope move.
|
|
1407
|
-
// The row is live, so no tombstone may remain for it.
|
|
1408
|
-
const liveAfterDelete = rowsLiveAfterDelete(commit.changes);
|
|
1409
|
-
if (liveAfterDelete.size > 0) {
|
|
1410
|
-
await q.query(
|
|
1411
|
-
`DELETE FROM sync_tombstones existing
|
|
1412
|
-
USING jsonb_to_recordset($2::text::jsonb) AS moved(tbl text, row_id text)
|
|
1413
|
-
WHERE existing.partition=$1
|
|
1414
|
-
AND existing.tbl=moved.tbl
|
|
1415
|
-
AND existing.row_id=moved.row_id`,
|
|
1416
|
-
[
|
|
1417
|
-
p,
|
|
1418
|
-
JSON.stringify(
|
|
1419
|
-
[...liveAfterDelete].map((key) => {
|
|
1420
|
-
const at = key.indexOf('\u0000');
|
|
1421
|
-
return { tbl: key.slice(0, at), row_id: key.slice(at + 1) };
|
|
1422
|
-
}),
|
|
1423
|
-
),
|
|
1424
|
-
],
|
|
1786
|
+
${scopesCte},
|
|
1787
|
+
tombstoned AS (${tombstones(chunkMovedParam)})
|
|
1788
|
+
SELECT 1`,
|
|
1789
|
+
chunkParams,
|
|
1425
1790
|
);
|
|
1426
1791
|
}
|
|
1427
1792
|
return commitSeq;
|
|
@@ -2270,7 +2635,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
2270
2635
|
): Promise<'activated' | 'stale' | 'unverifiable'> {
|
|
2271
2636
|
return this.#exec.transaction(async (client) => {
|
|
2272
2637
|
// The same `SELECT … FOR UPDATE` partition row lock that
|
|
2273
|
-
// `
|
|
2638
|
+
// `preparePushApply` takes; a bare `BEGIN` would race a push.
|
|
2274
2639
|
await lockPartitionOn(client, partition);
|
|
2275
2640
|
const fence = await client.query<{ required_writer_version: unknown }>(
|
|
2276
2641
|
'SELECT required_writer_version FROM sync_writer_fence WHERE partition=$1',
|