@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/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/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: '
|
|
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
|
|
231
|
+
let sparse;
|
|
208
232
|
try {
|
|
209
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
// §
|
|
245
|
-
//
|
|
246
|
-
|
|
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
|
|
258
|
-
//
|
|
259
|
-
//
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
|
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
|
/**
|