@syncular/server 0.9.0 → 0.11.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/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.9.0",
3
+ "version": "0.11.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.9.0"
56
+ "@syncular/core": "0.11.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
@@ -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 { ValidateOpKind, ValidatorRegistry } from './validate';
46
- import { toValidateRow, ValidationRejection } from './validate';
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
- | { readonly kind: 'applied'; readonly change: NewChange | undefined }
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 { kind: 'applied', change: undefined };
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
- decodeRow(table.columns, stored.payload),
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
- const rejectionTx = await storage.begin(partition);
675
- try {
676
- await rejectionTx.putPushResult(clientId, frame.clientCommitId, stored);
677
- await rejectionTx.commit();
678
- } catch (error) {
679
- await rejectionTx.rollback();
680
- throw error;
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
  : {}),
@@ -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. */