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