@syncular/server 0.43.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/push.ts CHANGED
@@ -48,6 +48,7 @@ import { authorizeWrite, renderScopeValue, storedScopesForRow } from './scopes';
48
48
  import { decodeColumnVersions, encodeColumnVersions } from './relational-rows';
49
49
  import type {
50
50
  NewChange,
51
+ PreparedPushApply,
51
52
  StorageTransaction,
52
53
  StoredCommit,
53
54
  StoredPushResult,
@@ -1574,6 +1575,16 @@ export async function processPushCommitWithTrace(
1574
1575
  frame: PushCommitFrame,
1575
1576
  wireSchema: CompiledSchema = schema,
1576
1577
  ): Promise<ProcessedPushCommit> {
1578
+ // The frame names its rows before the transaction opens, so the admission
1579
+ // read carries the commit's read-ahead and the apply issues no separate
1580
+ // prefetch statement.
1581
+ const targets = new Map<string, string[]>();
1582
+ for (const op of frame.operations) {
1583
+ if (!schema.tables.has(op.table)) continue;
1584
+ const ids = targets.get(op.table) ?? [];
1585
+ ids.push(op.rowId);
1586
+ targets.set(op.table, ids);
1587
+ }
1577
1588
  return processPushOperationsWithTrace(
1578
1589
  ctx,
1579
1590
  schema,
@@ -1582,6 +1593,9 @@ export async function processPushCommitWithTrace(
1582
1593
  frame.clientCommitId,
1583
1594
  async () => frame.operations,
1584
1595
  wireSchema,
1596
+ targets.size === 0
1597
+ ? undefined
1598
+ : [...targets].map(([table, rowIds]) => ({ table, rowIds })),
1585
1599
  );
1586
1600
  }
1587
1601
 
@@ -1600,86 +1614,46 @@ export async function processPushOperationsWithTrace(
1600
1614
  tx: StorageTransaction,
1601
1615
  ) => Promise<readonly ApplyOperation[]>,
1602
1616
  wireSchema: CompiledSchema = schema,
1617
+ prefetchTargets?: readonly {
1618
+ readonly table: string;
1619
+ readonly rowIds: readonly string[];
1620
+ }[],
1603
1621
  ): Promise<ProcessedPushCommit> {
1604
- const { storage, partition } = ctx;
1605
- let persisted: StoredPushResult | undefined;
1606
- try {
1607
- persisted = await storage.getPushResult(
1608
- partition,
1609
- clientId,
1610
- clientCommitId,
1611
- );
1612
- } catch (error) {
1613
- if (
1614
- error instanceof SyncError &&
1615
- error.code === 'sync.idempotency_cache_miss'
1616
- ) {
1617
- // §6.3: answer the retryable cache-miss for this commit rather than
1618
- // re-applying. Not persisted — a retry may find a readable record.
1619
- return {
1620
- frame: idempotencyCacheMissFrame(clientCommitId, error),
1621
- replayed: false,
1622
- };
1623
- }
1624
- throw error;
1625
- }
1626
- if (persisted !== undefined) {
1627
- return processedPushCommit(clientCommitId, persisted, true);
1628
- }
1629
-
1622
+ const { partition } = ctx;
1630
1623
  const createdAtMs = clockOf(ctx)();
1631
1624
  const blobCtx: BlobApplyContext = { store: ctx.blobs, partition };
1632
1625
  const crdtMergers = ctx.crdtMergers;
1633
1626
  const validators = ctx.validators;
1634
1627
  const commitValidator = ctx.commitValidator;
1635
1628
  const reactionPlanner = ctx.reactionPlanner;
1636
- const tx = await storage.begin(partition);
1637
- const lockPartitionForPush =
1638
- tx.lockPartitionForPush?.bind(tx) ??
1639
- tx.lockPartitionForCommitValidation?.bind(tx);
1640
- const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
1629
+ const tx = await ctx.storage.begin(partition);
1641
1630
  try {
1642
- if (
1643
- lockPartitionForPush === undefined ||
1644
- commitRejectedPushResult === undefined ||
1645
- (reactionPlanner !== undefined && tx.enqueueReactions === undefined)
1646
- ) {
1631
+ if (reactionPlanner !== undefined && tx.enqueueReactions === undefined) {
1647
1632
  throw new Error(
1648
- 'storage transaction does not support serialized push apply, atomic rejection finalization, and configured durable reactions',
1633
+ 'storage transaction does not support configured durable reactions',
1649
1634
  );
1650
1635
  }
1651
- await lockPartitionForPush();
1652
- // RFC 0007: evaluate the gate on this transaction's own connection while
1653
- // the partition lock is held, so the migration check and the write share
1654
- // one transaction and no migration can interleave in between.
1655
- const gate = await tx.readServeGate(schema.version);
1656
- const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
1657
- if (refusal !== undefined) {
1658
- await tx.rollback();
1659
- throw serveNotReadyError(refusal);
1660
- }
1661
- // The optimistic lookup above may have raced another delivery. Re-check
1662
- // only after acquiring partition serialization and before any operation
1663
- // read, validation, merge, or staged write. The re-check runs on the
1664
- // transaction's own connection when the backend provides one: a pooled
1665
- // Postgres client holding the partition lock would otherwise wait for a
1666
- // second pool slot and deadlock against pushes waiting on the lock. A
1667
- // racing duplicate's result commits before the lock releases, so the
1668
- // transaction-scoped read observes it.
1636
+ // The storage takes the partition lock and then reads the admission token
1637
+ // on the transaction's own connection: the RFC 0007 serve gate and the
1638
+ // §2.3 idempotency record, plus any read-ahead the caller already knows.
1639
+ // A racing duplicate's result commits before the lock releases, so the
1640
+ // later read is exact and a duplicate never reruns an operation,
1641
+ // validator, or merge.
1642
+ let prepared: PreparedPushApply;
1669
1643
  try {
1670
- const serializedPersisted =
1671
- tx.getPushResult !== undefined
1672
- ? await tx.getPushResult(clientId, clientCommitId)
1673
- : await storage.getPushResult(partition, clientId, clientCommitId);
1674
- if (serializedPersisted !== undefined) {
1675
- await tx.rollback();
1676
- return processedPushCommit(clientCommitId, serializedPersisted, true);
1677
- }
1644
+ prepared = await tx.preparePushApply(
1645
+ clientId,
1646
+ clientCommitId,
1647
+ schema.version,
1648
+ prefetchTargets,
1649
+ );
1678
1650
  } catch (error) {
1679
1651
  if (
1680
1652
  error instanceof SyncError &&
1681
1653
  error.code === 'sync.idempotency_cache_miss'
1682
1654
  ) {
1655
+ // §6.3: answer the retryable cache-miss for this commit rather than
1656
+ // re-applying. Not persisted — a retry may find a readable record.
1683
1657
  await tx.rollback();
1684
1658
  return {
1685
1659
  frame: idempotencyCacheMissFrame(clientCommitId, error),
@@ -1688,6 +1662,22 @@ export async function processPushOperationsWithTrace(
1688
1662
  }
1689
1663
  throw error;
1690
1664
  }
1665
+ // RFC 0007: the gate is evaluated while the partition lock is held, so
1666
+ // the migration check and the write share one transaction and no
1667
+ // migration can interleave in between.
1668
+ const refusal = serveGateRefusal(
1669
+ prepared.gate,
1670
+ schema.version,
1671
+ ctx.checkpoints,
1672
+ );
1673
+ if (refusal !== undefined) {
1674
+ await tx.rollback();
1675
+ throw serveNotReadyError(refusal);
1676
+ }
1677
+ if (prepared.persisted !== undefined) {
1678
+ await tx.rollback();
1679
+ return processedPushCommit(clientCommitId, prepared.persisted, true);
1680
+ }
1691
1681
  const results: PushOperationResult[] = [];
1692
1682
  const changes: NewChange[] = [];
1693
1683
  const validatedOperations: ValidateCommitOperation[] = [];
@@ -1702,8 +1692,10 @@ export async function processPushOperationsWithTrace(
1702
1692
  ids.push(op.rowId);
1703
1693
  targets.set(op.table, ids);
1704
1694
  }
1705
- for (const [table, rowIds] of targets) {
1706
- await tx.prefetchRows(table, rowIds);
1695
+ if (targets.size > 0) {
1696
+ await tx.prefetchRows(
1697
+ [...targets].map(([table, rowIds]) => ({ table, rowIds })),
1698
+ );
1707
1699
  }
1708
1700
  }
1709
1701
  for (let opIndex = 0; opIndex < operations.length; opIndex++) {
@@ -1802,18 +1794,13 @@ export async function processPushOperationsWithTrace(
1802
1794
  results: [terminated],
1803
1795
  });
1804
1796
  // Discard candidates and persist the rejection while retaining the same
1805
- // partition lock. There is no unlock gap in which a duplicate can rerun.
1806
- await commitRejectedPushResult(clientId, clientCommitId, stored);
1807
- const canonical = await storage.getPushResult(
1808
- partition,
1797
+ // partition lock. There is no unlock gap in which a duplicate can rerun,
1798
+ // and the finalization returns the canonical outcome it persisted.
1799
+ const canonical = await tx.commitRejectedPushResult(
1809
1800
  clientId,
1810
1801
  clientCommitId,
1802
+ stored,
1811
1803
  );
1812
- if (canonical === undefined) {
1813
- throw new Error(
1814
- 'push rejection finalization did not persist an outcome',
1815
- );
1816
- }
1817
1804
  return processedPushCommit(
1818
1805
  clientCommitId,
1819
1806
  canonical,
@@ -1895,24 +1882,12 @@ export async function processPushOperationsWithTrace(
1895
1882
  },
1896
1883
  ],
1897
1884
  });
1898
- if (commitRejectedPushResult === undefined) {
1899
- await tx.rollback();
1900
- throw new Error(
1901
- 'storage transaction lost atomic push rejection finalization support',
1902
- );
1903
- }
1904
1885
  try {
1905
- await commitRejectedPushResult(clientId, clientCommitId, stored);
1906
- const canonical = await storage.getPushResult(
1907
- partition,
1886
+ const canonical = await tx.commitRejectedPushResult(
1908
1887
  clientId,
1909
1888
  clientCommitId,
1889
+ stored,
1910
1890
  );
1911
- if (canonical === undefined) {
1912
- throw new Error(
1913
- 'push rejection finalization did not persist an outcome',
1914
- );
1915
- }
1916
1891
  return processedPushCommit(
1917
1892
  clientCommitId,
1918
1893
  canonical,
@@ -807,17 +807,38 @@ export const COMMIT_WINDOW_BATCH_SQL = `SELECT s.i AS sub, c.commit_seq AS commi
807
807
  ORDER BY s.i, c.commit_seq, ch.idx`;
808
808
 
809
809
  /**
810
- * PostgreSQL push read-ahead: every requested row id with its stored row
811
- * (NULL columns when absent, `selectRowSql` shape) and its §5 delete
812
- * tombstone. Params: [partition, rowIds as JSON array text, tbl].
810
+ * PostgreSQL push read-ahead across every table a commit targets, in one
811
+ * statement: each requested row id with its stored row (NULL columns when
812
+ * absent, `selectRowSql` shape) and its §5 delete tombstone. Params:
813
+ * [partition, specs as JSON text], `specs` = `{tbl, ids: string[]}` records.
813
814
  */
814
- export function prefetchRowsSql(table: CompiledTable): string {
815
- return `SELECT ids.id AS lookup_id, r.${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, r.${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, r.${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, r.${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, r.${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions, t.commit_seq AS tombstone_seq
816
- FROM jsonb_array_elements_text($2::text::jsonb) AS ids(id)
817
- LEFT JOIN ${quoteIdent(table.name)} r
818
- ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=ids.id
819
- LEFT JOIN sync_tombstones t
820
- ON t.partition=$1 AND t.tbl=$3 AND t.row_id=ids.id`;
815
+ export function prefetchRowsBatchSql(
816
+ tables: readonly CompiledTable[],
817
+ params: { readonly partition: string; readonly specs: string } = {
818
+ partition: '$1',
819
+ specs: '$2',
820
+ },
821
+ ): string {
822
+ // One branch per table: a lateral join cannot switch its relation per
823
+ // record, and the union keeps the whole read-ahead one round trip.
824
+ const projection = tables
825
+ .map((table) => {
826
+ const quoted = quoteIdent(table.name);
827
+ return `SELECT ids.tbl AS tbl, ids.id AS lookup_id, r.${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, r.${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, r.${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, r.${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, r.${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions, t.commit_seq AS tombstone_seq
828
+ FROM ids
829
+ LEFT JOIN ${quoted} r
830
+ ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=${params.partition} AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=ids.id
831
+ LEFT JOIN sync_tombstones t
832
+ ON t.partition=${params.partition} AND t.tbl=ids.tbl AND t.row_id=ids.id
833
+ WHERE ids.tbl='${table.name.replaceAll("'", "''")}'`;
834
+ })
835
+ .join('\n UNION ALL ');
836
+ return `WITH ids AS (
837
+ SELECT spec.tbl AS tbl, entry.id AS id
838
+ FROM jsonb_to_recordset(${params.specs}::text::jsonb) AS spec(tbl text, ids jsonb)
839
+ CROSS JOIN LATERAL jsonb_array_elements_text(spec.ids) AS entry(id)
840
+ )
841
+ ${projection}`;
821
842
  }
822
843
 
823
844
  /**
@@ -101,6 +101,7 @@ import type {
101
101
  IndexRowScanQuery,
102
102
  NewCommit,
103
103
  NewReaction,
104
+ PreparedPushApply,
104
105
  PartitionRegistryEntry,
105
106
  PrunedReactionCounts,
106
107
  ReactionClaimQuery,
@@ -255,20 +256,6 @@ class SqliteTransaction implements StorageTransaction {
255
256
  .run(this.#partition, table, rowId);
256
257
  }
257
258
 
258
- getPushResult(
259
- clientId: string,
260
- clientCommitId: string,
261
- ): Promise<StoredPushResult | undefined> {
262
- this.#assertOpen();
263
- // One shared SQLite connection: this read runs inside this
264
- // transaction's BEGIN IMMEDIATE.
265
- return this.#storage.getPushResult(
266
- this.#partition,
267
- clientId,
268
- clientCommitId,
269
- );
270
- }
271
-
272
259
  scanRows(query: RowScanQuery): Promise<StoredRow[]> {
273
260
  this.#assertOpen();
274
261
  return this.#storage.scanRows(this.#partition, query);
@@ -288,11 +275,30 @@ class SqliteTransaction implements StorageTransaction {
288
275
  return this.#storage.runAuthoritativeQuery(this.#partition, query);
289
276
  }
290
277
 
291
- async lockPartitionForPush(): Promise<void> {
278
+ async preparePushApply(
279
+ clientId: string,
280
+ clientCommitId: string,
281
+ runningSchemaVersion: number,
282
+ _targets: readonly {
283
+ readonly table: string;
284
+ readonly rowIds: readonly string[];
285
+ }[] = [],
286
+ ): Promise<PreparedPushApply> {
292
287
  this.#assertOpen();
293
288
  // BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
294
289
  this.#storage.db.exec('SAVEPOINT syncular_push_candidate');
295
290
  this.#pushApplySavepoint = true;
291
+ return {
292
+ gate: await this.#storage.readServeGate(
293
+ this.#partition,
294
+ runningSchemaVersion,
295
+ ),
296
+ persisted: await this.#storage.getPushResult(
297
+ this.#partition,
298
+ clientId,
299
+ clientCommitId,
300
+ ),
301
+ };
296
302
  }
297
303
 
298
304
  async advanceCheckpoint(
@@ -324,7 +330,7 @@ class SqliteTransaction implements StorageTransaction {
324
330
  clientId: string,
325
331
  clientCommitId: string,
326
332
  result: StoredPushResult,
327
- ): Promise<void> {
333
+ ): Promise<StoredPushResult> {
328
334
  this.#assertOpen();
329
335
  if (!this.#pushApplySavepoint) {
330
336
  throw new Error('push rejection requires its apply savepoint');
@@ -334,6 +340,9 @@ class SqliteTransaction implements StorageTransaction {
334
340
  this.#pushApplySavepoint = false;
335
341
  await this.putPushResult(clientId, clientCommitId, result);
336
342
  await this.commit();
343
+ // The idempotency re-check found no record under the writer lock, so the
344
+ // insert above is the canonical outcome.
345
+ return result;
337
346
  }
338
347
 
339
348
  async upsertRow(
package/src/storage.ts CHANGED
@@ -74,6 +74,18 @@ export interface ServeGate {
74
74
  }[];
75
75
  }
76
76
 
77
+ /**
78
+ * The push apply's admission token: the partition lock, the serve gate, and
79
+ * the idempotency record read in one round trip. A storage with per-statement
80
+ * network cost answers all three with one statement (see
81
+ * `PostgresServerStorage`); the lock is held from this call through commit.
82
+ */
83
+ export interface PreparedPushApply {
84
+ readonly gate: ServeGate;
85
+ /** The already-persisted outcome for this key, or `undefined` on a miss. */
86
+ readonly persisted: StoredPushResult | undefined;
87
+ }
88
+
77
89
  /** Why `serveGate` refuses, or `undefined` when this request may serve. */
78
90
  export type ServeGateRefusal =
79
91
  | {
@@ -547,12 +559,17 @@ export interface StorageTransaction {
547
559
  getRow(table: string, rowId: string): Promise<StoredRow | undefined>;
548
560
  /**
549
561
  * Optional read-ahead: the push layer names every row a commit's
550
- * operations target, per table, before it applies them. A storage with
551
- * per-statement network cost reads them in one statement and answers the
552
- * following `getRow` calls from that snapshot until the row is written in
553
- * this transaction.
562
+ * operations target, across every table, before it applies them. A storage
563
+ * with per-statement network cost reads them in one statement (one branch
564
+ * per table) and answers the following `getRow` calls from that snapshot
565
+ * until the row is written in this transaction.
554
566
  */
555
- prefetchRows?(table: string, rowIds: readonly string[]): Promise<void>;
567
+ prefetchRows?(
568
+ targets: readonly {
569
+ readonly table: string;
570
+ readonly rowIds: readonly string[];
571
+ }[],
572
+ ): Promise<void>;
556
573
  /**
557
574
  * §5 delete-precedence tombstone read: the `commit_seq` of the newest
558
575
  * applied delete of this row still within the pruning horizon, or
@@ -567,20 +584,6 @@ export interface StorageTransaction {
567
584
  * over an absent row; a no-op when no tombstone exists.
568
585
  */
569
586
  clearTombstone(table: string, rowId: string): Promise<void>;
570
- /**
571
- * Optional transaction-scoped idempotency lookup (§2.3) with the same
572
- * semantics as `ServerStorage.getPushResult`, including the
573
- * `sync.idempotency_cache_miss` throw. The push layer prefers it for the
574
- * post-serialization duplicate re-check so the read runs on the
575
- * transaction's own connection — a pooled Postgres client that holds the
576
- * partition lock must never wait for a second pool slot mid-push. In-tree
577
- * SQLite/Postgres/D1 adapters implement it; a custom adapter that omits it
578
- * keeps the pool-level read.
579
- */
580
- getPushResult?(
581
- clientId: string,
582
- clientCommitId: string,
583
- ): Promise<StoredPushResult | undefined>;
584
587
  /**
585
588
  * Optional candidate-state scan used only by whole-commit validation.
586
589
  * In-tree SQLite/Postgres/D1 backends implement it with read-your-own-writes
@@ -610,28 +613,37 @@ export interface StorageTransaction {
610
613
  ): Promise<AuthoritativeQueryResult>;
611
614
  /**
612
615
  * Serialize every push apply for this partition before any operation read,
613
- * validation, merge, or write. The push layer re-checks idempotency only
614
- * after this resolves and retains the lock through terminal-result commit.
616
+ * validation, merge, or write, and read the admission token in the same
617
+ * round trip: the RFC 0007 serve gate and the §2.3 idempotency record for
618
+ * `clientCommitId`. The lock is retained through terminal-result commit,
619
+ * so the idempotency read is exact and a racing duplicate cannot rerun.
620
+ * `targets` are the rows the commit is known to touch before its operations
621
+ * are built; a storage with per-statement network cost folds that read-ahead
622
+ * into the admission read and answers the following `getRow` calls from it.
615
623
  * Missing support fails closed before an app-row mutation.
616
624
  */
617
- lockPartitionForPush?(): Promise<void>;
618
- /**
619
- * @deprecated Implement `lockPartitionForPush`. Kept as a compatibility
620
- * bridge for custom adapters whose existing implementation already locks
621
- * the complete partition from before candidate reads through commit.
622
- */
623
- lockPartitionForCommitValidation?(): Promise<void>;
625
+ preparePushApply(
626
+ clientId: string,
627
+ clientCommitId: string,
628
+ runningSchemaVersion: number,
629
+ targets?: readonly {
630
+ readonly table: string;
631
+ readonly rowIds: readonly string[];
632
+ }[],
633
+ ): Promise<PreparedPushApply>;
624
634
  /**
625
635
  * Rejection finalization while the push-apply serialization lock is still
626
636
  * held: discard every candidate write, persist the rejected idempotency
627
- * result, and finish the transaction atomically. Required for every push so
628
- * a concurrent duplicate cannot rerun operations, validators, or merges.
637
+ * result, and finish the transaction atomically. Returns the persisted
638
+ * canonical outcome, so the caller never re-reads it. Required for every
639
+ * push so a concurrent duplicate cannot rerun operations, validators, or
640
+ * merges.
629
641
  */
630
- commitRejectedPushResult?(
642
+ commitRejectedPushResult(
631
643
  clientId: string,
632
644
  clientCommitId: string,
633
645
  result: StoredPushResult,
634
- ): Promise<void>;
646
+ ): Promise<StoredPushResult>;
635
647
  upsertRow(
636
648
  table: string,
637
649
  row: StoredRow,
@@ -744,10 +756,10 @@ export interface ServerStorage {
744
756
  * same command races a push on Postgres and a SQLite-only test conceals it.
745
757
  *
746
758
  * A trusted command must therefore run through `registerRemoteCommand`,
747
- * which builds after `lockPartitionForPush` and the idempotency recheck and
748
- * shares the committed transaction, or call `lockPartitionForPush` before
749
- * its first authority, version, or replay read. SSP2 request handling does
750
- * this for you; only host-authored commands choose.
759
+ * which builds after `preparePushApply` and shares the committed
760
+ * transaction, or call `preparePushApply` before its first authority,
761
+ * version, or replay read. SSP2 request handling does this for you; only
762
+ * host-authored commands choose.
751
763
  */
752
764
  begin(partition: string): Promise<StorageTransaction>;
753
765