@syncular/server 0.8.0 → 0.10.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/README.md +113 -0
- package/dist/context.d.ts +8 -1
- package/dist/d1-storage.d.ts +10 -1
- package/dist/d1-storage.js +0 -0
- package/dist/frame-bytes.js +15 -0
- package/dist/handler.js +15 -0
- package/dist/postgres-storage.js +62 -0
- package/dist/push.js +191 -28
- package/dist/realtime.d.ts +5 -0
- package/dist/realtime.js +6 -0
- package/dist/sqlite-dialect.js +2 -0
- package/dist/sqlite-storage.js +22 -0
- package/dist/storage.d.ts +18 -0
- package/dist/validate.d.ts +62 -2
- package/dist/validate.js +39 -1
- package/package.json +2 -2
- package/src/context.ts +8 -1
- package/src/d1-storage.ts +0 -0
- package/src/frame-bytes.ts +16 -0
- package/src/handler.ts +20 -0
- package/src/postgres-storage.ts +79 -0
- package/src/push.ts +290 -29
- package/src/realtime.ts +11 -0
- package/src/sqlite-dialect.ts +3 -0
- package/src/sqlite-storage.ts +35 -0
- package/src/storage.ts +22 -0
- package/src/validate.ts +93 -2
package/src/push.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type PushOperationResult,
|
|
26
26
|
type PushResultFrame,
|
|
27
27
|
parseBlobRef,
|
|
28
|
+
type RejectionDetails,
|
|
28
29
|
type RowValue,
|
|
29
30
|
} from '@syncular/core';
|
|
30
31
|
import type { BlobStore } from './blob-store';
|
|
@@ -41,8 +42,18 @@ import type {
|
|
|
41
42
|
StoredCommit,
|
|
42
43
|
StoredPushResult,
|
|
43
44
|
} from './storage';
|
|
44
|
-
import type {
|
|
45
|
-
|
|
45
|
+
import type {
|
|
46
|
+
CommitValidationReader,
|
|
47
|
+
CommitValidator,
|
|
48
|
+
ValidateCommitOperation,
|
|
49
|
+
ValidateOpKind,
|
|
50
|
+
ValidatorRegistry,
|
|
51
|
+
} from './validate';
|
|
52
|
+
import {
|
|
53
|
+
CommitValidationRejection,
|
|
54
|
+
toValidateRow,
|
|
55
|
+
ValidationRejection,
|
|
56
|
+
} from './validate';
|
|
46
57
|
|
|
47
58
|
/**
|
|
48
59
|
* Extract the blobIds a decoded row references through its `blob_ref`
|
|
@@ -62,7 +73,11 @@ function blobIdsInRow(
|
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
type OperationOutcome =
|
|
65
|
-
| {
|
|
76
|
+
| {
|
|
77
|
+
readonly kind: 'applied';
|
|
78
|
+
readonly change: NewChange | undefined;
|
|
79
|
+
readonly operation: ValidateCommitOperation;
|
|
80
|
+
}
|
|
66
81
|
| { readonly kind: 'terminate'; readonly record: PushOperationResult };
|
|
67
82
|
|
|
68
83
|
function errorRecord(
|
|
@@ -70,10 +85,18 @@ function errorRecord(
|
|
|
70
85
|
code: string,
|
|
71
86
|
message: string,
|
|
72
87
|
retryable = false,
|
|
88
|
+
details?: RejectionDetails,
|
|
73
89
|
): OperationOutcome {
|
|
74
90
|
return {
|
|
75
91
|
kind: 'terminate',
|
|
76
|
-
record: {
|
|
92
|
+
record: {
|
|
93
|
+
opIndex,
|
|
94
|
+
status: 'error',
|
|
95
|
+
code,
|
|
96
|
+
message,
|
|
97
|
+
retryable,
|
|
98
|
+
...(details !== undefined ? { details } : {}),
|
|
99
|
+
},
|
|
77
100
|
};
|
|
78
101
|
}
|
|
79
102
|
|
|
@@ -141,7 +164,13 @@ async function runValidator(
|
|
|
141
164
|
);
|
|
142
165
|
} catch (error) {
|
|
143
166
|
if (error instanceof ValidationRejection) {
|
|
144
|
-
return errorRecord(
|
|
167
|
+
return errorRecord(
|
|
168
|
+
opIndex,
|
|
169
|
+
error.code,
|
|
170
|
+
error.message,
|
|
171
|
+
false,
|
|
172
|
+
error.details,
|
|
173
|
+
);
|
|
145
174
|
}
|
|
146
175
|
// §6.7: a non-ValidationRejection throw is still a rejection, mapped to
|
|
147
176
|
// the generic server-side constraint code (§10.2) — the validator's
|
|
@@ -237,7 +266,18 @@ async function applyOperation(
|
|
|
237
266
|
if (op.op === 'delete') {
|
|
238
267
|
if (stored === undefined) {
|
|
239
268
|
// Deleting an absent row is applied (idempotent, §6.2); no change.
|
|
240
|
-
return {
|
|
269
|
+
return {
|
|
270
|
+
kind: 'applied',
|
|
271
|
+
change: undefined,
|
|
272
|
+
operation: {
|
|
273
|
+
opIndex,
|
|
274
|
+
op: 'delete',
|
|
275
|
+
table: table.name,
|
|
276
|
+
rowId: op.rowId,
|
|
277
|
+
row: undefined,
|
|
278
|
+
stored: undefined,
|
|
279
|
+
},
|
|
280
|
+
};
|
|
241
281
|
}
|
|
242
282
|
if (!authorizeWrite(table, stored.scopes, resolved)) {
|
|
243
283
|
return errorRecord(
|
|
@@ -257,13 +297,14 @@ async function applyOperation(
|
|
|
257
297
|
// §6.7: validate the delete against the stored row (row = undefined,
|
|
258
298
|
// stored = the row about to be removed). Only reached for an existing
|
|
259
299
|
// row — an absent-row delete is an idempotent no-op above.
|
|
300
|
+
const storedValues = decodeRow(table.columns, stored.payload);
|
|
260
301
|
const deleteReject = await runValidator(
|
|
261
302
|
validators,
|
|
262
303
|
table,
|
|
263
304
|
'delete',
|
|
264
305
|
op.rowId,
|
|
265
306
|
undefined,
|
|
266
|
-
|
|
307
|
+
storedValues,
|
|
267
308
|
opIndex,
|
|
268
309
|
partition,
|
|
269
310
|
actorId,
|
|
@@ -278,6 +319,15 @@ async function applyOperation(
|
|
|
278
319
|
op: 'delete',
|
|
279
320
|
scopes: stored.scopes,
|
|
280
321
|
},
|
|
322
|
+
operation: {
|
|
323
|
+
opIndex,
|
|
324
|
+
op: 'delete',
|
|
325
|
+
table: table.name,
|
|
326
|
+
rowId: op.rowId,
|
|
327
|
+
row: undefined,
|
|
328
|
+
stored: toValidateRow(table.columns, storedValues),
|
|
329
|
+
storedServerVersion: stored.serverVersion,
|
|
330
|
+
},
|
|
281
331
|
};
|
|
282
332
|
}
|
|
283
333
|
|
|
@@ -396,6 +446,16 @@ async function applyOperation(
|
|
|
396
446
|
scopes: stored.scopes,
|
|
397
447
|
payload: newPayload,
|
|
398
448
|
},
|
|
449
|
+
operation: {
|
|
450
|
+
opIndex,
|
|
451
|
+
op: 'upsert',
|
|
452
|
+
table: table.name,
|
|
453
|
+
rowId: op.rowId,
|
|
454
|
+
row: toValidateRow(table.columns, values),
|
|
455
|
+
stored: toValidateRow(table.columns, storedValues),
|
|
456
|
+
storedServerVersion: stored.serverVersion,
|
|
457
|
+
nextServerVersion: newVersion,
|
|
458
|
+
},
|
|
399
459
|
};
|
|
400
460
|
}
|
|
401
461
|
|
|
@@ -491,6 +551,15 @@ async function applyOperation(
|
|
|
491
551
|
scopes: extracted.scopes,
|
|
492
552
|
payload: insertPayload,
|
|
493
553
|
},
|
|
554
|
+
operation: {
|
|
555
|
+
opIndex,
|
|
556
|
+
op: 'upsert',
|
|
557
|
+
table: table.name,
|
|
558
|
+
rowId: op.rowId,
|
|
559
|
+
row: toValidateRow(table.columns, values),
|
|
560
|
+
stored: undefined,
|
|
561
|
+
nextServerVersion: 1,
|
|
562
|
+
},
|
|
494
563
|
};
|
|
495
564
|
}
|
|
496
565
|
|
|
@@ -547,6 +616,121 @@ function missingScopeVariable(
|
|
|
547
616
|
return undefined;
|
|
548
617
|
}
|
|
549
618
|
|
|
619
|
+
function commitValidationReader(
|
|
620
|
+
tx: StorageTransaction,
|
|
621
|
+
schema: CompiledSchema,
|
|
622
|
+
): CommitValidationReader {
|
|
623
|
+
const tableFor = (name: string): CompiledTable => {
|
|
624
|
+
const table = schema.tables.get(name);
|
|
625
|
+
if (table === undefined) {
|
|
626
|
+
throw new Error(
|
|
627
|
+
`commit validator requested unknown table ${JSON.stringify(name)}`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
return table;
|
|
631
|
+
};
|
|
632
|
+
return {
|
|
633
|
+
getRow: async (tableName, rowId) => {
|
|
634
|
+
const table = tableFor(tableName);
|
|
635
|
+
const stored = await tx.getRow(tableName, rowId);
|
|
636
|
+
if (stored === undefined) return undefined;
|
|
637
|
+
return {
|
|
638
|
+
row: toValidateRow(
|
|
639
|
+
table.columns,
|
|
640
|
+
decodeRow(table.columns, stored.payload),
|
|
641
|
+
),
|
|
642
|
+
serverVersion: stored.serverVersion,
|
|
643
|
+
};
|
|
644
|
+
},
|
|
645
|
+
scanRows: async ({
|
|
646
|
+
table: tableName,
|
|
647
|
+
scopeFilter,
|
|
648
|
+
afterRowId = null,
|
|
649
|
+
limit = 100,
|
|
650
|
+
}) => {
|
|
651
|
+
const table = tableFor(tableName);
|
|
652
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1_000) {
|
|
653
|
+
throw new Error(
|
|
654
|
+
'commit validator scan limit must be an integer from 1 to 1,000',
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
if (tx.scanRows === undefined) {
|
|
658
|
+
throw new Error(
|
|
659
|
+
'storage transaction does not support commit-validator scans',
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
const rows = await tx.scanRows({
|
|
663
|
+
table: tableName,
|
|
664
|
+
scopeFilter,
|
|
665
|
+
afterRowId,
|
|
666
|
+
limit,
|
|
667
|
+
});
|
|
668
|
+
return rows.map((stored) => ({
|
|
669
|
+
row: toValidateRow(
|
|
670
|
+
table.columns,
|
|
671
|
+
decodeRow(table.columns, stored.payload),
|
|
672
|
+
),
|
|
673
|
+
serverVersion: stored.serverVersion,
|
|
674
|
+
}));
|
|
675
|
+
},
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function runCommitValidator(
|
|
680
|
+
validator: CommitValidator | undefined,
|
|
681
|
+
tx: StorageTransaction,
|
|
682
|
+
schema: CompiledSchema,
|
|
683
|
+
clientId: string,
|
|
684
|
+
clientCommitId: string,
|
|
685
|
+
actorId: string,
|
|
686
|
+
partition: string,
|
|
687
|
+
operations: readonly ValidateCommitOperation[],
|
|
688
|
+
): Promise<OperationOutcome | undefined> {
|
|
689
|
+
if (validator === undefined) return undefined;
|
|
690
|
+
try {
|
|
691
|
+
await validator({
|
|
692
|
+
clientId,
|
|
693
|
+
clientCommitId,
|
|
694
|
+
actorId,
|
|
695
|
+
partition,
|
|
696
|
+
operations,
|
|
697
|
+
read: commitValidationReader(tx, schema),
|
|
698
|
+
});
|
|
699
|
+
} catch (error) {
|
|
700
|
+
if (error instanceof CommitValidationRejection) {
|
|
701
|
+
if (error.opIndex >= operations.length) {
|
|
702
|
+
return errorRecord(
|
|
703
|
+
0,
|
|
704
|
+
'sync.constraint_violation',
|
|
705
|
+
`commit validator rejection names unavailable opIndex ${error.opIndex}`,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
return errorRecord(
|
|
709
|
+
error.opIndex,
|
|
710
|
+
error.code,
|
|
711
|
+
error.message,
|
|
712
|
+
false,
|
|
713
|
+
error.details,
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
if (error instanceof ValidationRejection) {
|
|
717
|
+
return errorRecord(
|
|
718
|
+
operations[0]?.opIndex ?? 0,
|
|
719
|
+
error.code,
|
|
720
|
+
error.message,
|
|
721
|
+
false,
|
|
722
|
+
error.details,
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
return errorRecord(
|
|
726
|
+
operations[0]?.opIndex ?? 0,
|
|
727
|
+
'sync.constraint_violation',
|
|
728
|
+
`whole-commit validator threw: ${error instanceof Error ? error.message : String(error)}`,
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
return undefined;
|
|
732
|
+
}
|
|
733
|
+
|
|
550
734
|
function resultFrame(
|
|
551
735
|
clientCommitId: string,
|
|
552
736
|
stored: StoredPushResult,
|
|
@@ -565,6 +749,26 @@ function resultFrame(
|
|
|
565
749
|
};
|
|
566
750
|
}
|
|
567
751
|
|
|
752
|
+
function idempotencyCacheMissFrame(
|
|
753
|
+
clientCommitId: string,
|
|
754
|
+
error: SyncError,
|
|
755
|
+
): PushResultFrame {
|
|
756
|
+
return {
|
|
757
|
+
type: 'PUSH_RESULT',
|
|
758
|
+
clientCommitId,
|
|
759
|
+
status: 'rejected',
|
|
760
|
+
results: [
|
|
761
|
+
{
|
|
762
|
+
opIndex: 0,
|
|
763
|
+
status: 'error',
|
|
764
|
+
code: 'sync.idempotency_cache_miss',
|
|
765
|
+
message: error.message,
|
|
766
|
+
retryable: true,
|
|
767
|
+
},
|
|
768
|
+
],
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
568
772
|
export interface AppliedCommitEvent {
|
|
569
773
|
readonly commit: StoredCommit;
|
|
570
774
|
}
|
|
@@ -595,20 +799,7 @@ export async function processPushCommit(
|
|
|
595
799
|
) {
|
|
596
800
|
// §6.3: answer the retryable cache-miss for this commit rather than
|
|
597
801
|
// re-applying. Not persisted — a retry may find a readable record.
|
|
598
|
-
return
|
|
599
|
-
type: 'PUSH_RESULT',
|
|
600
|
-
clientCommitId: frame.clientCommitId,
|
|
601
|
-
status: 'rejected',
|
|
602
|
-
results: [
|
|
603
|
-
{
|
|
604
|
-
opIndex: 0,
|
|
605
|
-
status: 'error',
|
|
606
|
-
code: 'sync.idempotency_cache_miss',
|
|
607
|
-
message: error.message,
|
|
608
|
-
retryable: true,
|
|
609
|
-
},
|
|
610
|
-
],
|
|
611
|
-
};
|
|
802
|
+
return idempotencyCacheMissFrame(frame.clientCommitId, error);
|
|
612
803
|
}
|
|
613
804
|
throw error;
|
|
614
805
|
}
|
|
@@ -620,10 +811,47 @@ export async function processPushCommit(
|
|
|
620
811
|
const blobCtx: BlobApplyContext = { store: ctx.blobs, partition };
|
|
621
812
|
const crdtMergers = ctx.crdtMergers;
|
|
622
813
|
const validators = ctx.validators;
|
|
814
|
+
const commitValidator = ctx.commitValidator;
|
|
623
815
|
const tx = await storage.begin(partition);
|
|
816
|
+
const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
|
|
624
817
|
try {
|
|
818
|
+
if (commitValidator !== undefined) {
|
|
819
|
+
if (
|
|
820
|
+
tx.lockPartitionForCommitValidation === undefined ||
|
|
821
|
+
commitRejectedPushResult === undefined
|
|
822
|
+
) {
|
|
823
|
+
throw new Error(
|
|
824
|
+
'storage transaction does not support atomic whole-commit validation finalization',
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
await tx.lockPartitionForCommitValidation();
|
|
828
|
+
// The optimistic lookup above may have raced another request for the
|
|
829
|
+
// same idempotency key. Re-check after acquiring partition serialization
|
|
830
|
+
// so a concurrent duplicate never reruns the aggregate validator.
|
|
831
|
+
try {
|
|
832
|
+
const serializedPersisted = await storage.getPushResult(
|
|
833
|
+
partition,
|
|
834
|
+
clientId,
|
|
835
|
+
frame.clientCommitId,
|
|
836
|
+
);
|
|
837
|
+
if (serializedPersisted !== undefined) {
|
|
838
|
+
await tx.rollback();
|
|
839
|
+
return resultFrame(frame.clientCommitId, serializedPersisted, true);
|
|
840
|
+
}
|
|
841
|
+
} catch (error) {
|
|
842
|
+
if (
|
|
843
|
+
error instanceof SyncError &&
|
|
844
|
+
error.code === 'sync.idempotency_cache_miss'
|
|
845
|
+
) {
|
|
846
|
+
await tx.rollback();
|
|
847
|
+
return idempotencyCacheMissFrame(frame.clientCommitId, error);
|
|
848
|
+
}
|
|
849
|
+
throw error;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
625
852
|
const results: PushOperationResult[] = [];
|
|
626
853
|
const changes: NewChange[] = [];
|
|
854
|
+
const validatedOperations: ValidateCommitOperation[] = [];
|
|
627
855
|
let terminated: PushOperationResult | undefined;
|
|
628
856
|
for (let opIndex = 0; opIndex < frame.operations.length; opIndex++) {
|
|
629
857
|
const op = frame.operations[opIndex];
|
|
@@ -645,24 +873,57 @@ export async function processPushCommit(
|
|
|
645
873
|
break;
|
|
646
874
|
}
|
|
647
875
|
results.push({ opIndex, status: 'applied' });
|
|
876
|
+
validatedOperations.push(outcome.operation);
|
|
648
877
|
if (outcome.change !== undefined) changes.push(outcome.change);
|
|
649
878
|
}
|
|
650
879
|
|
|
880
|
+
if (terminated === undefined) {
|
|
881
|
+
const commitReject = await runCommitValidator(
|
|
882
|
+
commitValidator,
|
|
883
|
+
tx,
|
|
884
|
+
schema,
|
|
885
|
+
clientId,
|
|
886
|
+
frame.clientCommitId,
|
|
887
|
+
ctx.actorId,
|
|
888
|
+
partition,
|
|
889
|
+
validatedOperations,
|
|
890
|
+
);
|
|
891
|
+
if (commitReject?.kind === 'terminate') {
|
|
892
|
+
terminated = commitReject.record;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
651
896
|
if (terminated !== undefined) {
|
|
652
897
|
// §6.3 rejected: only the terminating operation's record; §6.4:
|
|
653
898
|
// every write of the commit rolls back.
|
|
654
|
-
await tx.rollback();
|
|
655
899
|
const stored: StoredPushResult = {
|
|
656
900
|
status: 'rejected',
|
|
657
901
|
results: [terminated],
|
|
658
902
|
};
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
903
|
+
if (commitValidator !== undefined) {
|
|
904
|
+
// Discard candidate rows and persist the rejection while retaining the
|
|
905
|
+
// same partition lock. This closes the duplicate-request race between
|
|
906
|
+
// rollback and the durable idempotency outcome.
|
|
907
|
+
if (commitRejectedPushResult === undefined) {
|
|
908
|
+
throw new Error(
|
|
909
|
+
'storage transaction lost whole-commit rejection finalization support',
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
|
|
913
|
+
} else {
|
|
914
|
+
await tx.rollback();
|
|
915
|
+
const rejectionTx = await storage.begin(partition);
|
|
916
|
+
try {
|
|
917
|
+
await rejectionTx.putPushResult(
|
|
918
|
+
clientId,
|
|
919
|
+
frame.clientCommitId,
|
|
920
|
+
stored,
|
|
921
|
+
);
|
|
922
|
+
await rejectionTx.commit();
|
|
923
|
+
} catch (error) {
|
|
924
|
+
await rejectionTx.rollback();
|
|
925
|
+
throw error;
|
|
926
|
+
}
|
|
666
927
|
}
|
|
667
928
|
return resultFrame(frame.clientCommitId, stored, false);
|
|
668
929
|
}
|
package/src/realtime.ts
CHANGED
|
@@ -50,11 +50,16 @@ import {
|
|
|
50
50
|
import type { SegmentStore } from './segment-store';
|
|
51
51
|
import type { SegmentUrlConfig } from './signed-url';
|
|
52
52
|
import type { ServerStorage, StoredCommit } from './storage';
|
|
53
|
+
import type { CommitValidator, ValidatorRegistry } from './validate';
|
|
53
54
|
|
|
54
55
|
export interface RealtimeHubConfig {
|
|
55
56
|
readonly schema: ServerSchema;
|
|
56
57
|
readonly storage: ServerStorage;
|
|
57
58
|
readonly resolveScopes: ResolveScopes;
|
|
59
|
+
/** §6.7 validators used by sync rounds carried over this socket. */
|
|
60
|
+
readonly validators?: ValidatorRegistry;
|
|
61
|
+
/** §6.8 whole-commit validator shared with HTTP sync rounds. */
|
|
62
|
+
readonly commitValidator?: CommitValidator;
|
|
58
63
|
readonly clock?: () => number;
|
|
59
64
|
/** Deltas larger than this become `delta-too-large` wake-ups (§8.2). */
|
|
60
65
|
readonly maxDeltaBytes?: number;
|
|
@@ -926,6 +931,12 @@ export class RealtimeHub {
|
|
|
926
931
|
storage: this.#config.storage,
|
|
927
932
|
segments,
|
|
928
933
|
resolveScopes: this.#config.resolveScopes,
|
|
934
|
+
...(this.#config.validators !== undefined
|
|
935
|
+
? { validators: this.#config.validators }
|
|
936
|
+
: {}),
|
|
937
|
+
...(this.#config.commitValidator !== undefined
|
|
938
|
+
? { commitValidator: this.#config.commitValidator }
|
|
939
|
+
: {}),
|
|
929
940
|
...(this.#config.clock !== undefined
|
|
930
941
|
? { clock: this.#config.clock }
|
|
931
942
|
: {}),
|
package/src/sqlite-dialect.ts
CHANGED
|
@@ -126,6 +126,7 @@ interface SerializedResult {
|
|
|
126
126
|
serverVersion?: number;
|
|
127
127
|
serverRow?: string;
|
|
128
128
|
retryable?: boolean;
|
|
129
|
+
details?: import('@syncular/core').RejectionDetails;
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
/** Serialize a push result to the JSON `TEXT` stored in `sync_push_results`. */
|
|
@@ -151,6 +152,7 @@ export function serializePushResult(result: StoredPushResult): string {
|
|
|
151
152
|
code: record.code,
|
|
152
153
|
message: record.message,
|
|
153
154
|
retryable: record.retryable,
|
|
155
|
+
...(record.details !== undefined ? { details: record.details } : {}),
|
|
154
156
|
};
|
|
155
157
|
}
|
|
156
158
|
return { opIndex: record.opIndex, status: record.status };
|
|
@@ -182,6 +184,7 @@ export function deserializePushResult(text: string): StoredPushResult {
|
|
|
182
184
|
code: record.code ?? '',
|
|
183
185
|
message: record.message ?? '',
|
|
184
186
|
retryable: record.retryable ?? false,
|
|
187
|
+
...(record.details !== undefined ? { details: record.details } : {}),
|
|
185
188
|
};
|
|
186
189
|
}
|
|
187
190
|
return { opIndex: record.opIndex, status: 'applied' };
|
package/src/sqlite-storage.ts
CHANGED
|
@@ -60,6 +60,7 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
60
60
|
#storage: SqliteServerStorage;
|
|
61
61
|
#partition: string;
|
|
62
62
|
#open = true;
|
|
63
|
+
#commitValidationSavepoint = false;
|
|
63
64
|
|
|
64
65
|
constructor(storage: SqliteServerStorage, partition: string) {
|
|
65
66
|
this.#storage = storage;
|
|
@@ -76,6 +77,40 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
76
77
|
return this.#storage.getRow(this.#partition, table, rowId);
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
scanRows(query: RowScanQuery): Promise<StoredRow[]> {
|
|
81
|
+
this.#assertOpen();
|
|
82
|
+
return this.#storage.scanRows(this.#partition, query);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async lockPartitionForCommitValidation(): Promise<void> {
|
|
86
|
+
this.#assertOpen();
|
|
87
|
+
// BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
|
|
88
|
+
this.#storage.db.exec('SAVEPOINT syncular_commit_validation_candidate');
|
|
89
|
+
this.#commitValidationSavepoint = true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async commitRejectedPushResult(
|
|
93
|
+
clientId: string,
|
|
94
|
+
clientCommitId: string,
|
|
95
|
+
result: StoredPushResult,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
this.#assertOpen();
|
|
98
|
+
if (!this.#commitValidationSavepoint) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
'whole-commit rejection requires its validation savepoint',
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
this.#storage.db.exec(
|
|
104
|
+
'ROLLBACK TO SAVEPOINT syncular_commit_validation_candidate',
|
|
105
|
+
);
|
|
106
|
+
this.#storage.db.exec(
|
|
107
|
+
'RELEASE SAVEPOINT syncular_commit_validation_candidate',
|
|
108
|
+
);
|
|
109
|
+
this.#commitValidationSavepoint = false;
|
|
110
|
+
await this.putPushResult(clientId, clientCommitId, result);
|
|
111
|
+
await this.commit();
|
|
112
|
+
}
|
|
113
|
+
|
|
79
114
|
async upsertRow(table: string, row: StoredRow): Promise<void> {
|
|
80
115
|
this.#assertOpen();
|
|
81
116
|
this.#storage.writeRow(this.#partition, table, row);
|
package/src/storage.ts
CHANGED
|
@@ -166,6 +166,28 @@ export interface ScopeActivityQuery {
|
|
|
166
166
|
*/
|
|
167
167
|
export interface StorageTransaction {
|
|
168
168
|
getRow(table: string, rowId: string): Promise<StoredRow | undefined>;
|
|
169
|
+
/**
|
|
170
|
+
* Optional candidate-state scan used only by whole-commit validation.
|
|
171
|
+
* In-tree SQLite/Postgres/D1 backends implement it with read-your-own-writes
|
|
172
|
+
* semantics. A custom backend may omit it until `commitValidator` is used.
|
|
173
|
+
*/
|
|
174
|
+
scanRows?(query: RowScanQuery): Promise<StoredRow[]>;
|
|
175
|
+
/**
|
|
176
|
+
* Serialize candidate-state validation for this partition before any row
|
|
177
|
+
* read/write. Required at runtime when `commitValidator` is configured.
|
|
178
|
+
*/
|
|
179
|
+
lockPartitionForCommitValidation?(): Promise<void>;
|
|
180
|
+
/**
|
|
181
|
+
* §6.8 rejection finalization while the validation serialization lock is
|
|
182
|
+
* still held: discard every candidate write, persist the rejected
|
|
183
|
+
* idempotency result, and finish the transaction atomically. Required when
|
|
184
|
+
* `commitValidator` is configured so a concurrent duplicate cannot rerun it.
|
|
185
|
+
*/
|
|
186
|
+
commitRejectedPushResult?(
|
|
187
|
+
clientId: string,
|
|
188
|
+
clientCommitId: string,
|
|
189
|
+
result: StoredPushResult,
|
|
190
|
+
): Promise<void>;
|
|
169
191
|
upsertRow(table: string, row: StoredRow): Promise<void>;
|
|
170
192
|
deleteRow(table: string, rowId: string): Promise<void>;
|
|
171
193
|
/** Allocates the next per-partition commitSeq and appends the commit. */
|
package/src/validate.ts
CHANGED
|
@@ -13,7 +13,13 @@
|
|
|
13
13
|
* path pays only an `undefined` check per operation and builds no context
|
|
14
14
|
* object — zero cost, the events-seam discipline.
|
|
15
15
|
*/
|
|
16
|
-
import
|
|
16
|
+
import {
|
|
17
|
+
normalizeRejectionDetails,
|
|
18
|
+
type RejectionDetails,
|
|
19
|
+
type RowColumn,
|
|
20
|
+
type RowValue,
|
|
21
|
+
type ScopeMap,
|
|
22
|
+
} from '@syncular/core';
|
|
17
23
|
|
|
18
24
|
/**
|
|
19
25
|
* §6.7 reserved code prefixes. A host validator code MUST NOT start with
|
|
@@ -59,6 +65,63 @@ export interface ValidateOperation {
|
|
|
59
65
|
readonly stored: ValidateRow | undefined;
|
|
60
66
|
}
|
|
61
67
|
|
|
68
|
+
/**
|
|
69
|
+
* One authorized, decoded operation presented to the whole-commit validator.
|
|
70
|
+
* `row` is the final candidate row after scope stripping and CRDT merge;
|
|
71
|
+
* `stored` is the state observed immediately before this operation. Multiple
|
|
72
|
+
* operations targeting one row therefore retain their sequential evidence.
|
|
73
|
+
*/
|
|
74
|
+
export interface ValidateCommitOperation extends ValidateOperation {
|
|
75
|
+
readonly opIndex: number;
|
|
76
|
+
readonly storedServerVersion?: number;
|
|
77
|
+
readonly nextServerVersion?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One candidate-state row read from inside the still-open commit transaction. */
|
|
81
|
+
export interface CommitValidationRow {
|
|
82
|
+
readonly row: ValidateRow;
|
|
83
|
+
readonly serverVersion: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface CommitValidationScanInput {
|
|
87
|
+
readonly table: string;
|
|
88
|
+
/** Exact scope filter, using the same AND-across-keys semantics as sync. */
|
|
89
|
+
readonly scopeFilter: ScopeMap;
|
|
90
|
+
readonly afterRowId?: string | null;
|
|
91
|
+
/** Bounded per call by the server to 1..1,000; defaults to 100. */
|
|
92
|
+
readonly limit?: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Candidate-state reads bound to the same storage transaction as the commit.
|
|
97
|
+
* Reads observe every staged sibling operation and no uncommitted competing
|
|
98
|
+
* transaction when the storage's commit-validation lock contract is honored.
|
|
99
|
+
*/
|
|
100
|
+
export interface CommitValidationReader {
|
|
101
|
+
getRow(
|
|
102
|
+
table: string,
|
|
103
|
+
rowId: string,
|
|
104
|
+
): Promise<CommitValidationRow | undefined>;
|
|
105
|
+
scanRows(input: CommitValidationScanInput): Promise<CommitValidationRow[]>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ValidateCommitInput {
|
|
109
|
+
readonly clientId: string;
|
|
110
|
+
readonly clientCommitId: string;
|
|
111
|
+
readonly actorId: string;
|
|
112
|
+
readonly partition: string;
|
|
113
|
+
readonly operations: readonly ValidateCommitOperation[];
|
|
114
|
+
readonly read: CommitValidationReader;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Runs once after every operation passed protocol/scope/row validation and was
|
|
119
|
+
* staged, but before commit-log/idempotency append and transaction commit.
|
|
120
|
+
*/
|
|
121
|
+
export type CommitValidator = (
|
|
122
|
+
input: ValidateCommitInput,
|
|
123
|
+
) => void | Promise<void>;
|
|
124
|
+
|
|
62
125
|
/** Ambient context a validator may consult (§6.7). */
|
|
63
126
|
export interface ValidateContext {
|
|
64
127
|
/** Host-authenticated actor (§1.1) performing the write. */
|
|
@@ -93,8 +156,13 @@ export type ValidatorRegistry = Readonly<Record<string, Validator>>;
|
|
|
93
156
|
export class ValidationRejection extends Error {
|
|
94
157
|
override readonly name = 'ValidationRejection';
|
|
95
158
|
readonly code: string;
|
|
159
|
+
/**
|
|
160
|
+
* Bounded code-like metadata explicitly safe to replicate to authorized
|
|
161
|
+
* clients. Never place diagnostic prose, secrets, or clinical values here.
|
|
162
|
+
*/
|
|
163
|
+
readonly details: RejectionDetails | undefined;
|
|
96
164
|
|
|
97
|
-
constructor(code: string, message?: string) {
|
|
165
|
+
constructor(code: string, message?: string, details?: RejectionDetails) {
|
|
98
166
|
super(message ?? code);
|
|
99
167
|
if (code.length === 0) {
|
|
100
168
|
throw new Error('ValidationRejection code must be non-empty (§6.7)');
|
|
@@ -107,6 +175,29 @@ export class ValidationRejection extends Error {
|
|
|
107
175
|
}
|
|
108
176
|
}
|
|
109
177
|
this.code = code;
|
|
178
|
+
this.details =
|
|
179
|
+
details === undefined ? undefined : normalizeRejectionDetails(details);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A whole-commit rejection attributed to one operation for the existing
|
|
185
|
+
* per-operation PUSH_RESULT envelope. The validator may still describe
|
|
186
|
+
* multiple affected fields in `details.fieldPaths`.
|
|
187
|
+
*/
|
|
188
|
+
export class CommitValidationRejection extends ValidationRejection {
|
|
189
|
+
constructor(
|
|
190
|
+
readonly opIndex: number,
|
|
191
|
+
code: string,
|
|
192
|
+
message?: string,
|
|
193
|
+
details?: RejectionDetails,
|
|
194
|
+
) {
|
|
195
|
+
super(code, message, details);
|
|
196
|
+
if (!Number.isSafeInteger(opIndex) || opIndex < 0) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
'CommitValidationRejection opIndex must be a non-negative safe integer',
|
|
199
|
+
);
|
|
200
|
+
}
|
|
110
201
|
}
|
|
111
202
|
}
|
|
112
203
|
|