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