@syncular/server 0.42.2 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/push.d.ts CHANGED
@@ -28,6 +28,20 @@ import type { StorageTransaction, StoredCommit } from './storage.js';
28
28
  * row-codec decode (§5.9.1), so `parseBlobRef` here is total.
29
29
  */
30
30
  export declare function blobIdsInRow(table: CompiledTable, values: readonly RowValue[]): string[];
31
+ /**
32
+ * A server-authoritative scope move (§2.2). Commands emit it through
33
+ * `CommandMutation`; it is not an SSP2 wire operation, so a client cannot
34
+ * request one. Apply rewrites the stored row in place under the new scopes
35
+ * and emits the delete/upsert change pair in one commit.
36
+ */
37
+ export interface RescopeOperation {
38
+ readonly table: string;
39
+ readonly rowId: string;
40
+ readonly op: 'rescope';
41
+ /** Full-row payload (the row's values under the new scopes). */
42
+ readonly payload: Uint8Array;
43
+ }
44
+ export type ApplyOperation = PushOperation | RescopeOperation;
31
45
  export interface ProcessedPushCommit {
32
46
  readonly frame: PushResultFrame;
33
47
  /** True when this request observed an already-recorded idempotency outcome. */
@@ -54,4 +68,7 @@ export declare function processPushCommitWithTrace(ctx: SyncRequestContext, sche
54
68
  * The builder runs after the partition lock and idempotency re-check, so its
55
69
  * reads and the returned operations share the transaction that is committed.
56
70
  */
57
- export declare function processPushOperationsWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, clientCommitId: string, buildOperations: (tx: StorageTransaction) => Promise<readonly PushOperation[]>, 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
@@ -172,20 +172,149 @@ async function applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blo
172
172
  return errorRecord(opIndex, 'sync.forbidden', 'scope resolution failed (§3.4 step 4)');
173
173
  }
174
174
  const stored = await tx.getRow(op.table, op.rowId);
175
- if (op.op === 'delete') {
175
+ if (op.op === 'rescope') {
176
+ // §2.2 explicit scope move. One stored row changes its scope values;
177
+ // apply rewrites it in place, keeps `server_version`, and emits the
178
+ // delete under the old scopes plus the upsert under the new ones.
176
179
  if (stored === undefined) {
177
- // Deleting an absent row is applied (idempotent, §6.2); no change.
178
- return {
179
- kind: 'applied',
180
- change: undefined,
181
- operation: {
180
+ return errorRecord(opIndex, 'sync.row_missing', 'rescope targets an absent row (§2.2)');
181
+ }
182
+ if (!authorizeWrite(table, stored.scopes, resolved)) {
183
+ return errorRecord(opIndex, 'sync.forbidden', 'rescope denied by scope authorization (§3.4)');
184
+ }
185
+ const missing = missingScopeVariable(table, stored.scopes);
186
+ if (missing !== undefined) {
187
+ return errorRecord(opIndex, 'sync.missing_scopes', `stored row lacks scope variable ${JSON.stringify(missing)} (§3.1)`);
188
+ }
189
+ let sparse;
190
+ try {
191
+ sparse = decodeSparseRow(wireTable.columns, wireTable.primaryKeyIndex, op.payload);
192
+ }
193
+ catch (error) {
194
+ return errorRecord(opIndex, 'sync.invalid_request', `rescope payload failed row-codec decode (§1.7): ${error instanceof Error ? error.message : String(error)}`);
195
+ }
196
+ if (sparse.length < table.columns.length ||
197
+ sparse.some((value) => value === undefined)) {
198
+ return errorRecord(opIndex, 'sync.invalid_request', 'rescope requires a full-row payload (§2.2)');
199
+ }
200
+ const values = sparse.map((value) => value ?? null);
201
+ const pkValue = renderScopeValue(values[table.primaryKeyIndex] ?? null);
202
+ if (pkValue !== op.rowId) {
203
+ return errorRecord(opIndex, 'sync.invalid_request', 'payload primary key does not match rowId');
204
+ }
205
+ const storedValues = decodeRow(table.columns, stored.payload);
206
+ const scopeIndexes = new Set(table.scopePatterns.map((pattern) => pattern.columnIndex));
207
+ const sameValue = (left, right) => {
208
+ if (left instanceof Uint8Array || right instanceof Uint8Array) {
209
+ return (left instanceof Uint8Array &&
210
+ right instanceof Uint8Array &&
211
+ left.length === right.length &&
212
+ left.every((byte, index) => byte === right[index]));
213
+ }
214
+ return left === right;
215
+ };
216
+ for (let index = 0; index < table.columns.length; index++) {
217
+ if (index === table.primaryKeyIndex || scopeIndexes.has(index))
218
+ continue;
219
+ if (!sameValue(values[index] ?? null, storedValues[index] ?? null)) {
220
+ return errorRecord(opIndex, 'sync.invalid_request', 'rescope may change only scope columns (§2.2)');
221
+ }
222
+ }
223
+ const extracted = storedScopesForRow(table, values);
224
+ if ('missing' in extracted) {
225
+ return errorRecord(opIndex, 'sync.forbidden', `rescope missing scope column value for ${JSON.stringify(extracted.missing)} (§3.4)`);
226
+ }
227
+ if (!authorizeWrite(table, extracted.scopes, resolved)) {
228
+ return errorRecord(opIndex, 'sync.forbidden', 'rescope denied by scope authorization of the new scopes (§3.4)');
229
+ }
230
+ const deleteReject = await runValidator(validators, table, 'delete', op.rowId, undefined, storedValues, opIndex, tx, partition, actorId);
231
+ if (deleteReject !== undefined)
232
+ return deleteReject;
233
+ const upsertReject = await runValidator(validators, table, 'upsert', op.rowId, values, storedValues, opIndex, tx, partition, actorId);
234
+ if (upsertReject !== undefined)
235
+ return upsertReject;
236
+ const blobCheck = await checkAndRecordBlobs(tx, table, op.rowId, values, opIndex, blobCtx);
237
+ if (blobCheck !== undefined)
238
+ return blobCheck;
239
+ const payload = encodeRow(table.columns, values);
240
+ // The move clears any tombstone from an earlier incarnation: the row is
241
+ // live at the end of this commit, so the next move must not hit §5.2.
242
+ await tx.clearTombstone(op.table, op.rowId);
243
+ await tx.upsertRow(op.table, {
244
+ rowId: op.rowId,
245
+ serverVersion: stored.serverVersion,
246
+ scopes: extracted.scopes,
247
+ payload,
248
+ ...(stored.columnVersions !== undefined
249
+ ? { columnVersions: stored.columnVersions }
250
+ : {}),
251
+ }, { opIndex });
252
+ const storedRow = toValidateRow(table.columns, storedValues);
253
+ return {
254
+ kind: 'applied',
255
+ changes: [
256
+ {
257
+ table: op.table,
258
+ rowId: op.rowId,
259
+ op: 'delete',
260
+ scopes: stored.scopes,
261
+ },
262
+ {
263
+ table: op.table,
264
+ rowId: op.rowId,
265
+ op: 'upsert',
266
+ rowVersion: stored.serverVersion,
267
+ scopes: extracted.scopes,
268
+ payload,
269
+ },
270
+ ],
271
+ operations: [
272
+ {
182
273
  opIndex,
183
274
  op: 'delete',
184
275
  table: table.name,
185
276
  rowId: op.rowId,
186
277
  row: undefined,
187
- stored: undefined,
278
+ stored: storedRow,
279
+ storedServerVersion: stored.serverVersion,
280
+ },
281
+ {
282
+ opIndex,
283
+ op: 'upsert',
284
+ table: table.name,
285
+ rowId: op.rowId,
286
+ row: toValidateRow(table.columns, values),
287
+ stored: storedRow,
288
+ storedServerVersion: stored.serverVersion,
289
+ nextServerVersion: stored.serverVersion,
188
290
  },
291
+ ],
292
+ staged: {
293
+ table,
294
+ op: 'upsert',
295
+ rowId: op.rowId,
296
+ opIndex,
297
+ values,
298
+ stored,
299
+ },
300
+ };
301
+ }
302
+ if (op.op === 'delete') {
303
+ if (stored === undefined) {
304
+ // Deleting an absent row is applied (idempotent, §6.2); no change.
305
+ return {
306
+ kind: 'applied',
307
+ changes: [],
308
+ operations: [
309
+ {
310
+ opIndex,
311
+ op: 'delete',
312
+ table: table.name,
313
+ rowId: op.rowId,
314
+ row: undefined,
315
+ stored: undefined,
316
+ },
317
+ ],
189
318
  staged: {
190
319
  table,
191
320
  op: 'delete',
@@ -212,21 +341,25 @@ async function applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blo
212
341
  await tx.deleteRow(op.table, op.rowId);
213
342
  return {
214
343
  kind: 'applied',
215
- change: {
216
- table: op.table,
217
- rowId: op.rowId,
218
- op: 'delete',
219
- scopes: stored.scopes,
220
- },
221
- operation: {
222
- opIndex,
223
- op: 'delete',
224
- table: table.name,
225
- rowId: op.rowId,
226
- row: undefined,
227
- stored: toValidateRow(table.columns, storedValues),
228
- storedServerVersion: stored.serverVersion,
229
- },
344
+ changes: [
345
+ {
346
+ table: op.table,
347
+ rowId: op.rowId,
348
+ op: 'delete',
349
+ scopes: stored.scopes,
350
+ },
351
+ ],
352
+ operations: [
353
+ {
354
+ opIndex,
355
+ op: 'delete',
356
+ table: table.name,
357
+ rowId: op.rowId,
358
+ row: undefined,
359
+ stored: toValidateRow(table.columns, storedValues),
360
+ storedServerVersion: stored.serverVersion,
361
+ },
362
+ ],
230
363
  staged: { table, op: 'delete', rowId: op.rowId, opIndex, stored },
231
364
  };
232
365
  }
@@ -354,24 +487,28 @@ async function applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blo
354
487
  await tx.upsertRow(op.table, newRow, { opIndex });
355
488
  return {
356
489
  kind: 'applied',
357
- change: {
358
- table: op.table,
359
- rowId: op.rowId,
360
- op: 'upsert',
361
- rowVersion: newVersion,
362
- scopes: stored.scopes,
363
- payload: newPayload,
364
- },
365
- operation: {
366
- opIndex,
367
- op: 'upsert',
368
- table: table.name,
369
- rowId: op.rowId,
370
- row: toValidateRow(table.columns, values),
371
- stored: toValidateRow(table.columns, storedValues),
372
- storedServerVersion: stored.serverVersion,
373
- nextServerVersion: newVersion,
374
- },
490
+ changes: [
491
+ {
492
+ table: op.table,
493
+ rowId: op.rowId,
494
+ op: 'upsert',
495
+ rowVersion: newVersion,
496
+ scopes: stored.scopes,
497
+ payload: newPayload,
498
+ },
499
+ ],
500
+ operations: [
501
+ {
502
+ opIndex,
503
+ op: 'upsert',
504
+ table: table.name,
505
+ rowId: op.rowId,
506
+ row: toValidateRow(table.columns, values),
507
+ stored: toValidateRow(table.columns, storedValues),
508
+ storedServerVersion: stored.serverVersion,
509
+ nextServerVersion: newVersion,
510
+ },
511
+ ],
375
512
  staged: { table, op: 'upsert', rowId: op.rowId, opIndex, values, stored },
376
513
  };
377
514
  }
@@ -440,23 +577,27 @@ async function applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blo
440
577
  await tx.upsertRow(op.table, newRow, { opIndex });
441
578
  return {
442
579
  kind: 'applied',
443
- change: {
444
- table: op.table,
445
- rowId: op.rowId,
446
- op: 'upsert',
447
- rowVersion: 1,
448
- scopes: extracted.scopes,
449
- payload: insertPayload,
450
- },
451
- operation: {
452
- opIndex,
453
- op: 'upsert',
454
- table: table.name,
455
- rowId: op.rowId,
456
- row: toValidateRow(table.columns, values),
457
- stored: undefined,
458
- nextServerVersion: 1,
459
- },
580
+ changes: [
581
+ {
582
+ table: op.table,
583
+ rowId: op.rowId,
584
+ op: 'upsert',
585
+ rowVersion: 1,
586
+ scopes: extracted.scopes,
587
+ payload: insertPayload,
588
+ },
589
+ ],
590
+ operations: [
591
+ {
592
+ opIndex,
593
+ op: 'upsert',
594
+ table: table.name,
595
+ rowId: op.rowId,
596
+ row: toValidateRow(table.columns, values),
597
+ stored: undefined,
598
+ nextServerVersion: 1,
599
+ },
600
+ ],
460
601
  staged: {
461
602
  table,
462
603
  op: 'upsert',
@@ -856,80 +997,54 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
856
997
  * cache provenance needed by structured events and server helpers.
857
998
  */
858
999
  export async function processPushCommitWithTrace(ctx, schema, resolved, clientId, frame, wireSchema = schema) {
859
- 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 })));
860
1014
  }
861
1015
  /**
862
1016
  * Shared serialized apply path for SSP2 commits and authoritative commands.
863
1017
  * The builder runs after the partition lock and idempotency re-check, so its
864
1018
  * reads and the returned operations share the transaction that is committed.
865
1019
  */
866
- export async function processPushOperationsWithTrace(ctx, schema, resolved, clientId, clientCommitId, buildOperations, wireSchema = schema) {
867
- const { storage, partition } = ctx;
868
- let persisted;
869
- try {
870
- persisted = await storage.getPushResult(partition, clientId, clientCommitId);
871
- }
872
- catch (error) {
873
- if (error instanceof SyncError &&
874
- error.code === 'sync.idempotency_cache_miss') {
875
- // §6.3: answer the retryable cache-miss for this commit rather than
876
- // re-applying. Not persisted — a retry may find a readable record.
877
- return {
878
- frame: idempotencyCacheMissFrame(clientCommitId, error),
879
- replayed: false,
880
- };
881
- }
882
- throw error;
883
- }
884
- if (persisted !== undefined) {
885
- return processedPushCommit(clientCommitId, persisted, true);
886
- }
1020
+ export async function processPushOperationsWithTrace(ctx, schema, resolved, clientId, clientCommitId, buildOperations, wireSchema = schema, prefetchTargets) {
1021
+ const { partition } = ctx;
887
1022
  const createdAtMs = clockOf(ctx)();
888
1023
  const blobCtx = { store: ctx.blobs, partition };
889
1024
  const crdtMergers = ctx.crdtMergers;
890
1025
  const validators = ctx.validators;
891
1026
  const commitValidator = ctx.commitValidator;
892
1027
  const reactionPlanner = ctx.reactionPlanner;
893
- const tx = await storage.begin(partition);
894
- const lockPartitionForPush = tx.lockPartitionForPush?.bind(tx) ??
895
- tx.lockPartitionForCommitValidation?.bind(tx);
896
- const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
1028
+ const tx = await ctx.storage.begin(partition);
897
1029
  try {
898
- if (lockPartitionForPush === undefined ||
899
- commitRejectedPushResult === undefined ||
900
- (reactionPlanner !== undefined && tx.enqueueReactions === undefined)) {
901
- 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');
902
1032
  }
903
- await lockPartitionForPush();
904
- // RFC 0007: evaluate the gate on this transaction's own connection while
905
- // the partition lock is held, so the migration check and the write share
906
- // one transaction and no migration can interleave in between.
907
- const gate = await tx.readServeGate(schema.version);
908
- const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
909
- if (refusal !== undefined) {
910
- await tx.rollback();
911
- throw serveNotReadyError(refusal);
912
- }
913
- // The optimistic lookup above may have raced another delivery. Re-check
914
- // only after acquiring partition serialization and before any operation
915
- // read, validation, merge, or staged write. The re-check runs on the
916
- // transaction's own connection when the backend provides one: a pooled
917
- // Postgres client holding the partition lock would otherwise wait for a
918
- // second pool slot and deadlock against pushes waiting on the lock. A
919
- // racing duplicate's result commits before the lock releases, so the
920
- // 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;
921
1040
  try {
922
- const serializedPersisted = tx.getPushResult !== undefined
923
- ? await tx.getPushResult(clientId, clientCommitId)
924
- : await storage.getPushResult(partition, clientId, clientCommitId);
925
- if (serializedPersisted !== undefined) {
926
- await tx.rollback();
927
- return processedPushCommit(clientCommitId, serializedPersisted, true);
928
- }
1041
+ prepared = await tx.preparePushApply(clientId, clientCommitId, schema.version, prefetchTargets);
929
1042
  }
930
1043
  catch (error) {
931
1044
  if (error instanceof SyncError &&
932
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.
933
1048
  await tx.rollback();
934
1049
  return {
935
1050
  frame: idempotencyCacheMissFrame(clientCommitId, error),
@@ -938,6 +1053,18 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
938
1053
  }
939
1054
  throw error;
940
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
+ }
941
1068
  const results = [];
942
1069
  const changes = [];
943
1070
  const validatedOperations = [];
@@ -953,8 +1080,8 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
953
1080
  ids.push(op.rowId);
954
1081
  targets.set(op.table, ids);
955
1082
  }
956
- for (const [table, rowIds] of targets) {
957
- await tx.prefetchRows(table, rowIds);
1083
+ if (targets.size > 0) {
1084
+ await tx.prefetchRows([...targets].map(([table, rowIds]) => ({ table, rowIds })));
958
1085
  }
959
1086
  }
960
1087
  for (let opIndex = 0; opIndex < operations.length; opIndex++) {
@@ -967,10 +1094,9 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
967
1094
  break;
968
1095
  }
969
1096
  results.push({ opIndex, status: 'applied' });
970
- validatedOperations.push(outcome.operation);
1097
+ validatedOperations.push(...outcome.operations);
971
1098
  stagedWrites.push(outcome.staged);
972
- if (outcome.change !== undefined)
973
- changes.push(outcome.change);
1099
+ changes.push(...outcome.changes);
974
1100
  }
975
1101
  // §6.11: reference enforcement runs after every client operation is
976
1102
  // staged (so the candidate state is final for the client's own writes)
@@ -1021,12 +1147,9 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
1021
1147
  results: [terminated],
1022
1148
  });
1023
1149
  // Discard candidates and persist the rejection while retaining the same
1024
- // partition lock. There is no unlock gap in which a duplicate can rerun.
1025
- await commitRejectedPushResult(clientId, clientCommitId, stored);
1026
- const canonical = await storage.getPushResult(partition, clientId, clientCommitId);
1027
- if (canonical === undefined) {
1028
- throw new Error('push rejection finalization did not persist an outcome');
1029
- }
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);
1030
1153
  return processedPushCommit(clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
1031
1154
  }
1032
1155
  const commitSeq = await tx.appendCommit({
@@ -1104,16 +1227,8 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
1104
1227
  },
1105
1228
  ],
1106
1229
  });
1107
- if (commitRejectedPushResult === undefined) {
1108
- await tx.rollback();
1109
- throw new Error('storage transaction lost atomic push rejection finalization support');
1110
- }
1111
1230
  try {
1112
- await commitRejectedPushResult(clientId, clientCommitId, stored);
1113
- const canonical = await storage.getPushResult(partition, clientId, clientCommitId);
1114
- if (canonical === undefined) {
1115
- throw new Error('push rejection finalization did not persist an outcome');
1116
- }
1231
+ const canonical = await tx.commitRejectedPushResult(clientId, clientCommitId, stored);
1117
1232
  return processedPushCommit(clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
1118
1233
  }
1119
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
@@ -1,7 +1,7 @@
1
1
  import { retainRollbackFailure, rollbackAfterError } from './storage-errors.js';
2
2
  import { validateCommitPruneQuery } from './prune.js';
3
3
  import { StorageQueryError } from './storage-errors.js';
4
- import { clientRoundState, PARTITION_TOUCH_INTERVAL_MS, serveGateRefusal, } from './storage.js';
4
+ import { clientRoundState, PARTITION_TOUCH_INTERVAL_MS, rowsLiveAfterDelete, serveGateRefusal, } from './storage.js';
5
5
  import { serveNotReadyError } from './readiness.js';
6
6
  /**
7
7
  * SQLite server storage over the shared synchronous driver.
@@ -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();
@@ -186,11 +187,14 @@ class SqliteTransaction {
186
187
  const commitSeq = (row?.max_commit_seq ?? 0) + 1;
187
188
  db.query('UPDATE sync_partitions SET max_commit_seq=? WHERE partition=?').run(commitSeq, p);
188
189
  db.query('INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms, writer_version) VALUES (?,?,?,?,?,?,?)').run(p, commitSeq, commit.clientId, commit.clientCommitId, commit.actorId, commit.createdAtMs, this.#writerVersion);
190
+ const liveAfterDelete = rowsLiveAfterDelete(commit.changes);
189
191
  commit.changes.forEach((change, idx) => {
190
192
  db.query('INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload) VALUES (?,?,?,?,?,?,?,?,?)').run(p, commitSeq, idx, change.table, change.rowId, change.op === 'upsert' ? 1 : 2, change.rowVersion ?? null, JSON.stringify(change.scopes), change.payload ?? null);
191
- if (change.op === 'delete') {
193
+ if (change.op === 'delete' &&
194
+ !liveAfterDelete.has(`${change.table}\u0000${change.rowId}`)) {
192
195
  // §5 delete precedence: every applied delete leaves a tombstone,
193
- // pruned with the commit log (§4.6).
196
+ // pruned with the commit log (§4.6). A delete that the same commit
197
+ // follows with an upsert is a §2.2 scope move, not a deletion.
194
198
  db.query(`INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq) VALUES (?,?,?,?)
195
199
  ON CONFLICT(partition, tbl, row_id) DO UPDATE SET commit_seq=excluded.commit_seq`).run(p, change.table, change.rowId, commitSeq);
196
200
  }