@syncular/server 0.9.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 +67 -3
- package/dist/context.d.ts +8 -1
- package/dist/d1-storage.d.ts +10 -1
- package/dist/d1-storage.js +0 -0
- package/dist/postgres-storage.js +60 -0
- package/dist/push.js +181 -25
- package/dist/realtime.d.ts +3 -1
- package/dist/realtime.js +3 -0
- package/dist/sqlite-storage.js +22 -0
- package/dist/storage.d.ts +18 -0
- package/dist/validate.d.ts +56 -1
- package/dist/validate.js +15 -0
- package/package.json +2 -2
- package/src/context.ts +8 -1
- package/src/d1-storage.ts +0 -0
- package/src/postgres-storage.ts +76 -0
- package/src/push.ts +273 -27
- package/src/realtime.ts +6 -1
- package/src/sqlite-storage.ts +35 -0
- package/src/storage.ts +22 -0
- package/src/validate.ts +79 -0
package/dist/validate.js
CHANGED
|
@@ -57,6 +57,21 @@ export class ValidationRejection extends Error {
|
|
|
57
57
|
details === undefined ? undefined : normalizeRejectionDetails(details);
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* A whole-commit rejection attributed to one operation for the existing
|
|
62
|
+
* per-operation PUSH_RESULT envelope. The validator may still describe
|
|
63
|
+
* multiple affected fields in `details.fieldPaths`.
|
|
64
|
+
*/
|
|
65
|
+
export class CommitValidationRejection extends ValidationRejection {
|
|
66
|
+
opIndex;
|
|
67
|
+
constructor(opIndex, code, message, details) {
|
|
68
|
+
super(code, message, details);
|
|
69
|
+
this.opIndex = opIndex;
|
|
70
|
+
if (!Number.isSafeInteger(opIndex) || opIndex < 0) {
|
|
71
|
+
throw new Error('CommitValidationRejection opIndex must be a non-negative safe integer');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
60
75
|
/** Build the column-keyed row object a validator inspects (§6.7). */
|
|
61
76
|
export function toValidateRow(columns, values) {
|
|
62
77
|
const row = {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"!dist/**/*.test.d.ts"
|
|
54
54
|
],
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@syncular/core": "0.
|
|
56
|
+
"@syncular/core": "0.10.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/context.ts
CHANGED
|
@@ -18,7 +18,7 @@ import type {
|
|
|
18
18
|
} from './signed-url';
|
|
19
19
|
import type { SqliteImageBuilder } from './sqlite-image';
|
|
20
20
|
import type { ServerStorage, StoredCommit } from './storage';
|
|
21
|
-
import type { ValidatorRegistry } from './validate';
|
|
21
|
+
import type { CommitValidator, ValidatorRegistry } from './validate';
|
|
22
22
|
|
|
23
23
|
/** SSP2 body content type (§1.1). */
|
|
24
24
|
export const SSP2_CONTENT_TYPE = 'application/vnd.syncular.sync.v2';
|
|
@@ -112,6 +112,13 @@ export interface SyncServerConfig {
|
|
|
112
112
|
* process, never on the wire.
|
|
113
113
|
*/
|
|
114
114
|
readonly validators?: ValidatorRegistry;
|
|
115
|
+
/**
|
|
116
|
+
* §6.8 whole-commit validator. When present, the storage serializes this
|
|
117
|
+
* partition before operation reads/writes, then invokes the callback once
|
|
118
|
+
* over every staged decoded operation and candidate-state reader before
|
|
119
|
+
* commit-log/idempotency append. A throw rolls back the complete commit.
|
|
120
|
+
*/
|
|
121
|
+
readonly commitValidator?: CommitValidator;
|
|
115
122
|
readonly resolveScopes: ResolveScopes;
|
|
116
123
|
/**
|
|
117
124
|
* §7.3 auth leases. Absent ⇒ the feature is off: no `LEASE` frame is
|
package/src/d1-storage.ts
CHANGED
|
Binary file
|
package/src/postgres-storage.ts
CHANGED
|
@@ -396,6 +396,7 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
396
396
|
#partition: string;
|
|
397
397
|
#resolveTable: (name: string) => CompiledTable;
|
|
398
398
|
#open = true;
|
|
399
|
+
#commitValidationSavepoint = false;
|
|
399
400
|
/** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
|
|
400
401
|
#resolve: () => void;
|
|
401
402
|
#reject: (error: unknown) => void;
|
|
@@ -428,6 +429,81 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
428
429
|
);
|
|
429
430
|
}
|
|
430
431
|
|
|
432
|
+
async scanRows(query: RowScanQuery): Promise<StoredRow[]> {
|
|
433
|
+
this.#assertOpen();
|
|
434
|
+
const variables = Object.keys(query.scopeFilter).sort();
|
|
435
|
+
const firstVariable = variables[0];
|
|
436
|
+
if (firstVariable === undefined) return [];
|
|
437
|
+
const firstValues = query.scopeFilter[firstVariable] ?? [];
|
|
438
|
+
if (firstValues.length === 0) return [];
|
|
439
|
+
const sql = scanRowPageSql(
|
|
440
|
+
this.#resolveTable(query.table),
|
|
441
|
+
firstValues.length,
|
|
442
|
+
'postgres',
|
|
443
|
+
);
|
|
444
|
+
const rows: StoredRow[] = [];
|
|
445
|
+
let afterRowId = query.afterRowId ?? '';
|
|
446
|
+
const batchSize = Math.max(64, query.limit);
|
|
447
|
+
while (rows.length < query.limit) {
|
|
448
|
+
const { rows: records } = await this.#client.query<RowRecord>(sql, [
|
|
449
|
+
this.#partition,
|
|
450
|
+
query.table,
|
|
451
|
+
firstVariable,
|
|
452
|
+
...firstValues,
|
|
453
|
+
afterRowId,
|
|
454
|
+
batchSize,
|
|
455
|
+
]);
|
|
456
|
+
if (records.length === 0) break;
|
|
457
|
+
for (const record of records) {
|
|
458
|
+
afterRowId = record.row_id;
|
|
459
|
+
if (record.payload === null || record.payload === undefined) continue;
|
|
460
|
+
const stored = toStoredRow(record);
|
|
461
|
+
if (!matchesEffective(stored.scopes, query.scopeFilter)) continue;
|
|
462
|
+
rows.push(stored);
|
|
463
|
+
if (rows.length >= query.limit) break;
|
|
464
|
+
}
|
|
465
|
+
if (records.length < batchSize) break;
|
|
466
|
+
}
|
|
467
|
+
return rows;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async lockPartitionForCommitValidation(): Promise<void> {
|
|
471
|
+
this.#assertOpen();
|
|
472
|
+
await this.#client.query(
|
|
473
|
+
`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
|
|
474
|
+
ON CONFLICT (partition) DO NOTHING`,
|
|
475
|
+
[this.#partition],
|
|
476
|
+
);
|
|
477
|
+
await this.#client.query(
|
|
478
|
+
'SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE',
|
|
479
|
+
[this.#partition],
|
|
480
|
+
);
|
|
481
|
+
await this.#client.query('SAVEPOINT syncular_commit_validation_candidate');
|
|
482
|
+
this.#commitValidationSavepoint = true;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async commitRejectedPushResult(
|
|
486
|
+
clientId: string,
|
|
487
|
+
clientCommitId: string,
|
|
488
|
+
result: StoredPushResult,
|
|
489
|
+
): Promise<void> {
|
|
490
|
+
this.#assertOpen();
|
|
491
|
+
if (!this.#commitValidationSavepoint) {
|
|
492
|
+
throw new Error(
|
|
493
|
+
'whole-commit rejection requires its validation savepoint',
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
await this.#client.query(
|
|
497
|
+
'ROLLBACK TO SAVEPOINT syncular_commit_validation_candidate',
|
|
498
|
+
);
|
|
499
|
+
await this.#client.query(
|
|
500
|
+
'RELEASE SAVEPOINT syncular_commit_validation_candidate',
|
|
501
|
+
);
|
|
502
|
+
this.#commitValidationSavepoint = false;
|
|
503
|
+
await this.putPushResult(clientId, clientCommitId, result);
|
|
504
|
+
await this.commit();
|
|
505
|
+
}
|
|
506
|
+
|
|
431
507
|
async upsertRow(table: string, row: StoredRow): Promise<void> {
|
|
432
508
|
this.#assertOpen();
|
|
433
509
|
await writeRowOn(
|
package/src/push.ts
CHANGED
|
@@ -42,8 +42,18 @@ import type {
|
|
|
42
42
|
StoredCommit,
|
|
43
43
|
StoredPushResult,
|
|
44
44
|
} from './storage';
|
|
45
|
-
import type {
|
|
46
|
-
|
|
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';
|
|
47
57
|
|
|
48
58
|
/**
|
|
49
59
|
* Extract the blobIds a decoded row references through its `blob_ref`
|
|
@@ -63,7 +73,11 @@ function blobIdsInRow(
|
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
type OperationOutcome =
|
|
66
|
-
| {
|
|
76
|
+
| {
|
|
77
|
+
readonly kind: 'applied';
|
|
78
|
+
readonly change: NewChange | undefined;
|
|
79
|
+
readonly operation: ValidateCommitOperation;
|
|
80
|
+
}
|
|
67
81
|
| { readonly kind: 'terminate'; readonly record: PushOperationResult };
|
|
68
82
|
|
|
69
83
|
function errorRecord(
|
|
@@ -252,7 +266,18 @@ async function applyOperation(
|
|
|
252
266
|
if (op.op === 'delete') {
|
|
253
267
|
if (stored === undefined) {
|
|
254
268
|
// Deleting an absent row is applied (idempotent, §6.2); no change.
|
|
255
|
-
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
|
+
};
|
|
256
281
|
}
|
|
257
282
|
if (!authorizeWrite(table, stored.scopes, resolved)) {
|
|
258
283
|
return errorRecord(
|
|
@@ -272,13 +297,14 @@ async function applyOperation(
|
|
|
272
297
|
// §6.7: validate the delete against the stored row (row = undefined,
|
|
273
298
|
// stored = the row about to be removed). Only reached for an existing
|
|
274
299
|
// row — an absent-row delete is an idempotent no-op above.
|
|
300
|
+
const storedValues = decodeRow(table.columns, stored.payload);
|
|
275
301
|
const deleteReject = await runValidator(
|
|
276
302
|
validators,
|
|
277
303
|
table,
|
|
278
304
|
'delete',
|
|
279
305
|
op.rowId,
|
|
280
306
|
undefined,
|
|
281
|
-
|
|
307
|
+
storedValues,
|
|
282
308
|
opIndex,
|
|
283
309
|
partition,
|
|
284
310
|
actorId,
|
|
@@ -293,6 +319,15 @@ async function applyOperation(
|
|
|
293
319
|
op: 'delete',
|
|
294
320
|
scopes: stored.scopes,
|
|
295
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
|
+
},
|
|
296
331
|
};
|
|
297
332
|
}
|
|
298
333
|
|
|
@@ -411,6 +446,16 @@ async function applyOperation(
|
|
|
411
446
|
scopes: stored.scopes,
|
|
412
447
|
payload: newPayload,
|
|
413
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
|
+
},
|
|
414
459
|
};
|
|
415
460
|
}
|
|
416
461
|
|
|
@@ -506,6 +551,15 @@ async function applyOperation(
|
|
|
506
551
|
scopes: extracted.scopes,
|
|
507
552
|
payload: insertPayload,
|
|
508
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
|
+
},
|
|
509
563
|
};
|
|
510
564
|
}
|
|
511
565
|
|
|
@@ -562,6 +616,121 @@ function missingScopeVariable(
|
|
|
562
616
|
return undefined;
|
|
563
617
|
}
|
|
564
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
|
+
|
|
565
734
|
function resultFrame(
|
|
566
735
|
clientCommitId: string,
|
|
567
736
|
stored: StoredPushResult,
|
|
@@ -580,6 +749,26 @@ function resultFrame(
|
|
|
580
749
|
};
|
|
581
750
|
}
|
|
582
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
|
+
|
|
583
772
|
export interface AppliedCommitEvent {
|
|
584
773
|
readonly commit: StoredCommit;
|
|
585
774
|
}
|
|
@@ -610,20 +799,7 @@ export async function processPushCommit(
|
|
|
610
799
|
) {
|
|
611
800
|
// §6.3: answer the retryable cache-miss for this commit rather than
|
|
612
801
|
// re-applying. Not persisted — a retry may find a readable record.
|
|
613
|
-
return
|
|
614
|
-
type: 'PUSH_RESULT',
|
|
615
|
-
clientCommitId: frame.clientCommitId,
|
|
616
|
-
status: 'rejected',
|
|
617
|
-
results: [
|
|
618
|
-
{
|
|
619
|
-
opIndex: 0,
|
|
620
|
-
status: 'error',
|
|
621
|
-
code: 'sync.idempotency_cache_miss',
|
|
622
|
-
message: error.message,
|
|
623
|
-
retryable: true,
|
|
624
|
-
},
|
|
625
|
-
],
|
|
626
|
-
};
|
|
802
|
+
return idempotencyCacheMissFrame(frame.clientCommitId, error);
|
|
627
803
|
}
|
|
628
804
|
throw error;
|
|
629
805
|
}
|
|
@@ -635,10 +811,47 @@ export async function processPushCommit(
|
|
|
635
811
|
const blobCtx: BlobApplyContext = { store: ctx.blobs, partition };
|
|
636
812
|
const crdtMergers = ctx.crdtMergers;
|
|
637
813
|
const validators = ctx.validators;
|
|
814
|
+
const commitValidator = ctx.commitValidator;
|
|
638
815
|
const tx = await storage.begin(partition);
|
|
816
|
+
const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
|
|
639
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
|
+
}
|
|
640
852
|
const results: PushOperationResult[] = [];
|
|
641
853
|
const changes: NewChange[] = [];
|
|
854
|
+
const validatedOperations: ValidateCommitOperation[] = [];
|
|
642
855
|
let terminated: PushOperationResult | undefined;
|
|
643
856
|
for (let opIndex = 0; opIndex < frame.operations.length; opIndex++) {
|
|
644
857
|
const op = frame.operations[opIndex];
|
|
@@ -660,24 +873,57 @@ export async function processPushCommit(
|
|
|
660
873
|
break;
|
|
661
874
|
}
|
|
662
875
|
results.push({ opIndex, status: 'applied' });
|
|
876
|
+
validatedOperations.push(outcome.operation);
|
|
663
877
|
if (outcome.change !== undefined) changes.push(outcome.change);
|
|
664
878
|
}
|
|
665
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
|
+
|
|
666
896
|
if (terminated !== undefined) {
|
|
667
897
|
// §6.3 rejected: only the terminating operation's record; §6.4:
|
|
668
898
|
// every write of the commit rolls back.
|
|
669
|
-
await tx.rollback();
|
|
670
899
|
const stored: StoredPushResult = {
|
|
671
900
|
status: 'rejected',
|
|
672
901
|
results: [terminated],
|
|
673
902
|
};
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
+
}
|
|
681
927
|
}
|
|
682
928
|
return resultFrame(frame.clientCommitId, stored, false);
|
|
683
929
|
}
|
package/src/realtime.ts
CHANGED
|
@@ -50,7 +50,7 @@ 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 { ValidatorRegistry } from './validate';
|
|
53
|
+
import type { CommitValidator, ValidatorRegistry } from './validate';
|
|
54
54
|
|
|
55
55
|
export interface RealtimeHubConfig {
|
|
56
56
|
readonly schema: ServerSchema;
|
|
@@ -58,6 +58,8 @@ export interface RealtimeHubConfig {
|
|
|
58
58
|
readonly resolveScopes: ResolveScopes;
|
|
59
59
|
/** §6.7 validators used by sync rounds carried over this socket. */
|
|
60
60
|
readonly validators?: ValidatorRegistry;
|
|
61
|
+
/** §6.8 whole-commit validator shared with HTTP sync rounds. */
|
|
62
|
+
readonly commitValidator?: CommitValidator;
|
|
61
63
|
readonly clock?: () => number;
|
|
62
64
|
/** Deltas larger than this become `delta-too-large` wake-ups (§8.2). */
|
|
63
65
|
readonly maxDeltaBytes?: number;
|
|
@@ -932,6 +934,9 @@ export class RealtimeHub {
|
|
|
932
934
|
...(this.#config.validators !== undefined
|
|
933
935
|
? { validators: this.#config.validators }
|
|
934
936
|
: {}),
|
|
937
|
+
...(this.#config.commitValidator !== undefined
|
|
938
|
+
? { commitValidator: this.#config.commitValidator }
|
|
939
|
+
: {}),
|
|
935
940
|
...(this.#config.clock !== undefined
|
|
936
941
|
? { clock: this.#config.clock }
|
|
937
942
|
: {}),
|
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. */
|