@syncular/server 0.42.2 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/d1-storage.js +14 -4
- package/dist/operations.d.ts +5 -0
- package/dist/operations.js +4 -4
- package/dist/postgres-storage.js +386 -104
- package/dist/push.d.ts +18 -1
- package/dist/push.js +247 -132
- package/dist/relational-rows.d.ts +9 -5
- package/dist/relational-rows.js +29 -11
- package/dist/sqlite-storage.js +14 -10
- package/dist/storage.d.ts +46 -32
- package/dist/storage.js +26 -0
- package/package.json +2 -2
- package/src/d1-storage.ts +30 -5
- package/src/operations.ts +12 -7
- package/src/postgres-storage.ts +553 -166
- package/src/push.ts +358 -151
- package/src/relational-rows.ts +31 -10
- package/src/sqlite-storage.ts +33 -18
- package/src/storage.ts +75 -36
package/src/push.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { authorizeWrite, renderScopeValue, storedScopesForRow } from './scopes';
|
|
|
48
48
|
import { decodeColumnVersions, encodeColumnVersions } from './relational-rows';
|
|
49
49
|
import type {
|
|
50
50
|
NewChange,
|
|
51
|
+
PreparedPushApply,
|
|
51
52
|
StorageTransaction,
|
|
52
53
|
StoredCommit,
|
|
53
54
|
StoredPushResult,
|
|
@@ -108,12 +109,28 @@ type TerminatingOutcome = {
|
|
|
108
109
|
type OperationOutcome =
|
|
109
110
|
| {
|
|
110
111
|
readonly kind: 'applied';
|
|
111
|
-
readonly
|
|
112
|
-
readonly
|
|
112
|
+
readonly changes: readonly NewChange[];
|
|
113
|
+
readonly operations: readonly ValidateCommitOperation[];
|
|
113
114
|
readonly staged: StagedWrite;
|
|
114
115
|
}
|
|
115
116
|
| TerminatingOutcome;
|
|
116
117
|
|
|
118
|
+
/**
|
|
119
|
+
* A server-authoritative scope move (§2.2). Commands emit it through
|
|
120
|
+
* `CommandMutation`; it is not an SSP2 wire operation, so a client cannot
|
|
121
|
+
* request one. Apply rewrites the stored row in place under the new scopes
|
|
122
|
+
* and emits the delete/upsert change pair in one commit.
|
|
123
|
+
*/
|
|
124
|
+
export interface RescopeOperation {
|
|
125
|
+
readonly table: string;
|
|
126
|
+
readonly rowId: string;
|
|
127
|
+
readonly op: 'rescope';
|
|
128
|
+
/** Full-row payload (the row's values under the new scopes). */
|
|
129
|
+
readonly payload: Uint8Array;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type ApplyOperation = PushOperation | RescopeOperation;
|
|
133
|
+
|
|
117
134
|
function errorRecord(
|
|
118
135
|
opIndex: number,
|
|
119
136
|
code: string,
|
|
@@ -296,7 +313,7 @@ async function applyOperation(
|
|
|
296
313
|
schema: CompiledSchema,
|
|
297
314
|
wireSchema: CompiledSchema,
|
|
298
315
|
resolved: ResolvedScopes,
|
|
299
|
-
op:
|
|
316
|
+
op: ApplyOperation,
|
|
300
317
|
opIndex: number,
|
|
301
318
|
blobCtx: BlobApplyContext,
|
|
302
319
|
crdtMergers: CrdtMergerRegistry | undefined,
|
|
@@ -322,20 +339,224 @@ async function applyOperation(
|
|
|
322
339
|
}
|
|
323
340
|
const stored = await tx.getRow(op.table, op.rowId);
|
|
324
341
|
|
|
325
|
-
if (op.op === '
|
|
342
|
+
if (op.op === 'rescope') {
|
|
343
|
+
// §2.2 explicit scope move. One stored row changes its scope values;
|
|
344
|
+
// apply rewrites it in place, keeps `server_version`, and emits the
|
|
345
|
+
// delete under the old scopes plus the upsert under the new ones.
|
|
326
346
|
if (stored === undefined) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
347
|
+
return errorRecord(
|
|
348
|
+
opIndex,
|
|
349
|
+
'sync.row_missing',
|
|
350
|
+
'rescope targets an absent row (§2.2)',
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
if (!authorizeWrite(table, stored.scopes, resolved)) {
|
|
354
|
+
return errorRecord(
|
|
355
|
+
opIndex,
|
|
356
|
+
'sync.forbidden',
|
|
357
|
+
'rescope denied by scope authorization (§3.4)',
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
const missing = missingScopeVariable(table, stored.scopes);
|
|
361
|
+
if (missing !== undefined) {
|
|
362
|
+
return errorRecord(
|
|
363
|
+
opIndex,
|
|
364
|
+
'sync.missing_scopes',
|
|
365
|
+
`stored row lacks scope variable ${JSON.stringify(missing)} (§3.1)`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
let sparse: SparseRowValue[];
|
|
369
|
+
try {
|
|
370
|
+
sparse = decodeSparseRow(
|
|
371
|
+
wireTable.columns,
|
|
372
|
+
wireTable.primaryKeyIndex,
|
|
373
|
+
op.payload,
|
|
374
|
+
);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
return errorRecord(
|
|
377
|
+
opIndex,
|
|
378
|
+
'sync.invalid_request',
|
|
379
|
+
`rescope payload failed row-codec decode (§1.7): ${error instanceof Error ? error.message : String(error)}`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
if (
|
|
383
|
+
sparse.length < table.columns.length ||
|
|
384
|
+
sparse.some((value) => value === undefined)
|
|
385
|
+
) {
|
|
386
|
+
return errorRecord(
|
|
387
|
+
opIndex,
|
|
388
|
+
'sync.invalid_request',
|
|
389
|
+
'rescope requires a full-row payload (§2.2)',
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
const values: RowValue[] = sparse.map((value) => value ?? null);
|
|
393
|
+
const pkValue = renderScopeValue(values[table.primaryKeyIndex] ?? null);
|
|
394
|
+
if (pkValue !== op.rowId) {
|
|
395
|
+
return errorRecord(
|
|
396
|
+
opIndex,
|
|
397
|
+
'sync.invalid_request',
|
|
398
|
+
'payload primary key does not match rowId',
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
const storedValues = decodeRow(table.columns, stored.payload);
|
|
402
|
+
const scopeIndexes = new Set(
|
|
403
|
+
table.scopePatterns.map((pattern) => pattern.columnIndex),
|
|
404
|
+
);
|
|
405
|
+
const sameValue = (left: RowValue, right: RowValue): boolean => {
|
|
406
|
+
if (left instanceof Uint8Array || right instanceof Uint8Array) {
|
|
407
|
+
return (
|
|
408
|
+
left instanceof Uint8Array &&
|
|
409
|
+
right instanceof Uint8Array &&
|
|
410
|
+
left.length === right.length &&
|
|
411
|
+
left.every((byte, index) => byte === right[index])
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
return left === right;
|
|
415
|
+
};
|
|
416
|
+
for (let index = 0; index < table.columns.length; index++) {
|
|
417
|
+
if (index === table.primaryKeyIndex || scopeIndexes.has(index)) continue;
|
|
418
|
+
if (!sameValue(values[index] ?? null, storedValues[index] ?? null)) {
|
|
419
|
+
return errorRecord(
|
|
420
|
+
opIndex,
|
|
421
|
+
'sync.invalid_request',
|
|
422
|
+
'rescope may change only scope columns (§2.2)',
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const extracted = storedScopesForRow(table, values);
|
|
427
|
+
if ('missing' in extracted) {
|
|
428
|
+
return errorRecord(
|
|
429
|
+
opIndex,
|
|
430
|
+
'sync.forbidden',
|
|
431
|
+
`rescope missing scope column value for ${JSON.stringify(extracted.missing)} (§3.4)`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
if (!authorizeWrite(table, extracted.scopes, resolved)) {
|
|
435
|
+
return errorRecord(
|
|
436
|
+
opIndex,
|
|
437
|
+
'sync.forbidden',
|
|
438
|
+
'rescope denied by scope authorization of the new scopes (§3.4)',
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
const deleteReject = await runValidator(
|
|
442
|
+
validators,
|
|
443
|
+
table,
|
|
444
|
+
'delete',
|
|
445
|
+
op.rowId,
|
|
446
|
+
undefined,
|
|
447
|
+
storedValues,
|
|
448
|
+
opIndex,
|
|
449
|
+
tx,
|
|
450
|
+
partition,
|
|
451
|
+
actorId,
|
|
452
|
+
);
|
|
453
|
+
if (deleteReject !== undefined) return deleteReject;
|
|
454
|
+
const upsertReject = await runValidator(
|
|
455
|
+
validators,
|
|
456
|
+
table,
|
|
457
|
+
'upsert',
|
|
458
|
+
op.rowId,
|
|
459
|
+
values,
|
|
460
|
+
storedValues,
|
|
461
|
+
opIndex,
|
|
462
|
+
tx,
|
|
463
|
+
partition,
|
|
464
|
+
actorId,
|
|
465
|
+
);
|
|
466
|
+
if (upsertReject !== undefined) return upsertReject;
|
|
467
|
+
const blobCheck = await checkAndRecordBlobs(
|
|
468
|
+
tx,
|
|
469
|
+
table,
|
|
470
|
+
op.rowId,
|
|
471
|
+
values,
|
|
472
|
+
opIndex,
|
|
473
|
+
blobCtx,
|
|
474
|
+
);
|
|
475
|
+
if (blobCheck !== undefined) return blobCheck;
|
|
476
|
+
const payload = encodeRow(table.columns, values);
|
|
477
|
+
// The move clears any tombstone from an earlier incarnation: the row is
|
|
478
|
+
// live at the end of this commit, so the next move must not hit §5.2.
|
|
479
|
+
await tx.clearTombstone(op.table, op.rowId);
|
|
480
|
+
await tx.upsertRow(
|
|
481
|
+
op.table,
|
|
482
|
+
{
|
|
483
|
+
rowId: op.rowId,
|
|
484
|
+
serverVersion: stored.serverVersion,
|
|
485
|
+
scopes: extracted.scopes,
|
|
486
|
+
payload,
|
|
487
|
+
...(stored.columnVersions !== undefined
|
|
488
|
+
? { columnVersions: stored.columnVersions }
|
|
489
|
+
: {}),
|
|
490
|
+
},
|
|
491
|
+
{ opIndex },
|
|
492
|
+
);
|
|
493
|
+
const storedRow = toValidateRow(table.columns, storedValues);
|
|
494
|
+
return {
|
|
495
|
+
kind: 'applied',
|
|
496
|
+
changes: [
|
|
497
|
+
{
|
|
498
|
+
table: op.table,
|
|
499
|
+
rowId: op.rowId,
|
|
500
|
+
op: 'delete',
|
|
501
|
+
scopes: stored.scopes,
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
table: op.table,
|
|
505
|
+
rowId: op.rowId,
|
|
506
|
+
op: 'upsert',
|
|
507
|
+
rowVersion: stored.serverVersion,
|
|
508
|
+
scopes: extracted.scopes,
|
|
509
|
+
payload,
|
|
510
|
+
},
|
|
511
|
+
],
|
|
512
|
+
operations: [
|
|
513
|
+
{
|
|
332
514
|
opIndex,
|
|
333
515
|
op: 'delete',
|
|
334
516
|
table: table.name,
|
|
335
517
|
rowId: op.rowId,
|
|
336
518
|
row: undefined,
|
|
337
|
-
stored:
|
|
519
|
+
stored: storedRow,
|
|
520
|
+
storedServerVersion: stored.serverVersion,
|
|
338
521
|
},
|
|
522
|
+
{
|
|
523
|
+
opIndex,
|
|
524
|
+
op: 'upsert',
|
|
525
|
+
table: table.name,
|
|
526
|
+
rowId: op.rowId,
|
|
527
|
+
row: toValidateRow(table.columns, values),
|
|
528
|
+
stored: storedRow,
|
|
529
|
+
storedServerVersion: stored.serverVersion,
|
|
530
|
+
nextServerVersion: stored.serverVersion,
|
|
531
|
+
},
|
|
532
|
+
],
|
|
533
|
+
staged: {
|
|
534
|
+
table,
|
|
535
|
+
op: 'upsert',
|
|
536
|
+
rowId: op.rowId,
|
|
537
|
+
opIndex,
|
|
538
|
+
values,
|
|
539
|
+
stored,
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (op.op === 'delete') {
|
|
545
|
+
if (stored === undefined) {
|
|
546
|
+
// Deleting an absent row is applied (idempotent, §6.2); no change.
|
|
547
|
+
return {
|
|
548
|
+
kind: 'applied',
|
|
549
|
+
changes: [],
|
|
550
|
+
operations: [
|
|
551
|
+
{
|
|
552
|
+
opIndex,
|
|
553
|
+
op: 'delete',
|
|
554
|
+
table: table.name,
|
|
555
|
+
rowId: op.rowId,
|
|
556
|
+
row: undefined,
|
|
557
|
+
stored: undefined,
|
|
558
|
+
},
|
|
559
|
+
],
|
|
339
560
|
staged: {
|
|
340
561
|
table,
|
|
341
562
|
op: 'delete',
|
|
@@ -380,21 +601,25 @@ async function applyOperation(
|
|
|
380
601
|
await tx.deleteRow(op.table, op.rowId);
|
|
381
602
|
return {
|
|
382
603
|
kind: 'applied',
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
604
|
+
changes: [
|
|
605
|
+
{
|
|
606
|
+
table: op.table,
|
|
607
|
+
rowId: op.rowId,
|
|
608
|
+
op: 'delete',
|
|
609
|
+
scopes: stored.scopes,
|
|
610
|
+
},
|
|
611
|
+
],
|
|
612
|
+
operations: [
|
|
613
|
+
{
|
|
614
|
+
opIndex,
|
|
615
|
+
op: 'delete',
|
|
616
|
+
table: table.name,
|
|
617
|
+
rowId: op.rowId,
|
|
618
|
+
row: undefined,
|
|
619
|
+
stored: toValidateRow(table.columns, storedValues),
|
|
620
|
+
storedServerVersion: stored.serverVersion,
|
|
621
|
+
},
|
|
622
|
+
],
|
|
398
623
|
staged: { table, op: 'delete', rowId: op.rowId, opIndex, stored },
|
|
399
624
|
};
|
|
400
625
|
}
|
|
@@ -586,24 +811,28 @@ async function applyOperation(
|
|
|
586
811
|
await tx.upsertRow(op.table, newRow, { opIndex });
|
|
587
812
|
return {
|
|
588
813
|
kind: 'applied',
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
814
|
+
changes: [
|
|
815
|
+
{
|
|
816
|
+
table: op.table,
|
|
817
|
+
rowId: op.rowId,
|
|
818
|
+
op: 'upsert',
|
|
819
|
+
rowVersion: newVersion,
|
|
820
|
+
scopes: stored.scopes,
|
|
821
|
+
payload: newPayload,
|
|
822
|
+
},
|
|
823
|
+
],
|
|
824
|
+
operations: [
|
|
825
|
+
{
|
|
826
|
+
opIndex,
|
|
827
|
+
op: 'upsert',
|
|
828
|
+
table: table.name,
|
|
829
|
+
rowId: op.rowId,
|
|
830
|
+
row: toValidateRow(table.columns, values),
|
|
831
|
+
stored: toValidateRow(table.columns, storedValues),
|
|
832
|
+
storedServerVersion: stored.serverVersion,
|
|
833
|
+
nextServerVersion: newVersion,
|
|
834
|
+
},
|
|
835
|
+
],
|
|
607
836
|
staged: { table, op: 'upsert', rowId: op.rowId, opIndex, values, stored },
|
|
608
837
|
};
|
|
609
838
|
}
|
|
@@ -721,23 +950,27 @@ async function applyOperation(
|
|
|
721
950
|
await tx.upsertRow(op.table, newRow, { opIndex });
|
|
722
951
|
return {
|
|
723
952
|
kind: 'applied',
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
953
|
+
changes: [
|
|
954
|
+
{
|
|
955
|
+
table: op.table,
|
|
956
|
+
rowId: op.rowId,
|
|
957
|
+
op: 'upsert',
|
|
958
|
+
rowVersion: 1,
|
|
959
|
+
scopes: extracted.scopes,
|
|
960
|
+
payload: insertPayload,
|
|
961
|
+
},
|
|
962
|
+
],
|
|
963
|
+
operations: [
|
|
964
|
+
{
|
|
965
|
+
opIndex,
|
|
966
|
+
op: 'upsert',
|
|
967
|
+
table: table.name,
|
|
968
|
+
rowId: op.rowId,
|
|
969
|
+
row: toValidateRow(table.columns, values),
|
|
970
|
+
stored: undefined,
|
|
971
|
+
nextServerVersion: 1,
|
|
972
|
+
},
|
|
973
|
+
],
|
|
741
974
|
staged: {
|
|
742
975
|
table,
|
|
743
976
|
op: 'upsert',
|
|
@@ -1342,6 +1575,16 @@ export async function processPushCommitWithTrace(
|
|
|
1342
1575
|
frame: PushCommitFrame,
|
|
1343
1576
|
wireSchema: CompiledSchema = schema,
|
|
1344
1577
|
): Promise<ProcessedPushCommit> {
|
|
1578
|
+
// The frame names its rows before the transaction opens, so the admission
|
|
1579
|
+
// read carries the commit's read-ahead and the apply issues no separate
|
|
1580
|
+
// prefetch statement.
|
|
1581
|
+
const targets = new Map<string, string[]>();
|
|
1582
|
+
for (const op of frame.operations) {
|
|
1583
|
+
if (!schema.tables.has(op.table)) continue;
|
|
1584
|
+
const ids = targets.get(op.table) ?? [];
|
|
1585
|
+
ids.push(op.rowId);
|
|
1586
|
+
targets.set(op.table, ids);
|
|
1587
|
+
}
|
|
1345
1588
|
return processPushOperationsWithTrace(
|
|
1346
1589
|
ctx,
|
|
1347
1590
|
schema,
|
|
@@ -1350,6 +1593,9 @@ export async function processPushCommitWithTrace(
|
|
|
1350
1593
|
frame.clientCommitId,
|
|
1351
1594
|
async () => frame.operations,
|
|
1352
1595
|
wireSchema,
|
|
1596
|
+
targets.size === 0
|
|
1597
|
+
? undefined
|
|
1598
|
+
: [...targets].map(([table, rowIds]) => ({ table, rowIds })),
|
|
1353
1599
|
);
|
|
1354
1600
|
}
|
|
1355
1601
|
|
|
@@ -1366,88 +1612,48 @@ export async function processPushOperationsWithTrace(
|
|
|
1366
1612
|
clientCommitId: string,
|
|
1367
1613
|
buildOperations: (
|
|
1368
1614
|
tx: StorageTransaction,
|
|
1369
|
-
) => Promise<readonly
|
|
1615
|
+
) => Promise<readonly ApplyOperation[]>,
|
|
1370
1616
|
wireSchema: CompiledSchema = schema,
|
|
1617
|
+
prefetchTargets?: readonly {
|
|
1618
|
+
readonly table: string;
|
|
1619
|
+
readonly rowIds: readonly string[];
|
|
1620
|
+
}[],
|
|
1371
1621
|
): Promise<ProcessedPushCommit> {
|
|
1372
|
-
const {
|
|
1373
|
-
let persisted: StoredPushResult | undefined;
|
|
1374
|
-
try {
|
|
1375
|
-
persisted = await storage.getPushResult(
|
|
1376
|
-
partition,
|
|
1377
|
-
clientId,
|
|
1378
|
-
clientCommitId,
|
|
1379
|
-
);
|
|
1380
|
-
} catch (error) {
|
|
1381
|
-
if (
|
|
1382
|
-
error instanceof SyncError &&
|
|
1383
|
-
error.code === 'sync.idempotency_cache_miss'
|
|
1384
|
-
) {
|
|
1385
|
-
// §6.3: answer the retryable cache-miss for this commit rather than
|
|
1386
|
-
// re-applying. Not persisted — a retry may find a readable record.
|
|
1387
|
-
return {
|
|
1388
|
-
frame: idempotencyCacheMissFrame(clientCommitId, error),
|
|
1389
|
-
replayed: false,
|
|
1390
|
-
};
|
|
1391
|
-
}
|
|
1392
|
-
throw error;
|
|
1393
|
-
}
|
|
1394
|
-
if (persisted !== undefined) {
|
|
1395
|
-
return processedPushCommit(clientCommitId, persisted, true);
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1622
|
+
const { partition } = ctx;
|
|
1398
1623
|
const createdAtMs = clockOf(ctx)();
|
|
1399
1624
|
const blobCtx: BlobApplyContext = { store: ctx.blobs, partition };
|
|
1400
1625
|
const crdtMergers = ctx.crdtMergers;
|
|
1401
1626
|
const validators = ctx.validators;
|
|
1402
1627
|
const commitValidator = ctx.commitValidator;
|
|
1403
1628
|
const reactionPlanner = ctx.reactionPlanner;
|
|
1404
|
-
const tx = await storage.begin(partition);
|
|
1405
|
-
const lockPartitionForPush =
|
|
1406
|
-
tx.lockPartitionForPush?.bind(tx) ??
|
|
1407
|
-
tx.lockPartitionForCommitValidation?.bind(tx);
|
|
1408
|
-
const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
|
|
1629
|
+
const tx = await ctx.storage.begin(partition);
|
|
1409
1630
|
try {
|
|
1410
|
-
if (
|
|
1411
|
-
lockPartitionForPush === undefined ||
|
|
1412
|
-
commitRejectedPushResult === undefined ||
|
|
1413
|
-
(reactionPlanner !== undefined && tx.enqueueReactions === undefined)
|
|
1414
|
-
) {
|
|
1631
|
+
if (reactionPlanner !== undefined && tx.enqueueReactions === undefined) {
|
|
1415
1632
|
throw new Error(
|
|
1416
|
-
'storage transaction does not support
|
|
1633
|
+
'storage transaction does not support configured durable reactions',
|
|
1417
1634
|
);
|
|
1418
1635
|
}
|
|
1419
|
-
|
|
1420
|
-
//
|
|
1421
|
-
//
|
|
1422
|
-
//
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
await tx.rollback();
|
|
1427
|
-
throw serveNotReadyError(refusal);
|
|
1428
|
-
}
|
|
1429
|
-
// The optimistic lookup above may have raced another delivery. Re-check
|
|
1430
|
-
// only after acquiring partition serialization and before any operation
|
|
1431
|
-
// read, validation, merge, or staged write. The re-check runs on the
|
|
1432
|
-
// transaction's own connection when the backend provides one: a pooled
|
|
1433
|
-
// Postgres client holding the partition lock would otherwise wait for a
|
|
1434
|
-
// second pool slot and deadlock against pushes waiting on the lock. A
|
|
1435
|
-
// racing duplicate's result commits before the lock releases, so the
|
|
1436
|
-
// transaction-scoped read observes it.
|
|
1636
|
+
// The storage takes the partition lock and then reads the admission token
|
|
1637
|
+
// on the transaction's own connection: the RFC 0007 serve gate and the
|
|
1638
|
+
// §2.3 idempotency record, plus any read-ahead the caller already knows.
|
|
1639
|
+
// A racing duplicate's result commits before the lock releases, so the
|
|
1640
|
+
// later read is exact and a duplicate never reruns an operation,
|
|
1641
|
+
// validator, or merge.
|
|
1642
|
+
let prepared: PreparedPushApply;
|
|
1437
1643
|
try {
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
return processedPushCommit(clientCommitId, serializedPersisted, true);
|
|
1445
|
-
}
|
|
1644
|
+
prepared = await tx.preparePushApply(
|
|
1645
|
+
clientId,
|
|
1646
|
+
clientCommitId,
|
|
1647
|
+
schema.version,
|
|
1648
|
+
prefetchTargets,
|
|
1649
|
+
);
|
|
1446
1650
|
} catch (error) {
|
|
1447
1651
|
if (
|
|
1448
1652
|
error instanceof SyncError &&
|
|
1449
1653
|
error.code === 'sync.idempotency_cache_miss'
|
|
1450
1654
|
) {
|
|
1655
|
+
// §6.3: answer the retryable cache-miss for this commit rather than
|
|
1656
|
+
// re-applying. Not persisted — a retry may find a readable record.
|
|
1451
1657
|
await tx.rollback();
|
|
1452
1658
|
return {
|
|
1453
1659
|
frame: idempotencyCacheMissFrame(clientCommitId, error),
|
|
@@ -1456,6 +1662,22 @@ export async function processPushOperationsWithTrace(
|
|
|
1456
1662
|
}
|
|
1457
1663
|
throw error;
|
|
1458
1664
|
}
|
|
1665
|
+
// RFC 0007: the gate is evaluated while the partition lock is held, so
|
|
1666
|
+
// the migration check and the write share one transaction and no
|
|
1667
|
+
// migration can interleave in between.
|
|
1668
|
+
const refusal = serveGateRefusal(
|
|
1669
|
+
prepared.gate,
|
|
1670
|
+
schema.version,
|
|
1671
|
+
ctx.checkpoints,
|
|
1672
|
+
);
|
|
1673
|
+
if (refusal !== undefined) {
|
|
1674
|
+
await tx.rollback();
|
|
1675
|
+
throw serveNotReadyError(refusal);
|
|
1676
|
+
}
|
|
1677
|
+
if (prepared.persisted !== undefined) {
|
|
1678
|
+
await tx.rollback();
|
|
1679
|
+
return processedPushCommit(clientCommitId, prepared.persisted, true);
|
|
1680
|
+
}
|
|
1459
1681
|
const results: PushOperationResult[] = [];
|
|
1460
1682
|
const changes: NewChange[] = [];
|
|
1461
1683
|
const validatedOperations: ValidateCommitOperation[] = [];
|
|
@@ -1470,8 +1692,10 @@ export async function processPushOperationsWithTrace(
|
|
|
1470
1692
|
ids.push(op.rowId);
|
|
1471
1693
|
targets.set(op.table, ids);
|
|
1472
1694
|
}
|
|
1473
|
-
|
|
1474
|
-
await tx.prefetchRows(
|
|
1695
|
+
if (targets.size > 0) {
|
|
1696
|
+
await tx.prefetchRows(
|
|
1697
|
+
[...targets].map(([table, rowIds]) => ({ table, rowIds })),
|
|
1698
|
+
);
|
|
1475
1699
|
}
|
|
1476
1700
|
}
|
|
1477
1701
|
for (let opIndex = 0; opIndex < operations.length; opIndex++) {
|
|
@@ -1495,9 +1719,9 @@ export async function processPushOperationsWithTrace(
|
|
|
1495
1719
|
break;
|
|
1496
1720
|
}
|
|
1497
1721
|
results.push({ opIndex, status: 'applied' });
|
|
1498
|
-
validatedOperations.push(outcome.
|
|
1722
|
+
validatedOperations.push(...outcome.operations);
|
|
1499
1723
|
stagedWrites.push(outcome.staged);
|
|
1500
|
-
|
|
1724
|
+
changes.push(...outcome.changes);
|
|
1501
1725
|
}
|
|
1502
1726
|
|
|
1503
1727
|
// §6.11: reference enforcement runs after every client operation is
|
|
@@ -1570,18 +1794,13 @@ export async function processPushOperationsWithTrace(
|
|
|
1570
1794
|
results: [terminated],
|
|
1571
1795
|
});
|
|
1572
1796
|
// Discard candidates and persist the rejection while retaining the same
|
|
1573
|
-
// partition lock. There is no unlock gap in which a duplicate can rerun
|
|
1574
|
-
|
|
1575
|
-
const canonical = await
|
|
1576
|
-
partition,
|
|
1797
|
+
// partition lock. There is no unlock gap in which a duplicate can rerun,
|
|
1798
|
+
// and the finalization returns the canonical outcome it persisted.
|
|
1799
|
+
const canonical = await tx.commitRejectedPushResult(
|
|
1577
1800
|
clientId,
|
|
1578
1801
|
clientCommitId,
|
|
1802
|
+
stored,
|
|
1579
1803
|
);
|
|
1580
|
-
if (canonical === undefined) {
|
|
1581
|
-
throw new Error(
|
|
1582
|
-
'push rejection finalization did not persist an outcome',
|
|
1583
|
-
);
|
|
1584
|
-
}
|
|
1585
1804
|
return processedPushCommit(
|
|
1586
1805
|
clientCommitId,
|
|
1587
1806
|
canonical,
|
|
@@ -1663,24 +1882,12 @@ export async function processPushOperationsWithTrace(
|
|
|
1663
1882
|
},
|
|
1664
1883
|
],
|
|
1665
1884
|
});
|
|
1666
|
-
if (commitRejectedPushResult === undefined) {
|
|
1667
|
-
await tx.rollback();
|
|
1668
|
-
throw new Error(
|
|
1669
|
-
'storage transaction lost atomic push rejection finalization support',
|
|
1670
|
-
);
|
|
1671
|
-
}
|
|
1672
1885
|
try {
|
|
1673
|
-
await commitRejectedPushResult(
|
|
1674
|
-
const canonical = await storage.getPushResult(
|
|
1675
|
-
partition,
|
|
1886
|
+
const canonical = await tx.commitRejectedPushResult(
|
|
1676
1887
|
clientId,
|
|
1677
1888
|
clientCommitId,
|
|
1889
|
+
stored,
|
|
1678
1890
|
);
|
|
1679
|
-
if (canonical === undefined) {
|
|
1680
|
-
throw new Error(
|
|
1681
|
-
'push rejection finalization did not persist an outcome',
|
|
1682
|
-
);
|
|
1683
|
-
}
|
|
1684
1891
|
return processedPushCommit(
|
|
1685
1892
|
clientCommitId,
|
|
1686
1893
|
canonical,
|