@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/context.d.ts +2 -0
- package/dist/context.js +1 -0
- package/dist/d1-storage.js +27 -1
- package/dist/errors.js +16 -6
- package/dist/frame-bytes.js +16 -18
- package/dist/handler.js +0 -3
- package/dist/operations.js +2 -2
- package/dist/postgres-storage.d.ts +1 -1
- package/dist/postgres-storage.js +35 -5
- package/dist/push.js +371 -42
- package/dist/relational-rows.d.ts +17 -1
- package/dist/relational-rows.js +76 -7
- package/dist/schema.d.ts +32 -0
- package/dist/schema.js +55 -0
- package/dist/seed.js +2 -2
- package/dist/sqlite-dialect.d.ts +2 -1
- package/dist/sqlite-dialect.js +10 -0
- package/dist/sqlite-storage.js +23 -0
- package/dist/storage.d.ts +21 -0
- package/package.json +2 -2
- package/src/context.ts +3 -0
- package/src/d1-storage.ts +40 -1
- package/src/errors.ts +16 -6
- package/src/frame-bytes.ts +16 -22
- package/src/handler.ts +0 -6
- package/src/operations.ts +2 -2
- package/src/postgres-storage.ts +51 -5
- package/src/push.ts +516 -41
- package/src/relational-rows.ts +82 -6
- package/src/schema.ts +109 -0
- package/src/seed.ts +2 -2
- package/src/sqlite-dialect.ts +12 -0
- package/src/sqlite-storage.ts +36 -0
- package/src/storage.ts +21 -0
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
|
-
|
|
|
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
|
-
):
|
|
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: '
|
|
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<
|
|
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
|
|
401
|
+
let sparse: SparseRowValue[];
|
|
351
402
|
try {
|
|
352
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
396
|
-
if (
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
402
|
-
|
|
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
|
|
425
|
-
//
|
|
426
|
-
//
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
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
|
|