@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 CHANGED
@@ -105,9 +105,73 @@ UI. Unknown members, free-form tokens, malformed paths, and over-limit data
105
105
  fail at construction. Diagnostic prose stays in `message`; apps should map
106
106
  the stable code/details to localized copy instead of displaying that message.
107
107
 
108
- Validators are per-operation. They do not make multi-row or multi-table
109
- invariants atomic; those require a server-authoritative command or a future
110
- whole-commit validation seam. A validator must not mutate the row it receives.
108
+ Validators are per-operation. A validator must not mutate the row it receives.
109
+
110
+ For a multi-row or multi-table invariant, install `commitValidator`. It runs
111
+ once after every operation is staged in the same transaction, and can inspect
112
+ both the decoded sibling operations and final candidate state:
113
+
114
+ ```ts
115
+ import {
116
+ CommitValidationRejection,
117
+ type SyncServerConfig,
118
+ } from '@syncular/server';
119
+
120
+ const config: SyncServerConfig = {
121
+ schema,
122
+ storage,
123
+ segments,
124
+ resolveScopes,
125
+ commitValidator: ({ operations }) => {
126
+ const transition = operations.find(
127
+ (operation) =>
128
+ operation.table === 'surgeries' &&
129
+ operation.row !== undefined &&
130
+ operation.stored !== undefined &&
131
+ operation.row.status !== operation.stored.status,
132
+ );
133
+ if (transition === undefined) return;
134
+
135
+ const hasEvent = operations.some(
136
+ (operation) =>
137
+ operation.table === 'surgery_status_events' &&
138
+ operation.row?.surgery_id === transition.rowId &&
139
+ operation.row?.status === transition.row?.status,
140
+ );
141
+ if (!hasEvent) {
142
+ throw new CommitValidationRejection(
143
+ transition.opIndex,
144
+ 'surgery.status_event_required',
145
+ 'diagnostic only',
146
+ {
147
+ fieldPaths: ['status'],
148
+ reason: 'missing_sibling_operation',
149
+ requiredAction: 'repair_aggregate',
150
+ },
151
+ );
152
+ }
153
+ },
154
+ };
155
+ ```
156
+
157
+ The callback also receives `read.getRow()` and bounded `read.scanRows()` APIs.
158
+ Those reads see the final candidate state, including staged sibling upserts and
159
+ deletes. Syncular serializes the partition before any operation read so two
160
+ aggregate validators cannot both accept mutually invalid candidates. It also
161
+ re-checks idempotency after taking that lock and persists a rejected outcome
162
+ while the lock is retained, so overlapping duplicate deliveries cannot rerun
163
+ the callback.
164
+
165
+ SQLite and PostgreSQL provide that serialization directly. D1 does not expose
166
+ an interactive lock: `D1ServerStorage` fails closed unless it is constructed
167
+ inside an external per-partition coordinator with
168
+ `{ commitValidationSerialized: true }` (normally one Durable Object per
169
+ partition). Do not set that assertion on a stateless Worker. Custom storages
170
+ must implement both the transaction lock and candidate scan seam.
171
+
172
+ Whole-commit validation checks a client-proposed commit; it does not grant
173
+ authority. Privileged operations such as connecting facilities still belong in
174
+ explicit server-authoritative commands.
111
175
 
112
176
  ## Structured events (the ops seam)
113
177
 
package/dist/context.d.ts CHANGED
@@ -14,7 +14,7 @@ import type { SegmentStore } from './segment-store.js';
14
14
  import type { BlobPresignConfig, BlobUploadPresignConfig, SegmentUrlConfig } from './signed-url.js';
15
15
  import type { SqliteImageBuilder } from './sqlite-image.js';
16
16
  import type { ServerStorage, StoredCommit } from './storage.js';
17
- import type { ValidatorRegistry } from './validate.js';
17
+ import type { CommitValidator, ValidatorRegistry } from './validate.js';
18
18
  /** SSP2 body content type (§1.1). */
19
19
  export declare const SSP2_CONTENT_TYPE = "application/vnd.syncular.sync.v2";
20
20
  export interface ResolveScopesArgs {
@@ -94,6 +94,13 @@ export interface SyncServerConfig {
94
94
  * process, never on the wire.
95
95
  */
96
96
  readonly validators?: ValidatorRegistry;
97
+ /**
98
+ * §6.8 whole-commit validator. When present, the storage serializes this
99
+ * partition before operation reads/writes, then invokes the callback once
100
+ * over every staged decoded operation and candidate-state reader before
101
+ * commit-log/idempotency append. A throw rolls back the complete commit.
102
+ */
103
+ readonly commitValidator?: CommitValidator;
97
104
  readonly resolveScopes: ResolveScopes;
98
105
  /**
99
106
  * §7.3 auth leases. Absent ⇒ the feature is off: no `LEASE` frame is
@@ -13,9 +13,18 @@ export interface D1Database {
13
13
  batch(statements: D1PreparedStatement[]): Promise<unknown[]>;
14
14
  exec(query: string): Promise<unknown>;
15
15
  }
16
+ export interface D1ServerStorageOptions {
17
+ /**
18
+ * Assert that all writes for a partition reach this storage serially.
19
+ * Required for §6.8 because D1 exposes no interactive transaction lock.
20
+ * Set this only inside a per-partition Durable Object or equivalent
21
+ * coordinator; the default fails closed when a commit validator is used.
22
+ */
23
+ readonly commitValidationSerialized?: boolean;
24
+ }
16
25
  export declare class D1ServerStorage implements ServerStorage {
17
26
  #private;
18
- constructor(db: D1Database);
27
+ constructor(db: D1Database, options?: D1ServerStorageOptions);
19
28
  /** Apply the schema DDL (idempotent). Call once before use. */
20
29
  migrate(): Promise<void>;
21
30
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
Binary file
@@ -225,6 +225,7 @@ class PostgresTransaction {
225
225
  #partition;
226
226
  #resolveTable;
227
227
  #open = true;
228
+ #commitValidationSavepoint = false;
228
229
  /** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
229
230
  #resolve;
230
231
  #reject;
@@ -243,6 +244,65 @@ class PostgresTransaction {
243
244
  this.#assertOpen();
244
245
  return getRowOn(this.#client, this.#resolveTable(table), this.#partition, rowId);
245
246
  }
247
+ async scanRows(query) {
248
+ this.#assertOpen();
249
+ const variables = Object.keys(query.scopeFilter).sort();
250
+ const firstVariable = variables[0];
251
+ if (firstVariable === undefined)
252
+ return [];
253
+ const firstValues = query.scopeFilter[firstVariable] ?? [];
254
+ if (firstValues.length === 0)
255
+ return [];
256
+ const sql = scanRowPageSql(this.#resolveTable(query.table), firstValues.length, 'postgres');
257
+ const rows = [];
258
+ let afterRowId = query.afterRowId ?? '';
259
+ const batchSize = Math.max(64, query.limit);
260
+ while (rows.length < query.limit) {
261
+ const { rows: records } = await this.#client.query(sql, [
262
+ this.#partition,
263
+ query.table,
264
+ firstVariable,
265
+ ...firstValues,
266
+ afterRowId,
267
+ batchSize,
268
+ ]);
269
+ if (records.length === 0)
270
+ break;
271
+ for (const record of records) {
272
+ afterRowId = record.row_id;
273
+ if (record.payload === null || record.payload === undefined)
274
+ continue;
275
+ const stored = toStoredRow(record);
276
+ if (!matchesEffective(stored.scopes, query.scopeFilter))
277
+ continue;
278
+ rows.push(stored);
279
+ if (rows.length >= query.limit)
280
+ break;
281
+ }
282
+ if (records.length < batchSize)
283
+ break;
284
+ }
285
+ return rows;
286
+ }
287
+ async lockPartitionForCommitValidation() {
288
+ this.#assertOpen();
289
+ await this.#client.query(`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
290
+ ON CONFLICT (partition) DO NOTHING`, [this.#partition]);
291
+ await this.#client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [this.#partition]);
292
+ await this.#client.query('SAVEPOINT syncular_commit_validation_candidate');
293
+ this.#commitValidationSavepoint = true;
294
+ }
295
+ async commitRejectedPushResult(clientId, clientCommitId, result) {
296
+ this.#assertOpen();
297
+ if (!this.#commitValidationSavepoint) {
298
+ throw new Error('whole-commit rejection requires its validation savepoint');
299
+ }
300
+ await this.#client.query('ROLLBACK TO SAVEPOINT syncular_commit_validation_candidate');
301
+ await this.#client.query('RELEASE SAVEPOINT syncular_commit_validation_candidate');
302
+ this.#commitValidationSavepoint = false;
303
+ await this.putPushResult(clientId, clientCommitId, result);
304
+ await this.commit();
305
+ }
246
306
  async upsertRow(table, row) {
247
307
  this.#assertOpen();
248
308
  await writeRowOn(this.#client, this.#resolveTable(table), this.#partition, row);
package/dist/push.js CHANGED
@@ -21,7 +21,7 @@ import { decodeRow, encodeRow, parseBlobRef, } from '@syncular/core';
21
21
  import { clockOf } from './context.js';
22
22
  import { SyncError } from './errors.js';
23
23
  import { authorizeWrite, renderScopeValue, storedScopesForRow } from './scopes.js';
24
- import { toValidateRow, ValidationRejection } from './validate.js';
24
+ import { CommitValidationRejection, toValidateRow, ValidationRejection, } from './validate.js';
25
25
  /**
26
26
  * Extract the blobIds a decoded row references through its `blob_ref`
27
27
  * columns (§5.9.4), skipping NULLs. Malformed BlobRefs already failed at
@@ -149,7 +149,18 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
149
149
  if (op.op === 'delete') {
150
150
  if (stored === undefined) {
151
151
  // Deleting an absent row is applied (idempotent, §6.2); no change.
152
- return { kind: 'applied', change: undefined };
152
+ return {
153
+ kind: 'applied',
154
+ change: undefined,
155
+ operation: {
156
+ opIndex,
157
+ op: 'delete',
158
+ table: table.name,
159
+ rowId: op.rowId,
160
+ row: undefined,
161
+ stored: undefined,
162
+ },
163
+ };
153
164
  }
154
165
  if (!authorizeWrite(table, stored.scopes, resolved)) {
155
166
  return errorRecord(opIndex, 'sync.forbidden', 'delete denied by scope authorization (§3.4)');
@@ -161,7 +172,8 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
161
172
  // §6.7: validate the delete against the stored row (row = undefined,
162
173
  // stored = the row about to be removed). Only reached for an existing
163
174
  // row — an absent-row delete is an idempotent no-op above.
164
- const deleteReject = await runValidator(validators, table, 'delete', op.rowId, undefined, decodeRow(table.columns, stored.payload), opIndex, partition, actorId);
175
+ const storedValues = decodeRow(table.columns, stored.payload);
176
+ const deleteReject = await runValidator(validators, table, 'delete', op.rowId, undefined, storedValues, opIndex, partition, actorId);
165
177
  if (deleteReject !== undefined)
166
178
  return deleteReject;
167
179
  await tx.deleteRow(op.table, op.rowId);
@@ -173,6 +185,15 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
173
185
  op: 'delete',
174
186
  scopes: stored.scopes,
175
187
  },
188
+ operation: {
189
+ opIndex,
190
+ op: 'delete',
191
+ table: table.name,
192
+ rowId: op.rowId,
193
+ row: undefined,
194
+ stored: toValidateRow(table.columns, storedValues),
195
+ storedServerVersion: stored.serverVersion,
196
+ },
176
197
  };
177
198
  }
178
199
  // upsert — payload presence is enforced by the envelope codec (§6.1).
@@ -253,6 +274,16 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
253
274
  scopes: stored.scopes,
254
275
  payload: newPayload,
255
276
  },
277
+ operation: {
278
+ opIndex,
279
+ op: 'upsert',
280
+ table: table.name,
281
+ rowId: op.rowId,
282
+ row: toValidateRow(table.columns, values),
283
+ stored: toValidateRow(table.columns, storedValues),
284
+ storedServerVersion: stored.serverVersion,
285
+ nextServerVersion: newVersion,
286
+ },
256
287
  };
257
288
  }
258
289
  // Insert path: no stored row.
@@ -309,6 +340,15 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
309
340
  scopes: extracted.scopes,
310
341
  payload: insertPayload,
311
342
  },
343
+ operation: {
344
+ opIndex,
345
+ op: 'upsert',
346
+ table: table.name,
347
+ rowId: op.rowId,
348
+ row: toValidateRow(table.columns, values),
349
+ stored: undefined,
350
+ nextServerVersion: 1,
351
+ },
312
352
  };
313
353
  }
314
354
  /**
@@ -346,6 +386,73 @@ function missingScopeVariable(table, scopes) {
346
386
  }
347
387
  return undefined;
348
388
  }
389
+ function commitValidationReader(tx, schema) {
390
+ const tableFor = (name) => {
391
+ const table = schema.tables.get(name);
392
+ if (table === undefined) {
393
+ throw new Error(`commit validator requested unknown table ${JSON.stringify(name)}`);
394
+ }
395
+ return table;
396
+ };
397
+ return {
398
+ getRow: async (tableName, rowId) => {
399
+ const table = tableFor(tableName);
400
+ const stored = await tx.getRow(tableName, rowId);
401
+ if (stored === undefined)
402
+ return undefined;
403
+ return {
404
+ row: toValidateRow(table.columns, decodeRow(table.columns, stored.payload)),
405
+ serverVersion: stored.serverVersion,
406
+ };
407
+ },
408
+ scanRows: async ({ table: tableName, scopeFilter, afterRowId = null, limit = 100, }) => {
409
+ const table = tableFor(tableName);
410
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1_000) {
411
+ throw new Error('commit validator scan limit must be an integer from 1 to 1,000');
412
+ }
413
+ if (tx.scanRows === undefined) {
414
+ throw new Error('storage transaction does not support commit-validator scans');
415
+ }
416
+ const rows = await tx.scanRows({
417
+ table: tableName,
418
+ scopeFilter,
419
+ afterRowId,
420
+ limit,
421
+ });
422
+ return rows.map((stored) => ({
423
+ row: toValidateRow(table.columns, decodeRow(table.columns, stored.payload)),
424
+ serverVersion: stored.serverVersion,
425
+ }));
426
+ },
427
+ };
428
+ }
429
+ async function runCommitValidator(validator, tx, schema, clientId, clientCommitId, actorId, partition, operations) {
430
+ if (validator === undefined)
431
+ return undefined;
432
+ try {
433
+ await validator({
434
+ clientId,
435
+ clientCommitId,
436
+ actorId,
437
+ partition,
438
+ operations,
439
+ read: commitValidationReader(tx, schema),
440
+ });
441
+ }
442
+ catch (error) {
443
+ if (error instanceof CommitValidationRejection) {
444
+ if (error.opIndex >= operations.length) {
445
+ return errorRecord(0, 'sync.constraint_violation', `commit validator rejection names unavailable opIndex ${error.opIndex}`);
446
+ }
447
+ return errorRecord(error.opIndex, error.code, error.message, false, error.details);
448
+ }
449
+ if (error instanceof ValidationRejection) {
450
+ return errorRecord(operations[0]?.opIndex ?? 0, error.code, error.message, false, error.details);
451
+ }
452
+ return errorRecord(operations[0]?.opIndex ?? 0, 'sync.constraint_violation', `whole-commit validator threw: ${error instanceof Error ? error.message : String(error)}`);
453
+ }
454
+ return undefined;
455
+ }
349
456
  function resultFrame(clientCommitId, stored, replay) {
350
457
  const status = stored.status === 'applied' ? (replay ? 'cached' : 'applied') : 'rejected';
351
458
  return {
@@ -358,6 +465,22 @@ function resultFrame(clientCommitId, stored, replay) {
358
465
  results: [...stored.results],
359
466
  };
360
467
  }
468
+ function idempotencyCacheMissFrame(clientCommitId, error) {
469
+ return {
470
+ type: 'PUSH_RESULT',
471
+ clientCommitId,
472
+ status: 'rejected',
473
+ results: [
474
+ {
475
+ opIndex: 0,
476
+ status: 'error',
477
+ code: 'sync.idempotency_cache_miss',
478
+ message: error.message,
479
+ retryable: true,
480
+ },
481
+ ],
482
+ };
483
+ }
361
484
  /**
362
485
  * Process one `PUSH_COMMIT` frame: idempotency replay (§2.3), sequential
363
486
  * atomic apply (§6.4), realtime notification for applied commits.
@@ -373,20 +496,7 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
373
496
  error.code === 'sync.idempotency_cache_miss') {
374
497
  // §6.3: answer the retryable cache-miss for this commit rather than
375
498
  // re-applying. Not persisted — a retry may find a readable record.
376
- return {
377
- type: 'PUSH_RESULT',
378
- clientCommitId: frame.clientCommitId,
379
- status: 'rejected',
380
- results: [
381
- {
382
- opIndex: 0,
383
- status: 'error',
384
- code: 'sync.idempotency_cache_miss',
385
- message: error.message,
386
- retryable: true,
387
- },
388
- ],
389
- };
499
+ return idempotencyCacheMissFrame(frame.clientCommitId, error);
390
500
  }
391
501
  throw error;
392
502
  }
@@ -397,10 +507,38 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
397
507
  const blobCtx = { store: ctx.blobs, partition };
398
508
  const crdtMergers = ctx.crdtMergers;
399
509
  const validators = ctx.validators;
510
+ const commitValidator = ctx.commitValidator;
400
511
  const tx = await storage.begin(partition);
512
+ const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
401
513
  try {
514
+ if (commitValidator !== undefined) {
515
+ if (tx.lockPartitionForCommitValidation === undefined ||
516
+ commitRejectedPushResult === undefined) {
517
+ throw new Error('storage transaction does not support atomic whole-commit validation finalization');
518
+ }
519
+ await tx.lockPartitionForCommitValidation();
520
+ // The optimistic lookup above may have raced another request for the
521
+ // same idempotency key. Re-check after acquiring partition serialization
522
+ // so a concurrent duplicate never reruns the aggregate validator.
523
+ try {
524
+ const serializedPersisted = await storage.getPushResult(partition, clientId, frame.clientCommitId);
525
+ if (serializedPersisted !== undefined) {
526
+ await tx.rollback();
527
+ return resultFrame(frame.clientCommitId, serializedPersisted, true);
528
+ }
529
+ }
530
+ catch (error) {
531
+ if (error instanceof SyncError &&
532
+ error.code === 'sync.idempotency_cache_miss') {
533
+ await tx.rollback();
534
+ return idempotencyCacheMissFrame(frame.clientCommitId, error);
535
+ }
536
+ throw error;
537
+ }
538
+ }
402
539
  const results = [];
403
540
  const changes = [];
541
+ const validatedOperations = [];
404
542
  let terminated;
405
543
  for (let opIndex = 0; opIndex < frame.operations.length; opIndex++) {
406
544
  const op = frame.operations[opIndex];
@@ -412,25 +550,43 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
412
550
  break;
413
551
  }
414
552
  results.push({ opIndex, status: 'applied' });
553
+ validatedOperations.push(outcome.operation);
415
554
  if (outcome.change !== undefined)
416
555
  changes.push(outcome.change);
417
556
  }
557
+ if (terminated === undefined) {
558
+ const commitReject = await runCommitValidator(commitValidator, tx, schema, clientId, frame.clientCommitId, ctx.actorId, partition, validatedOperations);
559
+ if (commitReject?.kind === 'terminate') {
560
+ terminated = commitReject.record;
561
+ }
562
+ }
418
563
  if (terminated !== undefined) {
419
564
  // §6.3 rejected: only the terminating operation's record; §6.4:
420
565
  // every write of the commit rolls back.
421
- await tx.rollback();
422
566
  const stored = {
423
567
  status: 'rejected',
424
568
  results: [terminated],
425
569
  };
426
- const rejectionTx = await storage.begin(partition);
427
- try {
428
- await rejectionTx.putPushResult(clientId, frame.clientCommitId, stored);
429
- await rejectionTx.commit();
570
+ if (commitValidator !== undefined) {
571
+ // Discard candidate rows and persist the rejection while retaining the
572
+ // same partition lock. This closes the duplicate-request race between
573
+ // rollback and the durable idempotency outcome.
574
+ if (commitRejectedPushResult === undefined) {
575
+ throw new Error('storage transaction lost whole-commit rejection finalization support');
576
+ }
577
+ await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
430
578
  }
431
- catch (error) {
432
- await rejectionTx.rollback();
433
- throw error;
579
+ else {
580
+ await tx.rollback();
581
+ const rejectionTx = await storage.begin(partition);
582
+ try {
583
+ await rejectionTx.putPushResult(clientId, frame.clientCommitId, stored);
584
+ await rejectionTx.commit();
585
+ }
586
+ catch (error) {
587
+ await rejectionTx.rollback();
588
+ throw error;
589
+ }
434
590
  }
435
591
  return resultFrame(frame.clientCommitId, stored, false);
436
592
  }
@@ -20,13 +20,15 @@ import type { ServerSchema } from './schema.js';
20
20
  import type { SegmentStore } from './segment-store.js';
21
21
  import type { SegmentUrlConfig } from './signed-url.js';
22
22
  import type { ServerStorage, StoredCommit } from './storage.js';
23
- import type { ValidatorRegistry } from './validate.js';
23
+ import type { CommitValidator, ValidatorRegistry } from './validate.js';
24
24
  export interface RealtimeHubConfig {
25
25
  readonly schema: ServerSchema;
26
26
  readonly storage: ServerStorage;
27
27
  readonly resolveScopes: ResolveScopes;
28
28
  /** §6.7 validators used by sync rounds carried over this socket. */
29
29
  readonly validators?: ValidatorRegistry;
30
+ /** §6.8 whole-commit validator shared with HTTP sync rounds. */
31
+ readonly commitValidator?: CommitValidator;
30
32
  readonly clock?: () => number;
31
33
  /** Deltas larger than this become `delta-too-large` wake-ups (§8.2). */
32
34
  readonly maxDeltaBytes?: number;
package/dist/realtime.js CHANGED
@@ -719,6 +719,9 @@ export class RealtimeHub {
719
719
  ...(this.#config.validators !== undefined
720
720
  ? { validators: this.#config.validators }
721
721
  : {}),
722
+ ...(this.#config.commitValidator !== undefined
723
+ ? { commitValidator: this.#config.commitValidator }
724
+ : {}),
722
725
  ...(this.#config.clock !== undefined
723
726
  ? { clock: this.#config.clock }
724
727
  : {}),
@@ -15,6 +15,7 @@ class SqliteTransaction {
15
15
  #storage;
16
16
  #partition;
17
17
  #open = true;
18
+ #commitValidationSavepoint = false;
18
19
  constructor(storage, partition) {
19
20
  this.#storage = storage;
20
21
  this.#partition = partition;
@@ -28,6 +29,27 @@ class SqliteTransaction {
28
29
  this.#assertOpen();
29
30
  return this.#storage.getRow(this.#partition, table, rowId);
30
31
  }
32
+ scanRows(query) {
33
+ this.#assertOpen();
34
+ return this.#storage.scanRows(this.#partition, query);
35
+ }
36
+ async lockPartitionForCommitValidation() {
37
+ this.#assertOpen();
38
+ // BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
39
+ this.#storage.db.exec('SAVEPOINT syncular_commit_validation_candidate');
40
+ this.#commitValidationSavepoint = true;
41
+ }
42
+ async commitRejectedPushResult(clientId, clientCommitId, result) {
43
+ this.#assertOpen();
44
+ if (!this.#commitValidationSavepoint) {
45
+ throw new Error('whole-commit rejection requires its validation savepoint');
46
+ }
47
+ this.#storage.db.exec('ROLLBACK TO SAVEPOINT syncular_commit_validation_candidate');
48
+ this.#storage.db.exec('RELEASE SAVEPOINT syncular_commit_validation_candidate');
49
+ this.#commitValidationSavepoint = false;
50
+ await this.putPushResult(clientId, clientCommitId, result);
51
+ await this.commit();
52
+ }
31
53
  async upsertRow(table, row) {
32
54
  this.#assertOpen();
33
55
  this.#storage.writeRow(this.#partition, table, row);
package/dist/storage.d.ts CHANGED
@@ -150,6 +150,24 @@ export interface ScopeActivityQuery {
150
150
  */
151
151
  export interface StorageTransaction {
152
152
  getRow(table: string, rowId: string): Promise<StoredRow | undefined>;
153
+ /**
154
+ * Optional candidate-state scan used only by whole-commit validation.
155
+ * In-tree SQLite/Postgres/D1 backends implement it with read-your-own-writes
156
+ * semantics. A custom backend may omit it until `commitValidator` is used.
157
+ */
158
+ scanRows?(query: RowScanQuery): Promise<StoredRow[]>;
159
+ /**
160
+ * Serialize candidate-state validation for this partition before any row
161
+ * read/write. Required at runtime when `commitValidator` is configured.
162
+ */
163
+ lockPartitionForCommitValidation?(): Promise<void>;
164
+ /**
165
+ * §6.8 rejection finalization while the validation serialization lock is
166
+ * still held: discard every candidate write, persist the rejected
167
+ * idempotency result, and finish the transaction atomically. Required when
168
+ * `commitValidator` is configured so a concurrent duplicate cannot rerun it.
169
+ */
170
+ commitRejectedPushResult?(clientId: string, clientCommitId: string, result: StoredPushResult): Promise<void>;
153
171
  upsertRow(table: string, row: StoredRow): Promise<void>;
154
172
  deleteRow(table: string, rowId: string): Promise<void>;
155
173
  /** Allocates the next per-partition commitSeq and appends the commit. */
@@ -13,7 +13,7 @@
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 RejectionDetails, type RowColumn, type RowValue } from '@syncular/core';
16
+ import { type RejectionDetails, type RowColumn, type RowValue, type ScopeMap } from '@syncular/core';
17
17
  /**
18
18
  * §6.7 reserved code prefixes. A host validator code MUST NOT start with
19
19
  * any of these: they namespace the protocol's own error families (§10.2)
@@ -49,6 +49,52 @@ export interface ValidateOperation {
49
49
  /** The currently-stored row (update/delete), keyed by column name; undefined on insert. */
50
50
  readonly stored: ValidateRow | undefined;
51
51
  }
52
+ /**
53
+ * One authorized, decoded operation presented to the whole-commit validator.
54
+ * `row` is the final candidate row after scope stripping and CRDT merge;
55
+ * `stored` is the state observed immediately before this operation. Multiple
56
+ * operations targeting one row therefore retain their sequential evidence.
57
+ */
58
+ export interface ValidateCommitOperation extends ValidateOperation {
59
+ readonly opIndex: number;
60
+ readonly storedServerVersion?: number;
61
+ readonly nextServerVersion?: number;
62
+ }
63
+ /** One candidate-state row read from inside the still-open commit transaction. */
64
+ export interface CommitValidationRow {
65
+ readonly row: ValidateRow;
66
+ readonly serverVersion: number;
67
+ }
68
+ export interface CommitValidationScanInput {
69
+ readonly table: string;
70
+ /** Exact scope filter, using the same AND-across-keys semantics as sync. */
71
+ readonly scopeFilter: ScopeMap;
72
+ readonly afterRowId?: string | null;
73
+ /** Bounded per call by the server to 1..1,000; defaults to 100. */
74
+ readonly limit?: number;
75
+ }
76
+ /**
77
+ * Candidate-state reads bound to the same storage transaction as the commit.
78
+ * Reads observe every staged sibling operation and no uncommitted competing
79
+ * transaction when the storage's commit-validation lock contract is honored.
80
+ */
81
+ export interface CommitValidationReader {
82
+ getRow(table: string, rowId: string): Promise<CommitValidationRow | undefined>;
83
+ scanRows(input: CommitValidationScanInput): Promise<CommitValidationRow[]>;
84
+ }
85
+ export interface ValidateCommitInput {
86
+ readonly clientId: string;
87
+ readonly clientCommitId: string;
88
+ readonly actorId: string;
89
+ readonly partition: string;
90
+ readonly operations: readonly ValidateCommitOperation[];
91
+ readonly read: CommitValidationReader;
92
+ }
93
+ /**
94
+ * Runs once after every operation passed protocol/scope/row validation and was
95
+ * staged, but before commit-log/idempotency append and transaction commit.
96
+ */
97
+ export type CommitValidator = (input: ValidateCommitInput) => void | Promise<void>;
52
98
  /** Ambient context a validator may consult (§6.7). */
53
99
  export interface ValidateContext {
54
100
  /** Host-authenticated actor (§1.1) performing the write. */
@@ -84,5 +130,14 @@ export declare class ValidationRejection extends Error {
84
130
  readonly details: RejectionDetails | undefined;
85
131
  constructor(code: string, message?: string, details?: RejectionDetails);
86
132
  }
133
+ /**
134
+ * A whole-commit rejection attributed to one operation for the existing
135
+ * per-operation PUSH_RESULT envelope. The validator may still describe
136
+ * multiple affected fields in `details.fieldPaths`.
137
+ */
138
+ export declare class CommitValidationRejection extends ValidationRejection {
139
+ readonly opIndex: number;
140
+ constructor(opIndex: number, code: string, message?: string, details?: RejectionDetails);
141
+ }
87
142
  /** Build the column-keyed row object a validator inspects (§6.7). */
88
143
  export declare function toValidateRow(columns: readonly RowColumn[], values: readonly RowValue[]): ValidateRow;