@syncular/server 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/push.d.ts CHANGED
@@ -68,4 +68,7 @@ export declare function processPushCommitWithTrace(ctx: SyncRequestContext, sche
68
68
  * The builder runs after the partition lock and idempotency re-check, so its
69
69
  * reads and the returned operations share the transaction that is committed.
70
70
  */
71
- export declare function processPushOperationsWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, clientCommitId: string, buildOperations: (tx: StorageTransaction) => Promise<readonly ApplyOperation[]>, wireSchema?: CompiledSchema): Promise<ProcessedPushCommit>;
71
+ export declare function processPushOperationsWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, clientCommitId: string, buildOperations: (tx: StorageTransaction) => Promise<readonly ApplyOperation[]>, wireSchema?: CompiledSchema, prefetchTargets?: readonly {
72
+ readonly table: string;
73
+ readonly rowIds: readonly string[];
74
+ }[]): Promise<ProcessedPushCommit>;
package/dist/push.js CHANGED
@@ -997,80 +997,54 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
997
997
  * cache provenance needed by structured events and server helpers.
998
998
  */
999
999
  export async function processPushCommitWithTrace(ctx, schema, resolved, clientId, frame, wireSchema = schema) {
1000
- return processPushOperationsWithTrace(ctx, schema, resolved, clientId, frame.clientCommitId, async () => frame.operations, wireSchema);
1000
+ // The frame names its rows before the transaction opens, so the admission
1001
+ // read carries the commit's read-ahead and the apply issues no separate
1002
+ // prefetch statement.
1003
+ const targets = new Map();
1004
+ for (const op of frame.operations) {
1005
+ if (!schema.tables.has(op.table))
1006
+ continue;
1007
+ const ids = targets.get(op.table) ?? [];
1008
+ ids.push(op.rowId);
1009
+ targets.set(op.table, ids);
1010
+ }
1011
+ return processPushOperationsWithTrace(ctx, schema, resolved, clientId, frame.clientCommitId, async () => frame.operations, wireSchema, targets.size === 0
1012
+ ? undefined
1013
+ : [...targets].map(([table, rowIds]) => ({ table, rowIds })));
1001
1014
  }
1002
1015
  /**
1003
1016
  * Shared serialized apply path for SSP2 commits and authoritative commands.
1004
1017
  * The builder runs after the partition lock and idempotency re-check, so its
1005
1018
  * reads and the returned operations share the transaction that is committed.
1006
1019
  */
1007
- export async function processPushOperationsWithTrace(ctx, schema, resolved, clientId, clientCommitId, buildOperations, wireSchema = schema) {
1008
- const { storage, partition } = ctx;
1009
- let persisted;
1010
- try {
1011
- persisted = await storage.getPushResult(partition, clientId, clientCommitId);
1012
- }
1013
- catch (error) {
1014
- if (error instanceof SyncError &&
1015
- error.code === 'sync.idempotency_cache_miss') {
1016
- // §6.3: answer the retryable cache-miss for this commit rather than
1017
- // re-applying. Not persisted — a retry may find a readable record.
1018
- return {
1019
- frame: idempotencyCacheMissFrame(clientCommitId, error),
1020
- replayed: false,
1021
- };
1022
- }
1023
- throw error;
1024
- }
1025
- if (persisted !== undefined) {
1026
- return processedPushCommit(clientCommitId, persisted, true);
1027
- }
1020
+ export async function processPushOperationsWithTrace(ctx, schema, resolved, clientId, clientCommitId, buildOperations, wireSchema = schema, prefetchTargets) {
1021
+ const { partition } = ctx;
1028
1022
  const createdAtMs = clockOf(ctx)();
1029
1023
  const blobCtx = { store: ctx.blobs, partition };
1030
1024
  const crdtMergers = ctx.crdtMergers;
1031
1025
  const validators = ctx.validators;
1032
1026
  const commitValidator = ctx.commitValidator;
1033
1027
  const reactionPlanner = ctx.reactionPlanner;
1034
- const tx = await storage.begin(partition);
1035
- const lockPartitionForPush = tx.lockPartitionForPush?.bind(tx) ??
1036
- tx.lockPartitionForCommitValidation?.bind(tx);
1037
- const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
1028
+ const tx = await ctx.storage.begin(partition);
1038
1029
  try {
1039
- if (lockPartitionForPush === undefined ||
1040
- commitRejectedPushResult === undefined ||
1041
- (reactionPlanner !== undefined && tx.enqueueReactions === undefined)) {
1042
- throw new Error('storage transaction does not support serialized push apply, atomic rejection finalization, and configured durable reactions');
1030
+ if (reactionPlanner !== undefined && tx.enqueueReactions === undefined) {
1031
+ throw new Error('storage transaction does not support configured durable reactions');
1043
1032
  }
1044
- await lockPartitionForPush();
1045
- // RFC 0007: evaluate the gate on this transaction's own connection while
1046
- // the partition lock is held, so the migration check and the write share
1047
- // one transaction and no migration can interleave in between.
1048
- const gate = await tx.readServeGate(schema.version);
1049
- const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
1050
- if (refusal !== undefined) {
1051
- await tx.rollback();
1052
- throw serveNotReadyError(refusal);
1053
- }
1054
- // The optimistic lookup above may have raced another delivery. Re-check
1055
- // only after acquiring partition serialization and before any operation
1056
- // read, validation, merge, or staged write. The re-check runs on the
1057
- // transaction's own connection when the backend provides one: a pooled
1058
- // Postgres client holding the partition lock would otherwise wait for a
1059
- // second pool slot and deadlock against pushes waiting on the lock. A
1060
- // racing duplicate's result commits before the lock releases, so the
1061
- // transaction-scoped read observes it.
1033
+ // The storage takes the partition lock and then reads the admission token
1034
+ // on the transaction's own connection: the RFC 0007 serve gate and the
1035
+ // §2.3 idempotency record, plus any read-ahead the caller already knows.
1036
+ // A racing duplicate's result commits before the lock releases, so the
1037
+ // later read is exact and a duplicate never reruns an operation,
1038
+ // validator, or merge.
1039
+ let prepared;
1062
1040
  try {
1063
- const serializedPersisted = tx.getPushResult !== undefined
1064
- ? await tx.getPushResult(clientId, clientCommitId)
1065
- : await storage.getPushResult(partition, clientId, clientCommitId);
1066
- if (serializedPersisted !== undefined) {
1067
- await tx.rollback();
1068
- return processedPushCommit(clientCommitId, serializedPersisted, true);
1069
- }
1041
+ prepared = await tx.preparePushApply(clientId, clientCommitId, schema.version, prefetchTargets);
1070
1042
  }
1071
1043
  catch (error) {
1072
1044
  if (error instanceof SyncError &&
1073
1045
  error.code === 'sync.idempotency_cache_miss') {
1046
+ // §6.3: answer the retryable cache-miss for this commit rather than
1047
+ // re-applying. Not persisted — a retry may find a readable record.
1074
1048
  await tx.rollback();
1075
1049
  return {
1076
1050
  frame: idempotencyCacheMissFrame(clientCommitId, error),
@@ -1079,6 +1053,18 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
1079
1053
  }
1080
1054
  throw error;
1081
1055
  }
1056
+ // RFC 0007: the gate is evaluated while the partition lock is held, so
1057
+ // the migration check and the write share one transaction and no
1058
+ // migration can interleave in between.
1059
+ const refusal = serveGateRefusal(prepared.gate, schema.version, ctx.checkpoints);
1060
+ if (refusal !== undefined) {
1061
+ await tx.rollback();
1062
+ throw serveNotReadyError(refusal);
1063
+ }
1064
+ if (prepared.persisted !== undefined) {
1065
+ await tx.rollback();
1066
+ return processedPushCommit(clientCommitId, prepared.persisted, true);
1067
+ }
1082
1068
  const results = [];
1083
1069
  const changes = [];
1084
1070
  const validatedOperations = [];
@@ -1094,8 +1080,8 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
1094
1080
  ids.push(op.rowId);
1095
1081
  targets.set(op.table, ids);
1096
1082
  }
1097
- for (const [table, rowIds] of targets) {
1098
- await tx.prefetchRows(table, rowIds);
1083
+ if (targets.size > 0) {
1084
+ await tx.prefetchRows([...targets].map(([table, rowIds]) => ({ table, rowIds })));
1099
1085
  }
1100
1086
  }
1101
1087
  for (let opIndex = 0; opIndex < operations.length; opIndex++) {
@@ -1161,12 +1147,9 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
1161
1147
  results: [terminated],
1162
1148
  });
1163
1149
  // Discard candidates and persist the rejection while retaining the same
1164
- // partition lock. There is no unlock gap in which a duplicate can rerun.
1165
- await commitRejectedPushResult(clientId, clientCommitId, stored);
1166
- const canonical = await storage.getPushResult(partition, clientId, clientCommitId);
1167
- if (canonical === undefined) {
1168
- throw new Error('push rejection finalization did not persist an outcome');
1169
- }
1150
+ // partition lock. There is no unlock gap in which a duplicate can rerun,
1151
+ // and the finalization returns the canonical outcome it persisted.
1152
+ const canonical = await tx.commitRejectedPushResult(clientId, clientCommitId, stored);
1170
1153
  return processedPushCommit(clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
1171
1154
  }
1172
1155
  const commitSeq = await tx.appendCommit({
@@ -1244,16 +1227,8 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
1244
1227
  },
1245
1228
  ],
1246
1229
  });
1247
- if (commitRejectedPushResult === undefined) {
1248
- await tx.rollback();
1249
- throw new Error('storage transaction lost atomic push rejection finalization support');
1250
- }
1251
1230
  try {
1252
- await commitRejectedPushResult(clientId, clientCommitId, stored);
1253
- const canonical = await storage.getPushResult(partition, clientId, clientCommitId);
1254
- if (canonical === undefined) {
1255
- throw new Error('push rejection finalization did not persist an outcome');
1256
- }
1231
+ const canonical = await tx.commitRejectedPushResult(clientId, clientCommitId, stored);
1257
1232
  return processedPushCommit(clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
1258
1233
  }
1259
1234
  catch (finalizationError) {
@@ -275,11 +275,15 @@ export declare function scanRowBatchSql(tables: readonly CompiledTable[]): strin
275
275
  */
276
276
  export declare const COMMIT_WINDOW_BATCH_SQL = "SELECT s.i AS sub, c.commit_seq AS commit_seq, m.actor_id AS actor_id, m.created_at_ms AS created_at_ms,\n ch.tbl AS tbl, ch.row_id AS row_id, ch.op AS op, ch.row_version AS row_version, ch.scopes AS scopes, ch.payload AS payload\n FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after bigint, through bigint, lim int)\n CROSS JOIN LATERAL (SELECT DISTINCT commit_seq FROM sync_change_scopes\n WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)\n AND commit_seq>s.after AND commit_seq<=s.through\n ORDER BY commit_seq LIMIT s.lim) c\n LEFT JOIN sync_commits m\n ON m.partition=$1 AND m.commit_seq=c.commit_seq\n LEFT JOIN sync_changes ch\n ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=s.tbl\n ORDER BY s.i, c.commit_seq, ch.idx";
277
277
  /**
278
- * PostgreSQL push read-ahead: every requested row id with its stored row
279
- * (NULL columns when absent, `selectRowSql` shape) and its §5 delete
280
- * tombstone. Params: [partition, rowIds as JSON array text, tbl].
281
- */
282
- export declare function prefetchRowsSql(table: CompiledTable): string;
278
+ * PostgreSQL push read-ahead across every table a commit targets, in one
279
+ * statement: each requested row id with its stored row (NULL columns when
280
+ * absent, `selectRowSql` shape) and its §5 delete tombstone. Params:
281
+ * [partition, specs as JSON text], `specs` = `{tbl, ids: string[]}` records.
282
+ */
283
+ export declare function prefetchRowsBatchSql(tables: readonly CompiledTable[], params?: {
284
+ readonly partition: string;
285
+ readonly specs: string;
286
+ }): string;
283
287
  /**
284
288
  * SQLite/D1: the newest `commit_seq` in the inverted change-scope index for
285
289
  * one variable's values, as one index-only max per value (a `(partition,
@@ -667,17 +667,35 @@ export const COMMIT_WINDOW_BATCH_SQL = `SELECT s.i AS sub, c.commit_seq AS commi
667
667
  ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=s.tbl
668
668
  ORDER BY s.i, c.commit_seq, ch.idx`;
669
669
  /**
670
- * PostgreSQL push read-ahead: every requested row id with its stored row
671
- * (NULL columns when absent, `selectRowSql` shape) and its §5 delete
672
- * tombstone. Params: [partition, rowIds as JSON array text, tbl].
673
- */
674
- export function prefetchRowsSql(table) {
675
- 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
676
- FROM jsonb_array_elements_text($2::text::jsonb) AS ids(id)
677
- LEFT JOIN ${quoteIdent(table.name)} r
678
- ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=ids.id
679
- LEFT JOIN sync_tombstones t
680
- ON t.partition=$1 AND t.tbl=$3 AND t.row_id=ids.id`;
670
+ * PostgreSQL push read-ahead across every table a commit targets, in one
671
+ * statement: each requested row id with its stored row (NULL columns when
672
+ * absent, `selectRowSql` shape) and its §5 delete tombstone. Params:
673
+ * [partition, specs as JSON text], `specs` = `{tbl, ids: string[]}` records.
674
+ */
675
+ export function prefetchRowsBatchSql(tables, params = {
676
+ partition: '$1',
677
+ specs: '$2',
678
+ }) {
679
+ // One branch per table: a lateral join cannot switch its relation per
680
+ // record, and the union keeps the whole read-ahead one round trip.
681
+ const projection = tables
682
+ .map((table) => {
683
+ const quoted = quoteIdent(table.name);
684
+ 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
685
+ FROM ids
686
+ LEFT JOIN ${quoted} r
687
+ ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=${params.partition} AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=ids.id
688
+ LEFT JOIN sync_tombstones t
689
+ ON t.partition=${params.partition} AND t.tbl=ids.tbl AND t.row_id=ids.id
690
+ WHERE ids.tbl='${table.name.replaceAll("'", "''")}'`;
691
+ })
692
+ .join('\n UNION ALL ');
693
+ return `WITH ids AS (
694
+ SELECT spec.tbl AS tbl, entry.id AS id
695
+ FROM jsonb_to_recordset(${params.specs}::text::jsonb) AS spec(tbl text, ids jsonb)
696
+ CROSS JOIN LATERAL jsonb_array_elements_text(spec.ids) AS entry(id)
697
+ )
698
+ ${projection}`;
681
699
  }
682
700
  /**
683
701
  * SQLite/D1: the newest `commit_seq` in the inverted change-scope index for
@@ -93,12 +93,6 @@ class SqliteTransaction {
93
93
  .query('DELETE FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?')
94
94
  .run(this.#partition, table, rowId);
95
95
  }
96
- getPushResult(clientId, clientCommitId) {
97
- this.#assertOpen();
98
- // One shared SQLite connection: this read runs inside this
99
- // transaction's BEGIN IMMEDIATE.
100
- return this.#storage.getPushResult(this.#partition, clientId, clientCommitId);
101
- }
102
96
  scanRows(query) {
103
97
  this.#assertOpen();
104
98
  return this.#storage.scanRows(this.#partition, query);
@@ -113,11 +107,15 @@ class SqliteTransaction {
113
107
  // BEGIN IMMEDIATE and observes every row staged so far.
114
108
  return this.#storage.runAuthoritativeQuery(this.#partition, query);
115
109
  }
116
- async lockPartitionForPush() {
110
+ async preparePushApply(clientId, clientCommitId, runningSchemaVersion, _targets = []) {
117
111
  this.#assertOpen();
118
112
  // BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
119
113
  this.#storage.db.exec('SAVEPOINT syncular_push_candidate');
120
114
  this.#pushApplySavepoint = true;
115
+ return {
116
+ gate: await this.#storage.readServeGate(this.#partition, runningSchemaVersion),
117
+ persisted: await this.#storage.getPushResult(this.#partition, clientId, clientCommitId),
118
+ };
121
119
  }
122
120
  async advanceCheckpoint(name, ownerEpoch, watermark, observedRows, nowMs) {
123
121
  this.#assertOpen();
@@ -144,6 +142,9 @@ class SqliteTransaction {
144
142
  this.#pushApplySavepoint = false;
145
143
  await this.putPushResult(clientId, clientCommitId, result);
146
144
  await this.commit();
145
+ // The idempotency re-check found no record under the writer lock, so the
146
+ // insert above is the canonical outcome.
147
+ return result;
147
148
  }
148
149
  async upsertRow(table, row, context) {
149
150
  this.#assertOpen();
package/dist/storage.d.ts CHANGED
@@ -67,6 +67,17 @@ export interface ServeGate {
67
67
  readonly name: string;
68
68
  }[];
69
69
  }
70
+ /**
71
+ * The push apply's admission token: the partition lock, the serve gate, and
72
+ * the idempotency record read in one round trip. A storage with per-statement
73
+ * network cost answers all three with one statement (see
74
+ * `PostgresServerStorage`); the lock is held from this call through commit.
75
+ */
76
+ export interface PreparedPushApply {
77
+ readonly gate: ServeGate;
78
+ /** The already-persisted outcome for this key, or `undefined` on a miss. */
79
+ readonly persisted: StoredPushResult | undefined;
80
+ }
70
81
  /** Why `serveGate` refuses, or `undefined` when this request may serve. */
71
82
  export type ServeGateRefusal = {
72
83
  readonly kind: 'schema';
@@ -416,12 +427,15 @@ export interface StorageTransaction {
416
427
  getRow(table: string, rowId: string): Promise<StoredRow | undefined>;
417
428
  /**
418
429
  * Optional read-ahead: the push layer names every row a commit's
419
- * operations target, per table, before it applies them. A storage with
420
- * per-statement network cost reads them in one statement and answers the
421
- * following `getRow` calls from that snapshot until the row is written in
422
- * this transaction.
430
+ * operations target, across every table, before it applies them. A storage
431
+ * with per-statement network cost reads them in one statement (one branch
432
+ * per table) and answers the following `getRow` calls from that snapshot
433
+ * until the row is written in this transaction.
423
434
  */
424
- prefetchRows?(table: string, rowIds: readonly string[]): Promise<void>;
435
+ prefetchRows?(targets: readonly {
436
+ readonly table: string;
437
+ readonly rowIds: readonly string[];
438
+ }[]): Promise<void>;
425
439
  /**
426
440
  * §5 delete-precedence tombstone read: the `commit_seq` of the newest
427
441
  * applied delete of this row still within the pruning horizon, or
@@ -436,17 +450,6 @@ export interface StorageTransaction {
436
450
  * over an absent row; a no-op when no tombstone exists.
437
451
  */
438
452
  clearTombstone(table: string, rowId: string): Promise<void>;
439
- /**
440
- * Optional transaction-scoped idempotency lookup (§2.3) with the same
441
- * semantics as `ServerStorage.getPushResult`, including the
442
- * `sync.idempotency_cache_miss` throw. The push layer prefers it for the
443
- * post-serialization duplicate re-check so the read runs on the
444
- * transaction's own connection — a pooled Postgres client that holds the
445
- * partition lock must never wait for a second pool slot mid-push. In-tree
446
- * SQLite/Postgres/D1 adapters implement it; a custom adapter that omits it
447
- * keeps the pool-level read.
448
- */
449
- getPushResult?(clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
450
453
  /**
451
454
  * Optional candidate-state scan used only by whole-commit validation.
452
455
  * In-tree SQLite/Postgres/D1 backends implement it with read-your-own-writes
@@ -474,24 +477,28 @@ export interface StorageTransaction {
474
477
  queryAuthoritative?(query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
475
478
  /**
476
479
  * Serialize every push apply for this partition before any operation read,
477
- * validation, merge, or write. The push layer re-checks idempotency only
478
- * after this resolves and retains the lock through terminal-result commit.
480
+ * validation, merge, or write, and read the admission token in the same
481
+ * round trip: the RFC 0007 serve gate and the §2.3 idempotency record for
482
+ * `clientCommitId`. The lock is retained through terminal-result commit,
483
+ * so the idempotency read is exact and a racing duplicate cannot rerun.
484
+ * `targets` are the rows the commit is known to touch before its operations
485
+ * are built; a storage with per-statement network cost folds that read-ahead
486
+ * into the admission read and answers the following `getRow` calls from it.
479
487
  * Missing support fails closed before an app-row mutation.
480
488
  */
481
- lockPartitionForPush?(): Promise<void>;
482
- /**
483
- * @deprecated Implement `lockPartitionForPush`. Kept as a compatibility
484
- * bridge for custom adapters whose existing implementation already locks
485
- * the complete partition from before candidate reads through commit.
486
- */
487
- lockPartitionForCommitValidation?(): Promise<void>;
489
+ preparePushApply(clientId: string, clientCommitId: string, runningSchemaVersion: number, targets?: readonly {
490
+ readonly table: string;
491
+ readonly rowIds: readonly string[];
492
+ }[]): Promise<PreparedPushApply>;
488
493
  /**
489
494
  * Rejection finalization while the push-apply serialization lock is still
490
495
  * held: discard every candidate write, persist the rejected idempotency
491
- * result, and finish the transaction atomically. Required for every push so
492
- * a concurrent duplicate cannot rerun operations, validators, or merges.
496
+ * result, and finish the transaction atomically. Returns the persisted
497
+ * canonical outcome, so the caller never re-reads it. Required for every
498
+ * push so a concurrent duplicate cannot rerun operations, validators, or
499
+ * merges.
493
500
  */
494
- commitRejectedPushResult?(clientId: string, clientCommitId: string, result: StoredPushResult): Promise<void>;
501
+ commitRejectedPushResult(clientId: string, clientCommitId: string, result: StoredPushResult): Promise<StoredPushResult>;
495
502
  upsertRow(table: string, row: StoredRow, context?: {
496
503
  readonly opIndex: number;
497
504
  }): Promise<void>;
@@ -573,10 +580,10 @@ export interface ServerStorage {
573
580
  * same command races a push on Postgres and a SQLite-only test conceals it.
574
581
  *
575
582
  * A trusted command must therefore run through `registerRemoteCommand`,
576
- * which builds after `lockPartitionForPush` and the idempotency recheck and
577
- * shares the committed transaction, or call `lockPartitionForPush` before
578
- * its first authority, version, or replay read. SSP2 request handling does
579
- * this for you; only host-authored commands choose.
583
+ * which builds after `preparePushApply` and shares the committed
584
+ * transaction, or call `preparePushApply` before its first authority,
585
+ * version, or replay read. SSP2 request handling does this for you; only
586
+ * host-authored commands choose.
580
587
  */
581
588
  begin(partition: string): Promise<StorageTransaction>;
582
589
  getMaxCommitSeq(partition: string): Promise<number>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -68,7 +68,7 @@
68
68
  "verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
69
69
  },
70
70
  "dependencies": {
71
- "@syncular/core": "0.43.0"
71
+ "@syncular/core": "0.44.0"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"
package/src/d1-storage.ts CHANGED
@@ -131,6 +131,7 @@ import type {
131
131
  IndexRowScanQuery,
132
132
  NewCommit,
133
133
  NewReaction,
134
+ PreparedPushApply,
134
135
  PartitionRegistryEntry,
135
136
  PrunedReactionCounts,
136
137
  ReactionClaimQuery,
@@ -550,7 +551,15 @@ class D1Transaction implements StorageTransaction {
550
551
  );
551
552
  }
552
553
 
553
- async lockPartitionForPush(): Promise<void> {
554
+ async preparePushApply(
555
+ clientId: string,
556
+ clientCommitId: string,
557
+ runningSchemaVersion: number,
558
+ _targets: readonly {
559
+ readonly table: string;
560
+ readonly rowIds: readonly string[];
561
+ }[] = [],
562
+ ): Promise<PreparedPushApply> {
554
563
  this.#assertOpen();
555
564
  if (!this.#pushApplySerialized) {
556
565
  throw new Error(
@@ -560,6 +569,10 @@ class D1Transaction implements StorageTransaction {
560
569
  // D1 has no interactive lock. The caller explicitly asserted that every
561
570
  // write for this partition is already serialized (normally by its DO).
562
571
  this.#pushApplyCheckpoint = this.#buffer.length;
572
+ return {
573
+ gate: await this.readServeGate(runningSchemaVersion),
574
+ persisted: await this.getPushResult(clientId, clientCommitId),
575
+ };
563
576
  }
564
577
 
565
578
  advanceCheckpoint(
@@ -591,7 +604,7 @@ class D1Transaction implements StorageTransaction {
591
604
  clientId: string,
592
605
  clientCommitId: string,
593
606
  result: StoredPushResult,
594
- ): Promise<void> {
607
+ ): Promise<StoredPushResult> {
595
608
  this.#assertOpen();
596
609
  const checkpoint = this.#pushApplyCheckpoint;
597
610
  if (checkpoint === undefined) {
@@ -604,6 +617,9 @@ class D1Transaction implements StorageTransaction {
604
617
  this.#applicationOpIndexes.clear();
605
618
  await this.putPushResult(clientId, clientCommitId, result);
606
619
  await this.commit();
620
+ // The idempotency re-check found no record under the serialized apply, so
621
+ // the insert above is the canonical outcome.
622
+ return result;
607
623
  }
608
624
 
609
625
  async #assertNoUniqueCollision(