@syncular/server 0.20.1 → 0.21.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.js CHANGED
@@ -17,12 +17,13 @@
17
17
  * merge); a validator throw rejects the whole commit atomically with a
18
18
  * host code.
19
19
  */
20
- import { decodeRow, encodeRow, parseBlobRef, } from '@syncular/core';
21
- import { clockOf } from './context.js';
20
+ import { decodeRow, decodeSparseRow, encodeRow, parseBlobRef, } from '@syncular/core';
21
+ import { clockOf, limitsOf } from './context.js';
22
22
  import { SyncError } from './errors.js';
23
23
  import { emitEvent } from './events.js';
24
24
  import { prepareReactions, toNewReactions, } from './reactions.js';
25
25
  import { authorizeWrite, renderScopeValue, storedScopesForRow } from './scopes.js';
26
+ import { decodeColumnVersions, encodeColumnVersions } from './relational-rows.js';
26
27
  import { StorageConstraintError } from './storage-errors.js';
27
28
  import { CommitValidationRejection, toValidateRow, ValidationRejection, } from './validate.js';
28
29
  /**
@@ -52,19 +53,28 @@ function errorRecord(opIndex, code, message, retryable = false, details) {
52
53
  },
53
54
  };
54
55
  }
55
- function conflictRecord(opIndex, serverVersion, serverRow) {
56
+ function conflictRecord(opIndex, serverVersion, serverRow, conflictColumns) {
56
57
  return {
57
58
  kind: 'terminate',
58
59
  record: {
59
60
  opIndex,
60
61
  status: 'conflict',
61
62
  code: 'sync.version_conflict',
62
- message: 'row version does not match baseVersion (§6.2)',
63
+ message: 'present columns moved past baseVersion (§6.2)',
63
64
  serverVersion,
64
65
  serverRow,
66
+ conflictColumns,
65
67
  },
66
68
  };
67
69
  }
70
+ /** §6.3 `conflictColumns`: a presence-layout bitmap over the marked columns. */
71
+ function conflictColumnsBitmap(columnCount, marked) {
72
+ const bytes = new Uint8Array(Math.ceil(columnCount / 8));
73
+ for (const index of marked) {
74
+ bytes[index >> 3] = (bytes[index >> 3] ?? 0) | (1 << (index & 7));
75
+ }
76
+ return bytes;
77
+ }
68
78
  /**
69
79
  * §6.7: run the table's write-validation hook, if configured, on the row
70
80
  * that WILL persist (post scope-strip, post CRDT-merge — the values the
@@ -112,13 +122,17 @@ async function runValidator(validators, table, op, rowId, values, storedValues,
112
122
  *
113
123
  * A NULL incoming crdt value is a semantic clear, not a merge — it passes
114
124
  * through untouched (the app is nulling the column, the same as any other
115
- * type). Merging only runs for a non-NULL incoming crdt value.
125
+ * type). Merging only runs for a non-NULL incoming crdt value. `present`
126
+ * marks the sparse payload's present columns (§6.2): an absent crdt column
127
+ * is unchanged and never merges.
116
128
  */
117
- async function mergeCrdtColumns(table, values, storedValues, opIndex, mergers) {
129
+ async function mergeCrdtColumns(table, values, storedValues, present, opIndex, mergers) {
118
130
  if (table.crdtColumns.length === 0)
119
131
  return { changed: false };
120
132
  let changed = false;
121
133
  for (const { index, crdtType } of table.crdtColumns) {
134
+ if (!present[index])
135
+ continue;
122
136
  const incoming = values[index];
123
137
  if (!(incoming instanceof Uint8Array))
124
138
  continue; // NULL clear or absent
@@ -163,6 +177,13 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
163
177
  row: undefined,
164
178
  stored: undefined,
165
179
  },
180
+ staged: {
181
+ table,
182
+ op: 'delete',
183
+ rowId: op.rowId,
184
+ opIndex,
185
+ stored: undefined,
186
+ },
166
187
  };
167
188
  }
168
189
  if (!authorizeWrite(table, stored.scopes, resolved)) {
@@ -197,74 +218,126 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
197
218
  stored: toValidateRow(table.columns, storedValues),
198
219
  storedServerVersion: stored.serverVersion,
199
220
  },
221
+ staged: { table, op: 'delete', rowId: op.rowId, opIndex, stored },
200
222
  };
201
223
  }
202
- // upsert — payload presence is enforced by the envelope codec (§6.1).
224
+ // upsert — payload presence is enforced by the envelope codec (§6.1). The
225
+ // payload is a sparse row (§2.4, wire version 3): `undefined` marks an
226
+ // absent column the apply leaves unchanged.
203
227
  const payload = op.payload;
204
228
  if (payload === undefined) {
205
229
  return errorRecord(opIndex, 'sync.invalid_request', 'upsert without payload');
206
230
  }
207
- let values;
231
+ let sparse;
208
232
  try {
209
- values = decodeRow(table.columns, payload);
233
+ sparse = decodeSparseRow(table.columns, table.primaryKeyIndex, payload);
210
234
  }
211
235
  catch (error) {
212
236
  return errorRecord(opIndex, 'sync.invalid_request', `row payload failed row-codec decode (§1.7): ${error instanceof Error ? error.message : String(error)}`);
213
237
  }
214
- const pkValue = renderScopeValue(values[table.primaryKeyIndex]);
238
+ const pkValue = renderScopeValue(sparse[table.primaryKeyIndex] ?? null);
215
239
  if (pkValue !== op.rowId) {
216
240
  return errorRecord(opIndex, 'sync.invalid_request', 'payload primary key does not match rowId');
217
241
  }
242
+ const present = sparse.map((value) => value !== undefined);
243
+ const presentCount = present.filter(Boolean).length;
218
244
  if (stored !== undefined) {
219
245
  // §3.4 step 2: authorize against the STORED row, never the payload.
220
246
  if (!authorizeWrite(table, stored.scopes, resolved)) {
221
247
  return errorRecord(opIndex, 'sync.forbidden', 'write denied by scope authorization (§3.4)');
222
248
  }
223
- if (op.baseVersion === 0) {
224
- // Lost insert race (§6.2); the stored row was authorized above,
225
- // so disclosure of the winner is permitted.
226
- return conflictRecord(opIndex, stored.serverVersion, stored.payload);
227
- }
228
- if (op.baseVersion !== undefined &&
229
- op.baseVersion !== stored.serverVersion) {
230
- return conflictRecord(opIndex, stored.serverVersion, stored.payload);
231
- }
232
- // §3.4 rule 5: scope columns are immutable on update — keep the
233
- // stored row's scope column values on both the baseVersion and
234
- // last-write-wins paths.
235
249
  const storedValues = decodeRow(table.columns, stored.payload);
236
- let mutated = false;
250
+ // §3.4 rule 5 / §6.2: scope columns are immutable on update. A present
251
+ // scope column whose value differs from the stored row is
252
+ // sync.invalid_request; present and equal applies as a no-op.
237
253
  for (const pattern of table.scopePatterns) {
238
- const storedValue = storedValues[pattern.columnIndex] ?? null;
239
- if (values[pattern.columnIndex] !== storedValue) {
240
- values[pattern.columnIndex] = storedValue;
241
- mutated = true;
254
+ const incoming = sparse[pattern.columnIndex];
255
+ if (incoming === undefined)
256
+ continue;
257
+ if ((incoming ?? null) !== (storedValues[pattern.columnIndex] ?? null)) {
258
+ return errorRecord(opIndex, 'sync.invalid_request', 'scope column is immutable on update (§3.4)');
259
+ }
260
+ }
261
+ if (op.baseVersion !== undefined && op.baseVersion > stored.serverVersion) {
262
+ return errorRecord(opIndex, 'sync.invalid_request', 'baseVersion exceeds the stored server version (§6.2)');
263
+ }
264
+ // §2.2 column versions. A row stored before column versions carries no
265
+ // blob: every column sits at the row's current serverVersion. With a
266
+ // blob, an unlisted column sits at 1.
267
+ const storedVersions = decodeColumnVersions(stored.columnVersions);
268
+ const effectiveVersion = (index) => storedVersions.get(index) ??
269
+ (stored.columnVersions === undefined ? stored.serverVersion : 1);
270
+ const crdtIndexes = new Set(table.crdtColumns.map((column) => column.index));
271
+ if (op.baseVersion === 0) {
272
+ // Lost insert race (§6.2); the stored row was authorized above, so
273
+ // disclosure of the winner is permitted. Every present non-crdt
274
+ // column exceeds baseVersion 0.
275
+ const marked = [];
276
+ for (let i = 0; i < table.columns.length; i++) {
277
+ if (present[i] && !crdtIndexes.has(i) && i !== table.primaryKeyIndex) {
278
+ marked.push(i);
279
+ }
280
+ }
281
+ return conflictRecord(opIndex, stored.serverVersion, stored.payload, conflictColumnsBitmap(table.columns.length, marked));
282
+ }
283
+ if (op.baseVersion !== undefined) {
284
+ // §6.2: conflict when any present non-crdt column moved past
285
+ // baseVersion. crdt columns merge and never conflict.
286
+ const marked = [];
287
+ for (let i = 0; i < table.columns.length; i++) {
288
+ if (present[i] &&
289
+ !crdtIndexes.has(i) &&
290
+ effectiveVersion(i) > op.baseVersion) {
291
+ marked.push(i);
292
+ }
293
+ }
294
+ if (marked.length > 0) {
295
+ return conflictRecord(opIndex, stored.serverVersion, stored.payload, conflictColumnsBitmap(table.columns.length, marked));
242
296
  }
243
297
  }
244
- // §5.10.3: crdt columns merge (stored ⊕ incoming) — never LWW, never
245
- // baseVersion-conflict (they were excluded from the checks above).
246
- const mergeOutcome = await mergeCrdtColumns(table, values, storedValues, opIndex, crdtMergers);
298
+ // §6.2 apply: write the present non-crdt columns, merge the present
299
+ // crdt columns (stored ⊕ incoming, §5.10.3), leave absent columns
300
+ // unchanged, and increment server_version.
301
+ const values = [...storedValues];
302
+ for (let i = 0; i < table.columns.length; i++) {
303
+ if (!present[i])
304
+ continue;
305
+ values[i] = sparse[i] ?? null;
306
+ }
307
+ const mergeOutcome = await mergeCrdtColumns(table, values, storedValues, present, opIndex, crdtMergers);
247
308
  if ('kind' in mergeOutcome)
248
309
  return mergeOutcome;
249
- if (mergeOutcome.changed)
250
- mutated = true;
251
- const newPayload = mutated ? encodeRow(table.columns, values) : payload;
252
310
  const newVersion = stored.serverVersion + 1;
311
+ const nextVersions = new Map();
312
+ for (let i = 0; i < table.columns.length; i++) {
313
+ // The primary key is immutable: its version never advances past the
314
+ // insert, so it never enters conflictColumns.
315
+ const version = present[i] && i !== table.primaryKeyIndex
316
+ ? newVersion
317
+ : effectiveVersion(i);
318
+ if (version > 1)
319
+ nextVersions.set(i, version);
320
+ }
321
+ // The stored payload is always the full-row codec (§2.4): the sparse
322
+ // push payload never reaches storage, segments, or COMMIT frames.
323
+ const newPayload = encodeRow(table.columns, values);
253
324
  // §6.6 / §5.9.6: verify referenced blobs exist before writing.
254
325
  const blobCheck = await checkAndRecordBlobs(tx, table, op.rowId, values, opIndex, blobCtx);
255
326
  if (blobCheck !== undefined)
256
327
  return blobCheck;
257
- // §6.7: validate the merged, scope-stripped row that will persist —
258
- // for a crdt column the validator sees the MERGED value (§5.10.3), the
259
- // state the store holds, not the raw pushed update.
328
+ // §6.7: validate the merged row that will persist — for a crdt column
329
+ // the validator sees the MERGED value (§5.10.3), the state the store
330
+ // holds, not the raw pushed update.
260
331
  const updateReject = await runValidator(validators, table, 'upsert', op.rowId, values, storedValues, opIndex, partition, actorId);
261
332
  if (updateReject !== undefined)
262
333
  return updateReject;
334
+ const columnVersions = encodeColumnVersions(nextVersions);
263
335
  const newRow = {
264
336
  rowId: op.rowId,
265
337
  serverVersion: newVersion,
266
338
  scopes: stored.scopes,
267
339
  payload: newPayload,
340
+ ...(columnVersions !== undefined ? { columnVersions } : {}),
268
341
  };
269
342
  await tx.upsertRow(op.table, newRow, { opIndex });
270
343
  return {
@@ -287,9 +360,11 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
287
360
  storedServerVersion: stored.serverVersion,
288
361
  nextServerVersion: newVersion,
289
362
  },
363
+ staged: { table, op: 'upsert', rowId: op.rowId, opIndex, values, stored },
290
364
  };
291
365
  }
292
- // Insert path: no stored row.
366
+ // Insert path: no stored row (§5.2).
367
+ const values = sparse.map((value) => value ?? null);
293
368
  if (op.baseVersion !== undefined && op.baseVersion !== 0) {
294
369
  // Authorize the payload first so absence is not disclosed to actors
295
370
  // without the scope; then §6.2: baseVersion ≠ 0, row absent.
@@ -300,6 +375,18 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
300
375
  }
301
376
  return errorRecord(opIndex, 'sync.row_missing', 'upsert with baseVersion targets an absent row (§6.2)');
302
377
  }
378
+ if (op.baseVersion === undefined) {
379
+ // §5.2: a delete within the retention horizon beats an unversioned
380
+ // upsert; an explicit insert (baseVersion 0) recreates instead.
381
+ const tombstoneSeq = await tx.getTombstoneSeq(op.table, op.rowId);
382
+ if (tombstoneSeq !== undefined) {
383
+ return errorRecord(opIndex, 'sync.row_deleted', 'a deleted row rejects an unversioned upsert within the retention horizon (§5.2)');
384
+ }
385
+ }
386
+ if (presentCount < table.columns.length) {
387
+ // §6.3: an insert requires every column present.
388
+ return errorRecord(opIndex, 'sync.row_missing', 'a partial payload targets an absent row (§6.3)');
389
+ }
303
390
  const extracted = storedScopesForRow(table, values);
304
391
  if ('missing' in extracted) {
305
392
  // §3.4 step 2: a missing or empty scope column value ⇒ deny.
@@ -310,12 +397,12 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
310
397
  }
311
398
  // §5.10.3: on insert a crdt column merges against the empty document
312
399
  // (stored = null) — normalizes the initial state through the merger.
313
- const insertMerge = await mergeCrdtColumns(table, values, undefined, opIndex, crdtMergers);
400
+ const insertMerge = await mergeCrdtColumns(table, values, undefined, present, opIndex, crdtMergers);
314
401
  if ('kind' in insertMerge)
315
402
  return insertMerge;
316
- const insertPayload = insertMerge.changed
317
- ? encodeRow(table.columns, values)
318
- : payload;
403
+ // The stored payload is the full-row codec (§2.4); every column is
404
+ // present on an insert.
405
+ const insertPayload = encodeRow(table.columns, values);
319
406
  // §6.6 / §5.9.6: verify referenced blobs exist before writing.
320
407
  const blobCheck = await checkAndRecordBlobs(tx, table, op.rowId, values, opIndex, blobCtx);
321
408
  if (blobCheck !== undefined)
@@ -326,6 +413,8 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
326
413
  const insertReject = await runValidator(validators, table, 'upsert', op.rowId, values, undefined, opIndex, partition, actorId);
327
414
  if (insertReject !== undefined)
328
415
  return insertReject;
416
+ // §5.2: an explicit insert recreates — the tombstone goes with it.
417
+ await tx.clearTombstone(op.table, op.rowId);
329
418
  const newRow = {
330
419
  rowId: op.rowId,
331
420
  serverVersion: 1,
@@ -352,6 +441,14 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
352
441
  stored: undefined,
353
442
  nextServerVersion: 1,
354
443
  },
444
+ staged: {
445
+ table,
446
+ op: 'upsert',
447
+ rowId: op.rowId,
448
+ opIndex,
449
+ values,
450
+ stored: undefined,
451
+ },
355
452
  };
356
453
  }
357
454
  /**
@@ -456,6 +553,212 @@ async function runCommitValidator(validator, tx, schema, clientId, clientCommitI
456
553
  }
457
554
  return undefined;
458
555
  }
556
+ /** §6.3.1 reference values are bounded to 256 encoded bytes; an over-long
557
+ * row id still rejects with the same code and reason, and the parent table
558
+ * name alone names the reference. */
559
+ function parentReference(parentTable, parentRowId) {
560
+ return new TextEncoder().encode(parentRowId).length <= 256
561
+ ? { parent: parentTable, row: parentRowId }
562
+ : { parent: parentTable };
563
+ }
564
+ function referenceRejection(opIndex, message, details) {
565
+ return {
566
+ opIndex,
567
+ status: 'error',
568
+ code: 'sync.reference_violation',
569
+ message,
570
+ retryable: false,
571
+ details,
572
+ };
573
+ }
574
+ /**
575
+ * §6.11 declared-reference enforcement, once per commit after every client
576
+ * operation is staged and before whole-commit validation: a present non-null
577
+ * reference column must name a live parent, and a parent delete expands
578
+ * through `CASCADE` / `SET NULL` or rejects `restricted_delete` under
579
+ * `RESTRICT`. Candidate state is read through the transaction, so a commit
580
+ * that deletes a parent and its children together passes. Appended
581
+ * operations join the §6.8 staged operation list (`opIndex` ≥ the client
582
+ * operation count) and emit ordinary changes carrying the child's stored
583
+ * scopes; a rejection attributes to the originating client delete.
584
+ */
585
+ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit, validators, partition, actorId) {
586
+ const changes = [];
587
+ const operations = [];
588
+ const originByIndex = [];
589
+ for (const write of staged) {
590
+ if (write.op !== 'upsert' || write.values === undefined)
591
+ continue;
592
+ for (const reference of write.table.references) {
593
+ const value = write.values[reference.columnIndex];
594
+ if (value === null || value === undefined)
595
+ continue;
596
+ const parentRowId = renderScopeValue(value);
597
+ if (parentRowId === undefined)
598
+ continue;
599
+ if ((await tx.getRow(reference.parentTable, parentRowId)) !== undefined) {
600
+ continue;
601
+ }
602
+ return {
603
+ kind: 'terminate',
604
+ record: referenceRejection(write.opIndex, 'declared reference names an absent parent row (§6.11)', {
605
+ reason: 'missing_parent',
606
+ fieldPaths: [reference.column],
607
+ references: parentReference(reference.parentTable, parentRowId),
608
+ }),
609
+ };
610
+ }
611
+ }
612
+ const schedule = (table, rowId, originOpIndex, scheduled, queue) => {
613
+ const key = `${table.name}\u0000${rowId}`;
614
+ if (scheduled.has(key))
615
+ return false;
616
+ scheduled.add(key);
617
+ queue.push({ table, rowId, originOpIndex });
618
+ return true;
619
+ };
620
+ const scheduled = new Set();
621
+ const processed = new Set();
622
+ const queue = [];
623
+ for (const write of staged) {
624
+ if (write.op === 'delete' && write.stored !== undefined) {
625
+ schedule(write.table, write.rowId, write.opIndex, scheduled, queue);
626
+ }
627
+ }
628
+ for (let cursor = 0; cursor < queue.length; cursor++) {
629
+ const item = queue[cursor];
630
+ if (item === undefined)
631
+ continue;
632
+ const key = `${item.table.name}\u0000${item.rowId}`;
633
+ if (processed.has(key))
634
+ continue;
635
+ processed.add(key);
636
+ for (const reference of item.table.referencedBy) {
637
+ if (tx.scanRowsByIndex === undefined) {
638
+ throw new Error('storage transaction does not support declared-reference enforcement (scanRowsByIndex)');
639
+ }
640
+ const children = [];
641
+ let afterRowId = null;
642
+ for (;;) {
643
+ const page = await tx.scanRowsByIndex({
644
+ table: reference.table,
645
+ index: reference.index,
646
+ values: [item.rowId],
647
+ afterRowId,
648
+ limit: 1_000,
649
+ });
650
+ children.push(...page);
651
+ if (page.length < 1_000)
652
+ break;
653
+ afterRowId = page[page.length - 1]?.rowId ?? null;
654
+ }
655
+ if (children.length === 0)
656
+ continue;
657
+ if (reference.onDelete === 'RESTRICT') {
658
+ return {
659
+ kind: 'terminate',
660
+ record: referenceRejection(item.originOpIndex, 'delete is blocked by a declared reference with ON DELETE RESTRICT (§6.11)', {
661
+ reason: 'restricted_delete',
662
+ references: { child: reference.table },
663
+ }),
664
+ };
665
+ }
666
+ const child = schema.tables.get(reference.table);
667
+ if (child === undefined) {
668
+ throw new Error(`unreachable: reference child table ${reference.table} is not compiled`);
669
+ }
670
+ for (const childRow of children) {
671
+ if (originByIndex.length >= cascadeLimit) {
672
+ return {
673
+ kind: 'terminate',
674
+ record: referenceRejection(item.originOpIndex, 'declared-reference cascade exceeded the per-commit operation cap (§6.11)', { reason: 'cascade_limit' }),
675
+ };
676
+ }
677
+ if (!schedule(child, childRow.rowId, item.originOpIndex, scheduled, queue)) {
678
+ continue;
679
+ }
680
+ const syntheticIndex = clientOpCount + originByIndex.length;
681
+ const storedValues = decodeRow(child.columns, childRow.payload);
682
+ if (reference.onDelete === 'CASCADE') {
683
+ const reject = await runValidator(validators, child, 'delete', childRow.rowId, undefined, storedValues, item.originOpIndex, partition, actorId);
684
+ if (reject !== undefined) {
685
+ return { kind: 'terminate', record: reject.record };
686
+ }
687
+ await tx.deleteRow(child.name, childRow.rowId);
688
+ changes.push({
689
+ table: child.name,
690
+ rowId: childRow.rowId,
691
+ op: 'delete',
692
+ scopes: childRow.scopes,
693
+ });
694
+ operations.push({
695
+ opIndex: syntheticIndex,
696
+ op: 'delete',
697
+ table: child.name,
698
+ rowId: childRow.rowId,
699
+ row: undefined,
700
+ stored: toValidateRow(child.columns, storedValues),
701
+ storedServerVersion: childRow.serverVersion,
702
+ });
703
+ }
704
+ else {
705
+ const values = [...storedValues];
706
+ values[reference.columnIndex] = null;
707
+ const reject = await runValidator(validators, child, 'upsert', childRow.rowId, values, storedValues, item.originOpIndex, partition, actorId);
708
+ if (reject !== undefined) {
709
+ return { kind: 'terminate', record: reject.record };
710
+ }
711
+ const nextServerVersion = childRow.serverVersion + 1;
712
+ // §6.2: the SET NULL upsert writes only the reference column, so
713
+ // only that column's version moves.
714
+ const storedVersions = decodeColumnVersions(childRow.columnVersions);
715
+ const nextVersions = new Map();
716
+ for (let i = 0; i < child.columns.length; i++) {
717
+ const version = i === reference.columnIndex
718
+ ? nextServerVersion
719
+ : (storedVersions.get(i) ??
720
+ (childRow.columnVersions === undefined
721
+ ? childRow.serverVersion
722
+ : 1));
723
+ if (version > 1)
724
+ nextVersions.set(i, version);
725
+ }
726
+ const payload = encodeRow(child.columns, values);
727
+ const cascadeVersions = encodeColumnVersions(nextVersions);
728
+ await tx.upsertRow(child.name, {
729
+ rowId: childRow.rowId,
730
+ serverVersion: nextServerVersion,
731
+ scopes: childRow.scopes,
732
+ payload,
733
+ ...(cascadeVersions !== undefined
734
+ ? { columnVersions: cascadeVersions }
735
+ : {}),
736
+ }, { opIndex: syntheticIndex });
737
+ changes.push({
738
+ table: child.name,
739
+ rowId: childRow.rowId,
740
+ op: 'upsert',
741
+ rowVersion: nextServerVersion,
742
+ scopes: childRow.scopes,
743
+ payload,
744
+ });
745
+ operations.push({
746
+ opIndex: syntheticIndex,
747
+ op: 'upsert',
748
+ table: child.name,
749
+ rowId: childRow.rowId,
750
+ row: toValidateRow(child.columns, values),
751
+ stored: toValidateRow(child.columns, storedValues),
752
+ storedServerVersion: childRow.serverVersion,
753
+ nextServerVersion,
754
+ });
755
+ }
756
+ originByIndex.push(item.originOpIndex);
757
+ }
758
+ }
759
+ }
760
+ return { kind: 'ok', changes, operations, originByIndex };
761
+ }
459
762
  function resultFrame(clientCommitId, stored, replay) {
460
763
  const status = stored.status === 'applied' ? (replay ? 'cached' : 'applied') : 'rejected';
461
764
  return {
@@ -592,6 +895,7 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
592
895
  const results = [];
593
896
  const changes = [];
594
897
  const validatedOperations = [];
898
+ const stagedWrites = [];
595
899
  let terminated;
596
900
  const operations = await buildOperations(tx);
597
901
  for (let opIndex = 0; opIndex < operations.length; opIndex++) {
@@ -605,13 +909,38 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
605
909
  }
606
910
  results.push({ opIndex, status: 'applied' });
607
911
  validatedOperations.push(outcome.operation);
912
+ stagedWrites.push(outcome.staged);
608
913
  if (outcome.change !== undefined)
609
914
  changes.push(outcome.change);
610
915
  }
916
+ // §6.11: reference enforcement runs after every client operation is
917
+ // staged (so the candidate state is final for the client's own writes)
918
+ // and before whole-commit validation.
919
+ let appendedOrigin = [];
920
+ if (terminated === undefined) {
921
+ const referencePass = await enforceReferences(tx, schema, stagedWrites, operations.length, limitsOf(ctx).maxCascadeOperationsPerCommit, validators, partition, ctx.actorId);
922
+ if (referencePass.kind === 'terminate') {
923
+ terminated = referencePass.record;
924
+ }
925
+ else {
926
+ changes.push(...referencePass.changes);
927
+ validatedOperations.push(...referencePass.operations);
928
+ appendedOrigin = referencePass.originByIndex;
929
+ }
930
+ }
611
931
  if (terminated === undefined) {
612
932
  const commitReject = await runCommitValidator(commitValidator, tx, schema, clientId, clientCommitId, ctx.actorId, partition, validatedOperations);
613
933
  if (commitReject?.kind === 'terminate') {
614
- terminated = commitReject.record;
934
+ // An appended §6.11 cascade operation carries a synthetic opIndex;
935
+ // surface the originating client operation instead.
936
+ const rejected = commitReject.record;
937
+ terminated =
938
+ rejected.opIndex >= operations.length
939
+ ? {
940
+ ...rejected,
941
+ opIndex: appendedOrigin[rejected.opIndex - operations.length] ?? 0,
942
+ }
943
+ : rejected;
615
944
  }
616
945
  }
617
946
  let preparedReactions = [];
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Every synced table is a REAL table in the server database — the app's
5
5
  * columns with proper type affinities, queryable with plain SQL/joins/BI —
6
- * plus five `_sync_*` meta columns:
6
+ * plus six `_sync_*` meta columns:
7
7
  *
8
8
  * _sync_partition TEXT partition key (multi-partition servers
9
9
  * share one database; same app PK in two
@@ -23,6 +23,10 @@
23
23
  * so sync round-trips are byte-identical by
24
24
  * construction. Typed columns are a
25
25
  * queryable projection.
26
+ * _sync_column_versions BLOB/BYTEA §2.2 per-column versions: LEB128
27
+ * (ordinal, version) pairs for columns
28
+ * above 1; NULL when none exceeds 1
29
+ * (every fresh insert). Server-internal.
26
30
  *
27
31
  * PRIMARY KEY (_sync_partition, _sync_row_id).
28
32
  *
@@ -52,6 +56,15 @@ export declare const SYNC_ROW_ID_COLUMN = "_sync_row_id";
52
56
  export declare const SYNC_VERSION_COLUMN = "_sync_server_version";
53
57
  export declare const SYNC_SCOPES_COLUMN = "_sync_scopes";
54
58
  export declare const SYNC_PAYLOAD_COLUMN = "_sync_payload";
59
+ export declare const SYNC_COLUMN_VERSIONS_COLUMN = "_sync_column_versions";
60
+ /**
61
+ * §2.2 column-version storage: LEB128 `(ordinal, version)` varint pairs,
62
+ * ascending ordinal, for the columns whose version exceeds 1. An empty (or
63
+ * NULL) blob means no column exceeds 1. Server-internal: these bytes never
64
+ * ride the wire.
65
+ */
66
+ export declare function encodeColumnVersions(versions: ReadonlyMap<number, number>): Uint8Array | undefined;
67
+ export declare function decodeColumnVersions(blob: Uint8Array | undefined | null): Map<number, number>;
55
68
  /** SQL-standard identifier quoting (both dialects). */
56
69
  export declare function quoteIdent(name: string): string;
57
70
  /** §5.3-style type affinities, per dialect. */
@@ -67,6 +80,9 @@ export declare function createTableDdl(table: CompiledTable, dialect: Relational
67
80
  /**
68
81
  * ALTER TABLE ADD COLUMN statements for schema columns missing from
69
82
  * `existingColumns` (introspected). Added columns are nullable (header note).
83
+ * The `_sync_column_versions` meta column is added to pre-column-version
84
+ * tables too, materialized or not: it is storage-internal, nullable, and
85
+ * never part of the app-column migration subset.
70
86
  */
71
87
  export declare function addColumnDdl(table: CompiledTable, existingColumns: ReadonlySet<string>, dialect: RelationalDialect): string[];
72
88
  /**