@syncular/server 0.42.2 → 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 +14 -4
- package/dist/operations.d.ts +5 -0
- package/dist/operations.js +4 -4
- package/dist/postgres-storage.js +386 -104
- package/dist/push.d.ts +18 -1
- package/dist/push.js +247 -132
- package/dist/relational-rows.d.ts +9 -5
- package/dist/relational-rows.js +29 -11
- package/dist/sqlite-storage.js +14 -10
- package/dist/storage.d.ts +46 -32
- package/dist/storage.js +26 -0
- package/package.json +2 -2
- package/src/d1-storage.ts +30 -5
- package/src/operations.ts +12 -7
- package/src/postgres-storage.ts +553 -166
- package/src/push.ts +358 -151
- package/src/relational-rows.ts +31 -10
- package/src/sqlite-storage.ts +33 -18
- package/src/storage.ts +75 -36
package/src/postgres-storage.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage';
|
|
|
4
4
|
import {
|
|
5
5
|
clientRoundState,
|
|
6
6
|
PARTITION_TOUCH_INTERVAL_MS,
|
|
7
|
+
rowsLiveAfterDelete,
|
|
7
8
|
serveGateRefusal,
|
|
8
9
|
} from './storage';
|
|
9
10
|
import { serveNotReadyError } from './readiness';
|
|
@@ -91,7 +92,7 @@ import {
|
|
|
91
92
|
serverIndexDdl,
|
|
92
93
|
selectRowScopesSql,
|
|
93
94
|
selectRowSql,
|
|
94
|
-
|
|
95
|
+
prefetchRowsBatchSql,
|
|
95
96
|
selectRowsForRewriteSql,
|
|
96
97
|
upsertSql,
|
|
97
98
|
upsertValues,
|
|
@@ -114,9 +115,11 @@ import type {
|
|
|
114
115
|
CommitWindowQuery,
|
|
115
116
|
DurableJsonValue,
|
|
116
117
|
IndexRowScanQuery,
|
|
118
|
+
NewChange,
|
|
117
119
|
NewCommit,
|
|
118
120
|
NewReaction,
|
|
119
121
|
PartitionRegistryEntry,
|
|
122
|
+
PreparedPushApply,
|
|
120
123
|
PrunedReactionCounts,
|
|
121
124
|
ReactionClaimQuery,
|
|
122
125
|
ReactionFailure,
|
|
@@ -421,27 +424,25 @@ async function readIndexSignatures(
|
|
|
421
424
|
return parseIndexSignatures(rows[0]?.indexes ?? '[]');
|
|
422
425
|
}
|
|
423
426
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
`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,
|
|
438
440
|
(SELECT log_epoch FROM sync_partition_registry WHERE partition=$1) AS log_epoch,
|
|
439
441
|
(SELECT json_agg(json_build_object('partition', partition, 'name', name))
|
|
440
442
|
FROM sync_backfill_checkpoints
|
|
441
|
-
WHERE schema_version=$2 AND state<>'activated') AS pending
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const row = rows[0];
|
|
443
|
+
WHERE schema_version=$2 AND state<>'activated') AS pending`;
|
|
444
|
+
|
|
445
|
+
function toServeGate(row: ServeGateRow | undefined): ServeGate {
|
|
445
446
|
return {
|
|
446
447
|
// asNumber maps the NULL of a missing marker row to 0.
|
|
447
448
|
storedSchemaVersion: asNumber(row?.schema_version),
|
|
@@ -453,6 +454,108 @@ async function readServeGateOn(
|
|
|
453
454
|
};
|
|
454
455
|
}
|
|
455
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
|
+
*/
|
|
456
559
|
async function hasSourceChangesAboveOn(
|
|
457
560
|
client: PgQueryable,
|
|
458
561
|
partition: string,
|
|
@@ -824,13 +927,16 @@ async function scanRowsByIndexOn(
|
|
|
824
927
|
}
|
|
825
928
|
|
|
826
929
|
/**
|
|
827
|
-
* Write rows of one table in one statement per chunk: the upsert,
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
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.
|
|
834
940
|
*/
|
|
835
941
|
function writeRowsOn(
|
|
836
942
|
q: PgQueryable,
|
|
@@ -841,16 +947,30 @@ function writeRowsOn(
|
|
|
841
947
|
/** `upsertValues` of the row, computed when it was staged. */
|
|
842
948
|
readonly values: readonly unknown[];
|
|
843
949
|
}[],
|
|
950
|
+
blobRefs: readonly {
|
|
951
|
+
readonly rowId: string;
|
|
952
|
+
readonly blobIds: readonly string[];
|
|
953
|
+
}[] = [],
|
|
844
954
|
): Promise<unknown> {
|
|
845
955
|
const width = tableColumnNames(compiled).length;
|
|
846
956
|
// PGlite corrupts its state above 32,767 bound parameters (see
|
|
847
957
|
// PG_CHANGES_PER_STATEMENT).
|
|
848
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
|
+
);
|
|
849
962
|
const writes: Promise<unknown>[] = [];
|
|
850
963
|
for (let first = 0; first < rows.length; first += chunk) {
|
|
851
964
|
const slice = rows.slice(first, first + chunk);
|
|
852
965
|
const params = slice.flatMap((entry) => entry.values);
|
|
853
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
|
+
}
|
|
854
974
|
writes.push(
|
|
855
975
|
q.query(
|
|
856
976
|
`WITH upserted AS (${upsertSql(compiled, 'postgres', slice.length)}),
|
|
@@ -868,6 +988,28 @@ function writeRowsOn(
|
|
|
868
988
|
WHERE existing.partition=$${n + 2} AND existing.tbl=$${n + 3}
|
|
869
989
|
AND existing.row_id=batch.row_id
|
|
870
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
|
|
871
1013
|
)
|
|
872
1014
|
INSERT INTO sync_row_scopes(partition, tbl, var, value, row_id)
|
|
873
1015
|
SELECT $${n + 2}, $${n + 3}, key, value, row_id FROM scope
|
|
@@ -879,6 +1021,45 @@ function writeRowsOn(
|
|
|
879
1021
|
),
|
|
880
1022
|
partition,
|
|
881
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
|
+
),
|
|
882
1063
|
],
|
|
883
1064
|
),
|
|
884
1065
|
);
|
|
@@ -896,9 +1077,14 @@ async function getPushResultOn(
|
|
|
896
1077
|
'SELECT result FROM sync_push_results WHERE partition=$1 AND client_id=$2 AND client_commit_id=$3',
|
|
897
1078
|
[partition, clientId, clientCommitId],
|
|
898
1079
|
);
|
|
899
|
-
|
|
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;
|
|
900
1086
|
try {
|
|
901
|
-
return deserializePushResult(asJson(
|
|
1087
|
+
return deserializePushResult(asJson(value));
|
|
902
1088
|
} catch {
|
|
903
1089
|
throw syncError(
|
|
904
1090
|
'sync.idempotency_cache_miss',
|
|
@@ -970,6 +1156,20 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
970
1156
|
}
|
|
971
1157
|
>
|
|
972
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;
|
|
973
1173
|
/** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
|
|
974
1174
|
#resolve: () => void;
|
|
975
1175
|
#reject: (error: unknown) => void;
|
|
@@ -1030,24 +1230,60 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
1030
1230
|
}
|
|
1031
1231
|
|
|
1032
1232
|
#writeStaged(): Promise<unknown> {
|
|
1033
|
-
if (this.#staged.size === 0
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
staged.map((
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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');
|
|
1051
1287
|
}
|
|
1052
1288
|
|
|
1053
1289
|
getRow(table: string, rowId: string): Promise<StoredRow | undefined> {
|
|
@@ -1064,28 +1300,38 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
1064
1300
|
);
|
|
1065
1301
|
}
|
|
1066
1302
|
|
|
1067
|
-
async prefetchRows(
|
|
1303
|
+
async prefetchRows(
|
|
1304
|
+
targets: readonly {
|
|
1305
|
+
readonly table: string;
|
|
1306
|
+
readonly rowIds: readonly string[];
|
|
1307
|
+
}[],
|
|
1308
|
+
): Promise<void> {
|
|
1068
1309
|
this.#assertOpen();
|
|
1069
|
-
const
|
|
1070
|
-
|
|
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)],
|
|
1071
1324
|
);
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
}
|
|
1079
|
-
>(prefetchRowsSql(this.#resolveTable(table)), [
|
|
1080
|
-
this.#partition,
|
|
1081
|
-
JSON.stringify(ids),
|
|
1082
|
-
table,
|
|
1083
|
-
]);
|
|
1084
|
-
for (const record of rows) {
|
|
1085
|
-
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}`;
|
|
1086
1332
|
this.#prefetchedRows.set(
|
|
1087
1333
|
key,
|
|
1088
|
-
record.row_id === null
|
|
1334
|
+
record.row_id === null || record.row_id === undefined
|
|
1089
1335
|
? null
|
|
1090
1336
|
: toStoredRow({ ...record, row_id: record.row_id }),
|
|
1091
1337
|
);
|
|
@@ -1116,28 +1362,13 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
1116
1362
|
async clearTombstone(table: string, rowId: string): Promise<void> {
|
|
1117
1363
|
this.#assertOpen();
|
|
1118
1364
|
this.#prefetchedTombstones.delete(`${table}\u0000${rowId}`);
|
|
1365
|
+
await this.#ensurePushSavepoint();
|
|
1119
1366
|
await this.#client.query(
|
|
1120
1367
|
'DELETE FROM sync_tombstones WHERE partition=$1 AND tbl=$2 AND row_id=$3',
|
|
1121
1368
|
[this.#partition, table, rowId],
|
|
1122
1369
|
);
|
|
1123
1370
|
}
|
|
1124
1371
|
|
|
1125
|
-
getPushResult(
|
|
1126
|
-
clientId: string,
|
|
1127
|
-
clientCommitId: string,
|
|
1128
|
-
): Promise<StoredPushResult | undefined> {
|
|
1129
|
-
this.#assertOpen();
|
|
1130
|
-
// Runs on this transaction's pinned client: the push layer's duplicate
|
|
1131
|
-
// re-check happens while the partition lock is held, and a pool-level
|
|
1132
|
-
// read there would wait for a second connection.
|
|
1133
|
-
return getPushResultOn(
|
|
1134
|
-
this.#client,
|
|
1135
|
-
this.#partition,
|
|
1136
|
-
clientId,
|
|
1137
|
-
clientCommitId,
|
|
1138
|
-
);
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
1372
|
async scanRows(query: RowScanQuery): Promise<StoredRow[]> {
|
|
1142
1373
|
this.#assertOpen();
|
|
1143
1374
|
const firstVariable = assertScopeIndexedScan(query);
|
|
@@ -1184,24 +1415,70 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
1184
1415
|
);
|
|
1185
1416
|
}
|
|
1186
1417
|
|
|
1187
|
-
queryAuthoritative(
|
|
1418
|
+
async queryAuthoritative(
|
|
1188
1419
|
query: AuthoritativeQueryRequest,
|
|
1189
1420
|
): Promise<AuthoritativeQueryResult> {
|
|
1190
1421
|
this.#assertOpen();
|
|
1191
1422
|
// Runs on this transaction's pinned client, so the read observes every
|
|
1192
1423
|
// staged row and never waits for a second pool connection.
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1424
|
+
const prepared = this.#prepareQuery(query);
|
|
1425
|
+
const result = await this.#client.query<Readonly<Record<string, unknown>>>(
|
|
1426
|
+
postgresPlaceholders(prepared.sql),
|
|
1427
|
+
prepared.params,
|
|
1197
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 };
|
|
1198
1442
|
}
|
|
1199
1443
|
|
|
1200
|
-
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> {
|
|
1201
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.
|
|
1202
1459
|
await lockPartitionOn(this.#client, this.#partition);
|
|
1203
|
-
|
|
1204
|
-
|
|
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 };
|
|
1205
1482
|
}
|
|
1206
1483
|
|
|
1207
1484
|
async advanceCheckpoint(
|
|
@@ -1212,6 +1489,7 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
1212
1489
|
nowMs: number,
|
|
1213
1490
|
): Promise<boolean> {
|
|
1214
1491
|
this.#assertOpen();
|
|
1492
|
+
await this.#ensurePushSavepoint();
|
|
1215
1493
|
// `RETURNING`: the affected-row check must not depend on a driver's
|
|
1216
1494
|
// `rowCount` for a statement with no result rows. Bun.sql's canonical
|
|
1217
1495
|
// adapter derives `rowCount` from the returned rows, so a bare UPDATE
|
|
@@ -1237,17 +1515,59 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
1237
1515
|
clientId: string,
|
|
1238
1516
|
clientCommitId: string,
|
|
1239
1517
|
result: StoredPushResult,
|
|
1240
|
-
): Promise<
|
|
1518
|
+
): Promise<StoredPushResult> {
|
|
1241
1519
|
this.#assertOpen();
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
}
|
|
1520
|
+
// Candidate rows that never reached the database are discarded with the
|
|
1521
|
+
// staging map; a savepoint exists only when one did.
|
|
1245
1522
|
this.#staged.clear();
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
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
|
+
);
|
|
1250
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;
|
|
1251
1571
|
}
|
|
1252
1572
|
|
|
1253
1573
|
async upsertRow(
|
|
@@ -1275,10 +1595,21 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
1275
1595
|
});
|
|
1276
1596
|
return;
|
|
1277
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();
|
|
1278
1605
|
try {
|
|
1279
|
-
await writeRowsOn(
|
|
1280
|
-
|
|
1281
|
-
|
|
1606
|
+
await writeRowsOn(
|
|
1607
|
+
this.#client,
|
|
1608
|
+
compiled,
|
|
1609
|
+
this.#partition,
|
|
1610
|
+
[{ row, values }],
|
|
1611
|
+
blobIds === undefined ? [] : [{ rowId: row.rowId, blobIds }],
|
|
1612
|
+
);
|
|
1282
1613
|
} catch (error) {
|
|
1283
1614
|
if (isPostgresConstraintError(error)) {
|
|
1284
1615
|
throw new StorageConstraintError(error, context?.opIndex);
|
|
@@ -1290,6 +1621,10 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
1290
1621
|
async deleteRow(table: string, rowId: string): Promise<void> {
|
|
1291
1622
|
this.#assertOpen();
|
|
1292
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();
|
|
1293
1628
|
// One statement: the row, its scope-index entries, and (§5.9.4: a
|
|
1294
1629
|
// deleted row references no blobs) its blob references.
|
|
1295
1630
|
await this.#client.query(
|
|
@@ -1302,104 +1637,156 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
1302
1637
|
);
|
|
1303
1638
|
}
|
|
1304
1639
|
|
|
1305
|
-
|
|
1640
|
+
setBlobRefs(
|
|
1306
1641
|
table: string,
|
|
1307
1642
|
rowId: string,
|
|
1308
1643
|
blobIds: readonly string[],
|
|
1309
1644
|
): Promise<void> {
|
|
1310
1645
|
this.#assertOpen();
|
|
1311
|
-
//
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
await this.#client.query(
|
|
1318
|
-
`INSERT INTO sync_blob_refs(partition, tbl, row_id, blob_id)
|
|
1319
|
-
VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING`,
|
|
1320
|
-
[this.#partition, table, rowId, blobId],
|
|
1321
|
-
);
|
|
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);
|
|
1322
1652
|
}
|
|
1653
|
+
rows.set(rowId, blobIds);
|
|
1654
|
+
return Promise.resolve();
|
|
1323
1655
|
}
|
|
1324
1656
|
|
|
1325
1657
|
async appendCommit(commit: NewCommit): Promise<number> {
|
|
1326
1658
|
this.#assertOpen();
|
|
1327
1659
|
// The commit writes its delete tombstones below.
|
|
1328
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;
|
|
1329
1664
|
const q = this.#client;
|
|
1330
1665
|
const p = this.#partition;
|
|
1331
1666
|
// Allocate the next dense commitSeq under a per-partition row lock: the
|
|
1332
1667
|
// UPDATE … RETURNING serializes concurrent pushes to this partition and
|
|
1333
1668
|
// never leaves a gap on rollback (see the file header).
|
|
1334
|
-
const
|
|
1335
|
-
`WITH allocated AS (
|
|
1336
|
-
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)
|
|
1337
1670
|
ON CONFLICT (partition) DO UPDATE
|
|
1338
1671
|
SET max_commit_seq = sync_partitions.max_commit_seq + 1
|
|
1339
|
-
RETURNING max_commit_seq
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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
|
+
}),
|
|
1352
1698
|
);
|
|
1353
|
-
const commitSeq = asNumber(rows[0]?.commit_seq);
|
|
1354
|
-
if (commit.changes.length === 0) return commitSeq;
|
|
1355
1699
|
// One writable-CTE chain per chunk of changes: the changes, their
|
|
1356
1700
|
// inverted scope entries, and the §5 delete tombstones. A statement binds
|
|
1357
1701
|
// at most a 16-bit count of parameters, so a chunk carries
|
|
1358
|
-
// PG_CHANGES_PER_STATEMENT changes
|
|
1359
|
-
// commit
|
|
1360
|
-
//
|
|
1361
|
-
//
|
|
1362
|
-
//
|
|
1363
|
-
// 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);
|
|
1364
1766
|
for (
|
|
1365
|
-
let first =
|
|
1767
|
+
let first = PG_CHANGES_PER_STATEMENT;
|
|
1366
1768
|
first < commit.changes.length;
|
|
1367
1769
|
first += PG_CHANGES_PER_STATEMENT
|
|
1368
1770
|
) {
|
|
1369
|
-
const
|
|
1370
|
-
const
|
|
1371
|
-
|
|
1372
|
-
.
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
change.rowId,
|
|
1378
|
-
change.op === 'upsert' ? 1 : 2,
|
|
1379
|
-
change.rowVersion ?? null,
|
|
1380
|
-
JSON.stringify(change.scopes),
|
|
1381
|
-
change.payload ?? null,
|
|
1382
|
-
);
|
|
1383
|
-
return `($1,$2::bigint,$${base + 1}::int,$${base + 2},$${base + 3},$${base + 4}::smallint,$${base + 5}::bigint,$${base + 6}::text::jsonb,$${base + 7}::bytea)`;
|
|
1384
|
-
});
|
|
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}`;
|
|
1385
1779
|
await q.query(
|
|
1386
1780
|
`WITH inserted AS (
|
|
1387
1781
|
INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
|
|
1388
|
-
|
|
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)
|
|
1389
1784
|
RETURNING partition, tbl, row_id, op, commit_seq, scopes
|
|
1390
1785
|
),
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
ON CONFLICT DO NOTHING
|
|
1396
|
-
)
|
|
1397
|
-
INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq)
|
|
1398
|
-
SELECT DISTINCT ON (tbl, row_id) partition, tbl, row_id, commit_seq
|
|
1399
|
-
FROM inserted WHERE op = 2
|
|
1400
|
-
ON CONFLICT (partition, tbl, row_id)
|
|
1401
|
-
DO UPDATE SET commit_seq = excluded.commit_seq`,
|
|
1402
|
-
params,
|
|
1786
|
+
${scopesCte},
|
|
1787
|
+
tombstoned AS (${tombstones(chunkMovedParam)})
|
|
1788
|
+
SELECT 1`,
|
|
1789
|
+
chunkParams,
|
|
1403
1790
|
);
|
|
1404
1791
|
}
|
|
1405
1792
|
return commitSeq;
|
|
@@ -2248,7 +2635,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
2248
2635
|
): Promise<'activated' | 'stale' | 'unverifiable'> {
|
|
2249
2636
|
return this.#exec.transaction(async (client) => {
|
|
2250
2637
|
// The same `SELECT … FOR UPDATE` partition row lock that
|
|
2251
|
-
// `
|
|
2638
|
+
// `preparePushApply` takes; a bare `BEGIN` would race a push.
|
|
2252
2639
|
await lockPartitionOn(client, partition);
|
|
2253
2640
|
const fence = await client.query<{ required_writer_version: unknown }>(
|
|
2254
2641
|
'SELECT required_writer_version FROM sync_writer_fence WHERE partition=$1',
|