@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.
@@ -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
- prefetchRowsSql,
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
- /** RFC 0007 serve gate on either the pool or a transaction's client. */
425
- async function readServeGateOn(
426
- client: PgQueryable,
427
- partition: string,
428
- runningSchemaVersion: number,
429
- ): Promise<ServeGate> {
430
- // One statement: every request reads the gate twice (entry and verify),
431
- // so its parts share one round trip and one snapshot.
432
- const { rows } = await client.query<{
433
- schema_version: unknown;
434
- log_epoch: string | null;
435
- pending: unknown;
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
- [partition, runningSchemaVersion],
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, and the
828
- * scope index synced to each row's scope map (entries outside it deleted,
829
- * missing ones inserted). Row ids are distinct, so no index entry is touched
830
- * twice and the writable CTEs cannot conflict. The scopes bind as text
831
- * before the JSONB cast (see `appendCommit`). Every chunk is handed to `q`
832
- * before the first one settles, so a statement issued after this call
833
- * queues behind all of them on the transaction FIFO.
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
- if (rows[0] === undefined) return undefined;
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(rows[0].result));
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) return Promise.resolve();
1034
- const staged = [...this.#staged];
1035
- this.#staged.clear();
1036
- return Promise.all(
1037
- staged.map(([table, rows]) => {
1038
- const entries = [...rows.values()];
1039
- return writeRowsOn(this.#serial, table, this.#partition, entries).catch(
1040
- (error: unknown) => {
1041
- // Staged tables have no unique index, so a violation is a column
1042
- // constraint; it names the first staged operation of the table.
1043
- if (isPostgresConstraintError(error)) {
1044
- throw new StorageConstraintError(error, entries[0]?.opIndex);
1045
- }
1046
- throw error;
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(table: string, rowIds: readonly string[]): Promise<void> {
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 ids = [...new Set(rowIds)].filter(
1070
- (rowId) => !this.#prefetchedRows.has(`${table}\u0000${rowId}`),
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
- if (ids.length === 0) return;
1073
- const { rows } = await this.#client.query<
1074
- Omit<RowRecord, 'row_id'> & {
1075
- lookup_id: string;
1076
- row_id: string | null;
1077
- tombstone_seq: unknown;
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
- return queryAuthoritativeOn(
1194
- this.#client,
1195
- this.#partition,
1196
- this.#prepareQuery(query),
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 lockPartitionForPush(): Promise<void> {
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
- await this.#client.query('SAVEPOINT syncular_push_candidate');
1204
- this.#pushApplySavepoint = true;
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<void> {
1518
+ ): Promise<StoredPushResult> {
1241
1519
  this.#assertOpen();
1242
- if (!this.#pushApplySavepoint) {
1243
- throw new Error('push rejection requires its apply savepoint');
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
- await this.#client.query('ROLLBACK TO SAVEPOINT syncular_push_candidate');
1247
- await this.#client.query('RELEASE SAVEPOINT syncular_push_candidate');
1248
- this.#pushApplySavepoint = false;
1249
- await this.putPushResult(clientId, clientCommitId, result);
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(this.#client, compiled, this.#partition, [
1280
- { row, values },
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
- async setBlobRefs(
1640
+ setBlobRefs(
1306
1641
  table: string,
1307
1642
  rowId: string,
1308
1643
  blobIds: readonly string[],
1309
1644
  ): Promise<void> {
1310
1645
  this.#assertOpen();
1311
- // Replace the row's reference set atomically inside the commit tx (§5.9.4).
1312
- await this.#client.query(
1313
- 'DELETE FROM sync_blob_refs WHERE partition=$1 AND tbl=$2 AND row_id=$3',
1314
- [this.#partition, table, rowId],
1315
- );
1316
- for (const blobId of blobIds) {
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 { rows } = await q.query<{ commit_seq: unknown }>(
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
- INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms, writer_version)
1342
- SELECT $1, max_commit_seq, $2, $3, $4, $5, $6 FROM allocated
1343
- RETURNING commit_seq`,
1344
- [
1345
- p,
1346
- commit.clientId,
1347
- commit.clientCommitId,
1348
- commit.actorId,
1349
- commit.createdAtMs,
1350
- this.#writerVersion,
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; every chunk runs in the
1359
- // commit's transaction under the same commit_seq with its global idx.
1360
- // Serialized scopes bind as text before parsing JSONB; drivers that
1361
- // encode JSONB parameters would otherwise store the string as a scalar.
1362
- // A commit can delete one row twice, so a chunk's tombstone insert keeps
1363
- // one row per key and a later chunk updates it to the same commit_seq.
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 = 0;
1767
+ let first = PG_CHANGES_PER_STATEMENT;
1366
1768
  first < commit.changes.length;
1367
1769
  first += PG_CHANGES_PER_STATEMENT
1368
1770
  ) {
1369
- const params: unknown[] = [p, commitSeq];
1370
- const values = commit.changes
1371
- .slice(first, first + PG_CHANGES_PER_STATEMENT)
1372
- .map((change, offset) => {
1373
- const base = params.length;
1374
- params.push(
1375
- first + offset,
1376
- change.table,
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
- VALUES ${values.join(',')}
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
- scoped AS (
1392
- INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
1393
- SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
1394
- FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
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
- // `lockPartitionForPush` takes; a bare `BEGIN` would race a push.
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',