@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/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 { ValidateOpKind, ValidatorRegistry } from './validate';
45
- 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';
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
- | { readonly kind: 'applied'; readonly change: NewChange | undefined }
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: { opIndex, status: 'error', code, message, retryable },
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(opIndex, error.code, error.message);
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 { 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
+ };
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
- decodeRow(table.columns, stored.payload),
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
- const rejectionTx = await storage.begin(partition);
660
- try {
661
- await rejectionTx.putPushResult(clientId, frame.clientCommitId, stored);
662
- await rejectionTx.commit();
663
- } catch (error) {
664
- await rejectionTx.rollback();
665
- 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
+ }
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
  : {}),
@@ -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' };
@@ -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 type { RowColumn, RowValue } from '@syncular/core';
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