@syncular/server 0.20.1 → 0.21.1

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
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import {
21
21
  decodeRow,
22
+ decodeSparseRow,
22
23
  encodeRow,
23
24
  type PushCommitFrame,
24
25
  type PushOperation,
@@ -27,10 +28,11 @@ import {
27
28
  parseBlobRef,
28
29
  type RejectionDetails,
29
30
  type RowValue,
31
+ type SparseRowValue,
30
32
  } from '@syncular/core';
31
33
  import type { BlobStore } from './blob-store';
32
34
  import type { SyncRequestContext } from './context';
33
- import { clockOf } from './context';
35
+ import { clockOf, limitsOf } from './context';
34
36
  import type { CrdtMergerRegistry } from './crdt-merger';
35
37
  import { SyncError } from './errors';
36
38
  import { emitEvent } from './events';
@@ -42,11 +44,13 @@ import {
42
44
  import type { CompiledSchema, CompiledTable } from './schema';
43
45
  import type { ResolvedScopes } from './scopes';
44
46
  import { authorizeWrite, renderScopeValue, storedScopesForRow } from './scopes';
47
+ import { decodeColumnVersions, encodeColumnVersions } from './relational-rows';
45
48
  import type {
46
49
  NewChange,
47
50
  StorageTransaction,
48
51
  StoredCommit,
49
52
  StoredPushResult,
53
+ StoredRow,
50
54
  } from './storage';
51
55
  import { StorageConstraintError } from './storage-errors';
52
56
  import type {
@@ -79,13 +83,32 @@ function blobIdsInRow(
79
83
  return ids;
80
84
  }
81
85
 
86
+ /** Final state of one staged client operation, as the §6.11 reference pass
87
+ * sees it after every client operation has been applied to candidate state. */
88
+ interface StagedWrite {
89
+ readonly table: CompiledTable;
90
+ readonly op: 'upsert' | 'delete';
91
+ readonly rowId: string;
92
+ readonly opIndex: number;
93
+ /** Final post-merge, post-scope-strip values (upsert only). */
94
+ readonly values?: readonly RowValue[];
95
+ /** Stored row observed immediately before the write; undefined on insert. */
96
+ readonly stored: StoredRow | undefined;
97
+ }
98
+
99
+ type TerminatingOutcome = {
100
+ readonly kind: 'terminate';
101
+ readonly record: PushOperationResult;
102
+ };
103
+
82
104
  type OperationOutcome =
83
105
  | {
84
106
  readonly kind: 'applied';
85
107
  readonly change: NewChange | undefined;
86
108
  readonly operation: ValidateCommitOperation;
109
+ readonly staged: StagedWrite;
87
110
  }
88
- | { readonly kind: 'terminate'; readonly record: PushOperationResult };
111
+ | TerminatingOutcome;
89
112
 
90
113
  function errorRecord(
91
114
  opIndex: number,
@@ -93,7 +116,7 @@ function errorRecord(
93
116
  message: string,
94
117
  retryable = false,
95
118
  details?: RejectionDetails,
96
- ): OperationOutcome {
119
+ ): TerminatingOutcome {
97
120
  return {
98
121
  kind: 'terminate',
99
122
  record: {
@@ -111,6 +134,7 @@ function conflictRecord(
111
134
  opIndex: number,
112
135
  serverVersion: number,
113
136
  serverRow: Uint8Array,
137
+ conflictColumns: Uint8Array,
114
138
  ): OperationOutcome {
115
139
  return {
116
140
  kind: 'terminate',
@@ -118,13 +142,26 @@ function conflictRecord(
118
142
  opIndex,
119
143
  status: 'conflict',
120
144
  code: 'sync.version_conflict',
121
- message: 'row version does not match baseVersion (§6.2)',
145
+ message: 'present columns moved past baseVersion (§6.2)',
122
146
  serverVersion,
123
147
  serverRow,
148
+ conflictColumns,
124
149
  },
125
150
  };
126
151
  }
127
152
 
153
+ /** §6.3 `conflictColumns`: a presence-layout bitmap over the marked columns. */
154
+ function conflictColumnsBitmap(
155
+ columnCount: number,
156
+ marked: readonly number[],
157
+ ): Uint8Array {
158
+ const bytes = new Uint8Array(Math.ceil(columnCount / 8));
159
+ for (const index of marked) {
160
+ bytes[index >> 3] = (bytes[index >> 3] ?? 0) | (1 << (index & 7));
161
+ }
162
+ return bytes;
163
+ }
164
+
128
165
  interface BlobApplyContext {
129
166
  readonly store: BlobStore | undefined;
130
167
  readonly partition: string;
@@ -149,7 +186,7 @@ async function runValidator(
149
186
  opIndex: number,
150
187
  partition: string,
151
188
  actorId: string,
152
- ): Promise<OperationOutcome | undefined> {
189
+ ): Promise<TerminatingOutcome | undefined> {
153
190
  const validator = validators?.[table.name];
154
191
  if (validator === undefined) return undefined;
155
192
  try {
@@ -201,18 +238,22 @@ async function runValidator(
201
238
  *
202
239
  * A NULL incoming crdt value is a semantic clear, not a merge — it passes
203
240
  * through untouched (the app is nulling the column, the same as any other
204
- * type). Merging only runs for a non-NULL incoming crdt value.
241
+ * type). Merging only runs for a non-NULL incoming crdt value. `present`
242
+ * marks the sparse payload's present columns (§6.2): an absent crdt column
243
+ * is unchanged and never merges.
205
244
  */
206
245
  async function mergeCrdtColumns(
207
246
  table: CompiledTable,
208
247
  values: RowValue[],
209
248
  storedValues: readonly RowValue[] | undefined,
249
+ present: readonly boolean[],
210
250
  opIndex: number,
211
251
  mergers: CrdtMergerRegistry | undefined,
212
252
  ): Promise<OperationOutcome | { readonly changed: boolean }> {
213
253
  if (table.crdtColumns.length === 0) return { changed: false };
214
254
  let changed = false;
215
255
  for (const { index, crdtType } of table.crdtColumns) {
256
+ if (!present[index]) continue;
216
257
  const incoming = values[index];
217
258
  if (!(incoming instanceof Uint8Array)) continue; // NULL clear or absent
218
259
  const merger = mergers?.[crdtType];
@@ -284,6 +325,13 @@ async function applyOperation(
284
325
  row: undefined,
285
326
  stored: undefined,
286
327
  },
328
+ staged: {
329
+ table,
330
+ op: 'delete',
331
+ rowId: op.rowId,
332
+ opIndex,
333
+ stored: undefined,
334
+ },
287
335
  };
288
336
  }
289
337
  if (!authorizeWrite(table, stored.scopes, resolved)) {
@@ -335,10 +383,13 @@ async function applyOperation(
335
383
  stored: toValidateRow(table.columns, storedValues),
336
384
  storedServerVersion: stored.serverVersion,
337
385
  },
386
+ staged: { table, op: 'delete', rowId: op.rowId, opIndex, stored },
338
387
  };
339
388
  }
340
389
 
341
- // upsert — payload presence is enforced by the envelope codec (§6.1).
390
+ // upsert — payload presence is enforced by the envelope codec (§6.1). The
391
+ // payload is a sparse row (§2.4, wire version 3): `undefined` marks an
392
+ // absent column the apply leaves unchanged.
342
393
  const payload = op.payload;
343
394
  if (payload === undefined) {
344
395
  return errorRecord(
@@ -347,9 +398,9 @@ async function applyOperation(
347
398
  'upsert without payload',
348
399
  );
349
400
  }
350
- let values: RowValue[];
401
+ let sparse: SparseRowValue[];
351
402
  try {
352
- values = decodeRow(table.columns, payload);
403
+ sparse = decodeSparseRow(table.columns, table.primaryKeyIndex, payload);
353
404
  } catch (error) {
354
405
  return errorRecord(
355
406
  opIndex,
@@ -357,7 +408,7 @@ async function applyOperation(
357
408
  `row payload failed row-codec decode (§1.7): ${error instanceof Error ? error.message : String(error)}`,
358
409
  );
359
410
  }
360
- const pkValue = renderScopeValue(values[table.primaryKeyIndex]);
411
+ const pkValue = renderScopeValue(sparse[table.primaryKeyIndex] ?? null);
361
412
  if (pkValue !== op.rowId) {
362
413
  return errorRecord(
363
414
  opIndex,
@@ -365,6 +416,8 @@ async function applyOperation(
365
416
  'payload primary key does not match rowId',
366
417
  );
367
418
  }
419
+ const present = sparse.map((value) => value !== undefined);
420
+ const presentCount = present.filter(Boolean).length;
368
421
 
369
422
  if (stored !== undefined) {
370
423
  // §3.4 step 2: authorize against the STORED row, never the payload.
@@ -375,42 +428,108 @@ async function applyOperation(
375
428
  'write denied by scope authorization (§3.4)',
376
429
  );
377
430
  }
378
- if (op.baseVersion === 0) {
379
- // Lost insert race (§6.2); the stored row was authorized above,
380
- // so disclosure of the winner is permitted.
381
- return conflictRecord(opIndex, stored.serverVersion, stored.payload);
382
- }
383
- if (
384
- op.baseVersion !== undefined &&
385
- op.baseVersion !== stored.serverVersion
386
- ) {
387
- return conflictRecord(opIndex, stored.serverVersion, stored.payload);
388
- }
389
- // §3.4 rule 5: scope columns are immutable on update — keep the
390
- // stored row's scope column values on both the baseVersion and
391
- // last-write-wins paths.
392
431
  const storedValues = decodeRow(table.columns, stored.payload);
393
- let mutated = false;
432
+ // §3.4 rule 5 / §6.2: scope columns are immutable on update. A present
433
+ // scope column whose value differs from the stored row is
434
+ // sync.invalid_request; present and equal applies as a no-op.
394
435
  for (const pattern of table.scopePatterns) {
395
- const storedValue = storedValues[pattern.columnIndex] ?? null;
396
- if (values[pattern.columnIndex] !== storedValue) {
397
- values[pattern.columnIndex] = storedValue;
398
- mutated = true;
436
+ const incoming = sparse[pattern.columnIndex];
437
+ if (incoming === undefined) continue;
438
+ if ((incoming ?? null) !== (storedValues[pattern.columnIndex] ?? null)) {
439
+ return errorRecord(
440
+ opIndex,
441
+ 'sync.invalid_request',
442
+ 'scope column is immutable on update (§3.4)',
443
+ );
399
444
  }
400
445
  }
401
- // §5.10.3: crdt columns merge (stored ⊕ incoming) — never LWW, never
402
- // baseVersion-conflict (they were excluded from the checks above).
446
+ if (op.baseVersion !== undefined && op.baseVersion > stored.serverVersion) {
447
+ return errorRecord(
448
+ opIndex,
449
+ 'sync.invalid_request',
450
+ 'baseVersion exceeds the stored server version (§6.2)',
451
+ );
452
+ }
453
+ // §2.2 column versions. A row stored before column versions carries no
454
+ // blob: every column sits at the row's current serverVersion. With a
455
+ // blob, an unlisted column sits at 1.
456
+ const storedVersions = decodeColumnVersions(stored.columnVersions);
457
+ const effectiveVersion = (index: number): number =>
458
+ storedVersions.get(index) ??
459
+ (stored.columnVersions === undefined ? stored.serverVersion : 1);
460
+ const crdtIndexes = new Set(
461
+ table.crdtColumns.map((column) => column.index),
462
+ );
463
+ if (op.baseVersion === 0) {
464
+ // Lost insert race (§6.2); the stored row was authorized above, so
465
+ // disclosure of the winner is permitted. Every present non-crdt
466
+ // column exceeds baseVersion 0.
467
+ const marked: number[] = [];
468
+ for (let i = 0; i < table.columns.length; i++) {
469
+ if (present[i] && !crdtIndexes.has(i) && i !== table.primaryKeyIndex) {
470
+ marked.push(i);
471
+ }
472
+ }
473
+ return conflictRecord(
474
+ opIndex,
475
+ stored.serverVersion,
476
+ stored.payload,
477
+ conflictColumnsBitmap(table.columns.length, marked),
478
+ );
479
+ }
480
+ if (op.baseVersion !== undefined) {
481
+ // §6.2: conflict when any present non-crdt column moved past
482
+ // baseVersion. crdt columns merge and never conflict.
483
+ const marked: number[] = [];
484
+ for (let i = 0; i < table.columns.length; i++) {
485
+ if (
486
+ present[i] &&
487
+ !crdtIndexes.has(i) &&
488
+ effectiveVersion(i) > op.baseVersion
489
+ ) {
490
+ marked.push(i);
491
+ }
492
+ }
493
+ if (marked.length > 0) {
494
+ return conflictRecord(
495
+ opIndex,
496
+ stored.serverVersion,
497
+ stored.payload,
498
+ conflictColumnsBitmap(table.columns.length, marked),
499
+ );
500
+ }
501
+ }
502
+ // §6.2 apply: write the present non-crdt columns, merge the present
503
+ // crdt columns (stored ⊕ incoming, §5.10.3), leave absent columns
504
+ // unchanged, and increment server_version.
505
+ const values: RowValue[] = [...storedValues];
506
+ for (let i = 0; i < table.columns.length; i++) {
507
+ if (!present[i]) continue;
508
+ values[i] = sparse[i] ?? null;
509
+ }
403
510
  const mergeOutcome = await mergeCrdtColumns(
404
511
  table,
405
512
  values,
406
513
  storedValues,
514
+ present,
407
515
  opIndex,
408
516
  crdtMergers,
409
517
  );
410
518
  if ('kind' in mergeOutcome) return mergeOutcome;
411
- if (mergeOutcome.changed) mutated = true;
412
- const newPayload = mutated ? encodeRow(table.columns, values) : payload;
413
519
  const newVersion = stored.serverVersion + 1;
520
+ const nextVersions = new Map<number, number>();
521
+ for (let i = 0; i < table.columns.length; i++) {
522
+ // The primary key is immutable: its version never advances past the
523
+ // insert, so it never enters conflictColumns.
524
+ const version =
525
+ present[i] && i !== table.primaryKeyIndex
526
+ ? newVersion
527
+ : effectiveVersion(i);
528
+ if (version > 1) nextVersions.set(i, version);
529
+ }
530
+ // The stored payload is always the full-row codec (§2.4): the sparse
531
+ // push payload never reaches storage, segments, or COMMIT frames.
532
+ const newPayload = encodeRow(table.columns, values);
414
533
  // §6.6 / §5.9.6: verify referenced blobs exist before writing.
415
534
  const blobCheck = await checkAndRecordBlobs(
416
535
  tx,
@@ -421,9 +540,9 @@ async function applyOperation(
421
540
  blobCtx,
422
541
  );
423
542
  if (blobCheck !== undefined) return blobCheck;
424
- // §6.7: validate the merged, scope-stripped row that will persist —
425
- // for a crdt column the validator sees the MERGED value (§5.10.3), the
426
- // state the store holds, not the raw pushed update.
543
+ // §6.7: validate the merged row that will persist — for a crdt column
544
+ // the validator sees the MERGED value (§5.10.3), the state the store
545
+ // holds, not the raw pushed update.
427
546
  const updateReject = await runValidator(
428
547
  validators,
429
548
  table,
@@ -436,11 +555,13 @@ async function applyOperation(
436
555
  actorId,
437
556
  );
438
557
  if (updateReject !== undefined) return updateReject;
558
+ const columnVersions = encodeColumnVersions(nextVersions);
439
559
  const newRow = {
440
560
  rowId: op.rowId,
441
561
  serverVersion: newVersion,
442
562
  scopes: stored.scopes,
443
563
  payload: newPayload,
564
+ ...(columnVersions !== undefined ? { columnVersions } : {}),
444
565
  };
445
566
  await tx.upsertRow(op.table, newRow, { opIndex });
446
567
  return {
@@ -463,10 +584,12 @@ async function applyOperation(
463
584
  storedServerVersion: stored.serverVersion,
464
585
  nextServerVersion: newVersion,
465
586
  },
587
+ staged: { table, op: 'upsert', rowId: op.rowId, opIndex, values, stored },
466
588
  };
467
589
  }
468
590
 
469
- // Insert path: no stored row.
591
+ // Insert path: no stored row (§5.2).
592
+ const values: RowValue[] = sparse.map((value) => value ?? null);
470
593
  if (op.baseVersion !== undefined && op.baseVersion !== 0) {
471
594
  // Authorize the payload first so absence is not disclosed to actors
472
595
  // without the scope; then §6.2: baseVersion ≠ 0, row absent.
@@ -487,6 +610,26 @@ async function applyOperation(
487
610
  'upsert with baseVersion targets an absent row (§6.2)',
488
611
  );
489
612
  }
613
+ if (op.baseVersion === undefined) {
614
+ // §5.2: a delete within the retention horizon beats an unversioned
615
+ // upsert; an explicit insert (baseVersion 0) recreates instead.
616
+ const tombstoneSeq = await tx.getTombstoneSeq(op.table, op.rowId);
617
+ if (tombstoneSeq !== undefined) {
618
+ return errorRecord(
619
+ opIndex,
620
+ 'sync.row_deleted',
621
+ 'a deleted row rejects an unversioned upsert within the retention horizon (§5.2)',
622
+ );
623
+ }
624
+ }
625
+ if (presentCount < table.columns.length) {
626
+ // §6.3: an insert requires every column present.
627
+ return errorRecord(
628
+ opIndex,
629
+ 'sync.row_missing',
630
+ 'a partial payload targets an absent row (§6.3)',
631
+ );
632
+ }
490
633
  const extracted = storedScopesForRow(table, values);
491
634
  if ('missing' in extracted) {
492
635
  // §3.4 step 2: a missing or empty scope column value ⇒ deny.
@@ -509,13 +652,14 @@ async function applyOperation(
509
652
  table,
510
653
  values,
511
654
  undefined,
655
+ present,
512
656
  opIndex,
513
657
  crdtMergers,
514
658
  );
515
659
  if ('kind' in insertMerge) return insertMerge;
516
- const insertPayload = insertMerge.changed
517
- ? encodeRow(table.columns, values)
518
- : payload;
660
+ // The stored payload is the full-row codec (§2.4); every column is
661
+ // present on an insert.
662
+ const insertPayload = encodeRow(table.columns, values);
519
663
  // §6.6 / §5.9.6: verify referenced blobs exist before writing.
520
664
  const blobCheck = await checkAndRecordBlobs(
521
665
  tx,
@@ -541,6 +685,8 @@ async function applyOperation(
541
685
  actorId,
542
686
  );
543
687
  if (insertReject !== undefined) return insertReject;
688
+ // §5.2: an explicit insert recreates — the tombstone goes with it.
689
+ await tx.clearTombstone(op.table, op.rowId);
544
690
  const newRow = {
545
691
  rowId: op.rowId,
546
692
  serverVersion: 1,
@@ -567,6 +713,14 @@ async function applyOperation(
567
713
  stored: undefined,
568
714
  nextServerVersion: 1,
569
715
  },
716
+ staged: {
717
+ table,
718
+ op: 'upsert',
719
+ rowId: op.rowId,
720
+ opIndex,
721
+ values,
722
+ stored: undefined,
723
+ },
570
724
  };
571
725
  }
572
726
 
@@ -738,6 +892,291 @@ async function runCommitValidator(
738
892
  return undefined;
739
893
  }
740
894
 
895
+ /** §6.3.1 reference values are bounded to 256 encoded bytes; an over-long
896
+ * row id still rejects with the same code and reason, and the parent table
897
+ * name alone names the reference. */
898
+ function parentReference(
899
+ parentTable: string,
900
+ parentRowId: string,
901
+ ): Readonly<Record<string, string>> {
902
+ return new TextEncoder().encode(parentRowId).length <= 256
903
+ ? { parent: parentTable, row: parentRowId }
904
+ : { parent: parentTable };
905
+ }
906
+
907
+ function referenceRejection(
908
+ opIndex: number,
909
+ message: string,
910
+ details: RejectionDetails,
911
+ ): PushOperationResult {
912
+ return {
913
+ opIndex,
914
+ status: 'error',
915
+ code: 'sync.reference_violation',
916
+ message,
917
+ retryable: false,
918
+ details,
919
+ };
920
+ }
921
+
922
+ /**
923
+ * §6.11 declared-reference enforcement, once per commit after every client
924
+ * operation is staged and before whole-commit validation: a present non-null
925
+ * reference column must name a live parent, and a parent delete expands
926
+ * through `CASCADE` / `SET NULL` or rejects `restricted_delete` under
927
+ * `RESTRICT`. Candidate state is read through the transaction, so a commit
928
+ * that deletes a parent and its children together passes. Appended
929
+ * operations join the §6.8 staged operation list (`opIndex` ≥ the client
930
+ * operation count) and emit ordinary changes carrying the child's stored
931
+ * scopes; a rejection attributes to the originating client delete.
932
+ */
933
+ async function enforceReferences(
934
+ tx: StorageTransaction,
935
+ schema: CompiledSchema,
936
+ staged: readonly StagedWrite[],
937
+ clientOpCount: number,
938
+ cascadeLimit: number,
939
+ validators: ValidatorRegistry | undefined,
940
+ partition: string,
941
+ actorId: string,
942
+ ): Promise<
943
+ | {
944
+ readonly kind: 'ok';
945
+ readonly changes: NewChange[];
946
+ readonly operations: ValidateCommitOperation[];
947
+ /** Synthetic opIndex → originating client opIndex. */
948
+ readonly originByIndex: readonly number[];
949
+ }
950
+ | { readonly kind: 'terminate'; readonly record: PushOperationResult }
951
+ > {
952
+ const changes: NewChange[] = [];
953
+ const operations: ValidateCommitOperation[] = [];
954
+ const originByIndex: number[] = [];
955
+
956
+ for (const write of staged) {
957
+ if (write.op !== 'upsert' || write.values === undefined) continue;
958
+ for (const reference of write.table.references) {
959
+ const value = write.values[reference.columnIndex];
960
+ if (value === null || value === undefined) continue;
961
+ const parentRowId = renderScopeValue(value);
962
+ if (parentRowId === undefined) continue;
963
+ if ((await tx.getRow(reference.parentTable, parentRowId)) !== undefined) {
964
+ continue;
965
+ }
966
+ return {
967
+ kind: 'terminate',
968
+ record: referenceRejection(
969
+ write.opIndex,
970
+ 'declared reference names an absent parent row (§6.11)',
971
+ {
972
+ reason: 'missing_parent',
973
+ fieldPaths: [reference.column],
974
+ references: parentReference(reference.parentTable, parentRowId),
975
+ },
976
+ ),
977
+ };
978
+ }
979
+ }
980
+
981
+ const schedule = (
982
+ table: CompiledTable,
983
+ rowId: string,
984
+ originOpIndex: number,
985
+ scheduled: Set<string>,
986
+ queue: Array<{
987
+ readonly table: CompiledTable;
988
+ readonly rowId: string;
989
+ readonly originOpIndex: number;
990
+ }>,
991
+ ): boolean => {
992
+ const key = `${table.name}\u0000${rowId}`;
993
+ if (scheduled.has(key)) return false;
994
+ scheduled.add(key);
995
+ queue.push({ table, rowId, originOpIndex });
996
+ return true;
997
+ };
998
+
999
+ const scheduled = new Set<string>();
1000
+ const processed = new Set<string>();
1001
+ const queue: Array<{
1002
+ readonly table: CompiledTable;
1003
+ readonly rowId: string;
1004
+ readonly originOpIndex: number;
1005
+ }> = [];
1006
+ for (const write of staged) {
1007
+ if (write.op === 'delete' && write.stored !== undefined) {
1008
+ schedule(write.table, write.rowId, write.opIndex, scheduled, queue);
1009
+ }
1010
+ }
1011
+
1012
+ for (let cursor = 0; cursor < queue.length; cursor++) {
1013
+ const item = queue[cursor];
1014
+ if (item === undefined) continue;
1015
+ const key = `${item.table.name}\u0000${item.rowId}`;
1016
+ if (processed.has(key)) continue;
1017
+ processed.add(key);
1018
+ for (const reference of item.table.referencedBy) {
1019
+ if (tx.scanRowsByIndex === undefined) {
1020
+ throw new Error(
1021
+ 'storage transaction does not support declared-reference enforcement (scanRowsByIndex)',
1022
+ );
1023
+ }
1024
+ const children: StoredRow[] = [];
1025
+ let afterRowId: string | null = null;
1026
+ for (;;) {
1027
+ const page = await tx.scanRowsByIndex({
1028
+ table: reference.table,
1029
+ index: reference.index,
1030
+ values: [item.rowId],
1031
+ afterRowId,
1032
+ limit: 1_000,
1033
+ });
1034
+ children.push(...page);
1035
+ if (page.length < 1_000) break;
1036
+ afterRowId = page[page.length - 1]?.rowId ?? null;
1037
+ }
1038
+ if (children.length === 0) continue;
1039
+ if (reference.onDelete === 'RESTRICT') {
1040
+ return {
1041
+ kind: 'terminate',
1042
+ record: referenceRejection(
1043
+ item.originOpIndex,
1044
+ 'delete is blocked by a declared reference with ON DELETE RESTRICT (§6.11)',
1045
+ {
1046
+ reason: 'restricted_delete',
1047
+ references: { child: reference.table },
1048
+ },
1049
+ ),
1050
+ };
1051
+ }
1052
+ const child = schema.tables.get(reference.table);
1053
+ if (child === undefined) {
1054
+ throw new Error(
1055
+ `unreachable: reference child table ${reference.table} is not compiled`,
1056
+ );
1057
+ }
1058
+ for (const childRow of children) {
1059
+ if (originByIndex.length >= cascadeLimit) {
1060
+ return {
1061
+ kind: 'terminate',
1062
+ record: referenceRejection(
1063
+ item.originOpIndex,
1064
+ 'declared-reference cascade exceeded the per-commit operation cap (§6.11)',
1065
+ { reason: 'cascade_limit' },
1066
+ ),
1067
+ };
1068
+ }
1069
+ if (
1070
+ !schedule(child, childRow.rowId, item.originOpIndex, scheduled, queue)
1071
+ ) {
1072
+ continue;
1073
+ }
1074
+ const syntheticIndex = clientOpCount + originByIndex.length;
1075
+ const storedValues = decodeRow(child.columns, childRow.payload);
1076
+ if (reference.onDelete === 'CASCADE') {
1077
+ const reject = await runValidator(
1078
+ validators,
1079
+ child,
1080
+ 'delete',
1081
+ childRow.rowId,
1082
+ undefined,
1083
+ storedValues,
1084
+ item.originOpIndex,
1085
+ partition,
1086
+ actorId,
1087
+ );
1088
+ if (reject !== undefined) {
1089
+ return { kind: 'terminate', record: reject.record };
1090
+ }
1091
+ await tx.deleteRow(child.name, childRow.rowId);
1092
+ changes.push({
1093
+ table: child.name,
1094
+ rowId: childRow.rowId,
1095
+ op: 'delete',
1096
+ scopes: childRow.scopes,
1097
+ });
1098
+ operations.push({
1099
+ opIndex: syntheticIndex,
1100
+ op: 'delete',
1101
+ table: child.name,
1102
+ rowId: childRow.rowId,
1103
+ row: undefined,
1104
+ stored: toValidateRow(child.columns, storedValues),
1105
+ storedServerVersion: childRow.serverVersion,
1106
+ });
1107
+ } else {
1108
+ const values = [...storedValues];
1109
+ values[reference.columnIndex] = null;
1110
+ const reject = await runValidator(
1111
+ validators,
1112
+ child,
1113
+ 'upsert',
1114
+ childRow.rowId,
1115
+ values,
1116
+ storedValues,
1117
+ item.originOpIndex,
1118
+ partition,
1119
+ actorId,
1120
+ );
1121
+ if (reject !== undefined) {
1122
+ return { kind: 'terminate', record: reject.record };
1123
+ }
1124
+ const nextServerVersion = childRow.serverVersion + 1;
1125
+ // §6.2: the SET NULL upsert writes only the reference column, so
1126
+ // only that column's version moves.
1127
+ const storedVersions = decodeColumnVersions(childRow.columnVersions);
1128
+ const nextVersions = new Map<number, number>();
1129
+ for (let i = 0; i < child.columns.length; i++) {
1130
+ const version =
1131
+ i === reference.columnIndex
1132
+ ? nextServerVersion
1133
+ : (storedVersions.get(i) ??
1134
+ (childRow.columnVersions === undefined
1135
+ ? childRow.serverVersion
1136
+ : 1));
1137
+ if (version > 1) nextVersions.set(i, version);
1138
+ }
1139
+ const payload = encodeRow(child.columns, values);
1140
+ const cascadeVersions = encodeColumnVersions(nextVersions);
1141
+ await tx.upsertRow(
1142
+ child.name,
1143
+ {
1144
+ rowId: childRow.rowId,
1145
+ serverVersion: nextServerVersion,
1146
+ scopes: childRow.scopes,
1147
+ payload,
1148
+ ...(cascadeVersions !== undefined
1149
+ ? { columnVersions: cascadeVersions }
1150
+ : {}),
1151
+ },
1152
+ { opIndex: syntheticIndex },
1153
+ );
1154
+ changes.push({
1155
+ table: child.name,
1156
+ rowId: childRow.rowId,
1157
+ op: 'upsert',
1158
+ rowVersion: nextServerVersion,
1159
+ scopes: childRow.scopes,
1160
+ payload,
1161
+ });
1162
+ operations.push({
1163
+ opIndex: syntheticIndex,
1164
+ op: 'upsert',
1165
+ table: child.name,
1166
+ rowId: childRow.rowId,
1167
+ row: toValidateRow(child.columns, values),
1168
+ stored: toValidateRow(child.columns, storedValues),
1169
+ storedServerVersion: childRow.serverVersion,
1170
+ nextServerVersion,
1171
+ });
1172
+ }
1173
+ originByIndex.push(item.originOpIndex);
1174
+ }
1175
+ }
1176
+ }
1177
+ return { kind: 'ok', changes, operations, originByIndex };
1178
+ }
1179
+
741
1180
  function resultFrame(
742
1181
  clientCommitId: string,
743
1182
  stored: StoredPushResult,
@@ -950,6 +1389,7 @@ export async function processPushOperationsWithTrace(
950
1389
  const results: PushOperationResult[] = [];
951
1390
  const changes: NewChange[] = [];
952
1391
  const validatedOperations: ValidateCommitOperation[] = [];
1392
+ const stagedWrites: StagedWrite[] = [];
953
1393
  let terminated: PushOperationResult | undefined;
954
1394
  const operations = await buildOperations(tx);
955
1395
  for (let opIndex = 0; opIndex < operations.length; opIndex++) {
@@ -973,9 +1413,34 @@ export async function processPushOperationsWithTrace(
973
1413
  }
974
1414
  results.push({ opIndex, status: 'applied' });
975
1415
  validatedOperations.push(outcome.operation);
1416
+ stagedWrites.push(outcome.staged);
976
1417
  if (outcome.change !== undefined) changes.push(outcome.change);
977
1418
  }
978
1419
 
1420
+ // §6.11: reference enforcement runs after every client operation is
1421
+ // staged (so the candidate state is final for the client's own writes)
1422
+ // and before whole-commit validation.
1423
+ let appendedOrigin: readonly number[] = [];
1424
+ if (terminated === undefined) {
1425
+ const referencePass = await enforceReferences(
1426
+ tx,
1427
+ schema,
1428
+ stagedWrites,
1429
+ operations.length,
1430
+ limitsOf(ctx).maxCascadeOperationsPerCommit,
1431
+ validators,
1432
+ partition,
1433
+ ctx.actorId,
1434
+ );
1435
+ if (referencePass.kind === 'terminate') {
1436
+ terminated = referencePass.record;
1437
+ } else {
1438
+ changes.push(...referencePass.changes);
1439
+ validatedOperations.push(...referencePass.operations);
1440
+ appendedOrigin = referencePass.originByIndex;
1441
+ }
1442
+ }
1443
+
979
1444
  if (terminated === undefined) {
980
1445
  const commitReject = await runCommitValidator(
981
1446
  commitValidator,
@@ -988,7 +1453,17 @@ export async function processPushOperationsWithTrace(
988
1453
  validatedOperations,
989
1454
  );
990
1455
  if (commitReject?.kind === 'terminate') {
991
- terminated = commitReject.record;
1456
+ // An appended §6.11 cascade operation carries a synthetic opIndex;
1457
+ // surface the originating client operation instead.
1458
+ const rejected = commitReject.record;
1459
+ terminated =
1460
+ rejected.opIndex >= operations.length
1461
+ ? {
1462
+ ...rejected,
1463
+ opIndex:
1464
+ appendedOrigin[rejected.opIndex - operations.length] ?? 0,
1465
+ }
1466
+ : rejected;
992
1467
  }
993
1468
  }
994
1469