@syncular/server 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -60,6 +60,119 @@ done by a binding of the core or by in-database fanout — a relay would add a
60
60
  hop, a second protocol surface, and a managed dependency for zero capability
61
61
  the core lacks.
62
62
 
63
+ ## Write validators and recovery metadata
64
+
65
+ `validators` is the server-authoritative seam for row business rules that
66
+ scope grants cannot express. A validator runs after row decode and scope
67
+ authorization, inside the commit transaction, for HTTP and WebSocket sync
68
+ rounds alike. Throw `ValidationRejection` for a deliberate host rejection:
69
+
70
+ ```ts
71
+ import {
72
+ ValidationRejection,
73
+ type SyncServerConfig,
74
+ } from '@syncular/server';
75
+
76
+ const config: SyncServerConfig = {
77
+ schema,
78
+ storage,
79
+ segments,
80
+ resolveScopes,
81
+ validators: {
82
+ surgeries: ({ row }) => {
83
+ if (typeof row?.duration_minutes === 'number' && row.duration_minutes < 5) {
84
+ throw new ValidationRejection(
85
+ 'surgery.duration_too_short',
86
+ 'diagnostic only',
87
+ {
88
+ fieldPaths: ['duration_minutes'],
89
+ reason: 'below_minimum',
90
+ requiredAction: 'edit_fields',
91
+ references: { minimum_minutes: '5' },
92
+ },
93
+ );
94
+ }
95
+ },
96
+ },
97
+ };
98
+ ```
99
+
100
+ The third argument is optional. When supplied, Syncular validates and
101
+ normalizes a bounded `RejectionDetails` object and persists it with the
102
+ idempotency result. Its values replicate to the authorized client, so include
103
+ only non-sensitive identifiers that the host explicitly approves for recovery
104
+ UI. Unknown members, free-form tokens, malformed paths, and over-limit data
105
+ fail at construction. Diagnostic prose stays in `message`; apps should map
106
+ the stable code/details to localized copy instead of displaying that message.
107
+
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.
175
+
63
176
  ## Structured events (the ops seam)
64
177
 
65
178
  One optional interface, `SyncularServerEvents`, carries every
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
@@ -39,6 +39,21 @@ function wrapperFor(frame) {
39
39
  case 'ERROR':
40
40
  case 'UNKNOWN':
41
41
  return { frames: [STUB_HEADER, frame], index: 1 };
42
+ case 'PUSH_RESULT_DETAILS': {
43
+ const result = {
44
+ type: 'PUSH_RESULT',
45
+ clientCommitId: frame.clientCommitId,
46
+ status: 'rejected',
47
+ results: frame.entries.map((entry) => ({
48
+ opIndex: entry.opIndex,
49
+ status: 'error',
50
+ code: 'sync.constraint_violation',
51
+ message: '',
52
+ retryable: false,
53
+ })),
54
+ };
55
+ return { frames: [STUB_HEADER, result, frame], index: 2 };
56
+ }
42
57
  case 'SUB_START':
43
58
  return { frames: [STUB_HEADER, frame, STUB_SUB_END], index: 1 };
44
59
  case 'SUB_END':
package/dist/handler.js CHANGED
@@ -187,6 +187,18 @@ async function planRequest(request, ctx, schema) {
187
187
  leaseToEmit,
188
188
  };
189
189
  }
190
+ function pushResultDetailsFrame(frame) {
191
+ const entries = frame.results.flatMap((result) => result.status === 'error' && result.details !== undefined
192
+ ? [{ opIndex: result.opIndex, details: result.details }]
193
+ : []);
194
+ return entries.length === 0
195
+ ? undefined
196
+ : {
197
+ type: 'PUSH_RESULT_DETAILS',
198
+ clientCommitId: frame.clientCommitId,
199
+ entries,
200
+ };
201
+ }
190
202
  function emitPushEvent(events, ctx, clientId, push, frame) {
191
203
  const base = {
192
204
  atMs: clockOf(ctx)(),
@@ -269,6 +281,9 @@ async function* streamResponse(plan, ctx, schema, report) {
269
281
  emitPushEvent(events, ctx, plan.header.clientId, push, frame);
270
282
  }
271
283
  yield encodeResponseFrame(frame);
284
+ const details = pushResultDetailsFrame(frame);
285
+ if (details !== undefined)
286
+ yield encodeResponseFrame(details);
272
287
  }
273
288
  // Pull half (§4): subscriptions echoed in request order.
274
289
  const cursors = [];
@@ -107,6 +107,7 @@ function serializePushResult(result) {
107
107
  code: record.code,
108
108
  message: record.message,
109
109
  retryable: record.retryable,
110
+ ...(record.details !== undefined ? { details: record.details } : {}),
110
111
  };
111
112
  }
112
113
  return { opIndex: record.opIndex, status: record.status };
@@ -133,6 +134,7 @@ function deserializePushResult(value) {
133
134
  code: record.code ?? '',
134
135
  message: record.message ?? '',
135
136
  retryable: record.retryable ?? false,
137
+ ...(record.details !== undefined ? { details: record.details } : {}),
136
138
  };
137
139
  }
138
140
  return { opIndex: record.opIndex, status: 'applied' };
@@ -223,6 +225,7 @@ class PostgresTransaction {
223
225
  #partition;
224
226
  #resolveTable;
225
227
  #open = true;
228
+ #commitValidationSavepoint = false;
226
229
  /** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
227
230
  #resolve;
228
231
  #reject;
@@ -241,6 +244,65 @@ class PostgresTransaction {
241
244
  this.#assertOpen();
242
245
  return getRowOn(this.#client, this.#resolveTable(table), this.#partition, rowId);
243
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
+ }
244
306
  async upsertRow(table, row) {
245
307
  this.#assertOpen();
246
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
@@ -36,10 +36,17 @@ function blobIdsInRow(table, values) {
36
36
  }
37
37
  return ids;
38
38
  }
39
- function errorRecord(opIndex, code, message, retryable = false) {
39
+ function errorRecord(opIndex, code, message, retryable = false, details) {
40
40
  return {
41
41
  kind: 'terminate',
42
- record: { opIndex, status: 'error', code, message, retryable },
42
+ record: {
43
+ opIndex,
44
+ status: 'error',
45
+ code,
46
+ message,
47
+ retryable,
48
+ ...(details !== undefined ? { details } : {}),
49
+ },
43
50
  };
44
51
  }
45
52
  function conflictRecord(opIndex, serverVersion, serverRow) {
@@ -83,7 +90,7 @@ async function runValidator(validators, table, op, rowId, values, storedValues,
83
90
  }
84
91
  catch (error) {
85
92
  if (error instanceof ValidationRejection) {
86
- return errorRecord(opIndex, error.code, error.message);
93
+ return errorRecord(opIndex, error.code, error.message, false, error.details);
87
94
  }
88
95
  // §6.7: a non-ValidationRejection throw is still a rejection, mapped to
89
96
  // the generic server-side constraint code (§10.2) — the validator's
@@ -142,7 +149,18 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
142
149
  if (op.op === 'delete') {
143
150
  if (stored === undefined) {
144
151
  // Deleting an absent row is applied (idempotent, §6.2); no change.
145
- 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
+ };
146
164
  }
147
165
  if (!authorizeWrite(table, stored.scopes, resolved)) {
148
166
  return errorRecord(opIndex, 'sync.forbidden', 'delete denied by scope authorization (§3.4)');
@@ -154,7 +172,8 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
154
172
  // §6.7: validate the delete against the stored row (row = undefined,
155
173
  // stored = the row about to be removed). Only reached for an existing
156
174
  // row — an absent-row delete is an idempotent no-op above.
157
- 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);
158
177
  if (deleteReject !== undefined)
159
178
  return deleteReject;
160
179
  await tx.deleteRow(op.table, op.rowId);
@@ -166,6 +185,15 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
166
185
  op: 'delete',
167
186
  scopes: stored.scopes,
168
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
+ },
169
197
  };
170
198
  }
171
199
  // upsert — payload presence is enforced by the envelope codec (§6.1).
@@ -246,6 +274,16 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
246
274
  scopes: stored.scopes,
247
275
  payload: newPayload,
248
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
+ },
249
287
  };
250
288
  }
251
289
  // Insert path: no stored row.
@@ -302,6 +340,15 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
302
340
  scopes: extracted.scopes,
303
341
  payload: insertPayload,
304
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
+ },
305
352
  };
306
353
  }
307
354
  /**
@@ -339,6 +386,73 @@ function missingScopeVariable(table, scopes) {
339
386
  }
340
387
  return undefined;
341
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
+ }
342
456
  function resultFrame(clientCommitId, stored, replay) {
343
457
  const status = stored.status === 'applied' ? (replay ? 'cached' : 'applied') : 'rejected';
344
458
  return {
@@ -351,6 +465,22 @@ function resultFrame(clientCommitId, stored, replay) {
351
465
  results: [...stored.results],
352
466
  };
353
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
+ }
354
484
  /**
355
485
  * Process one `PUSH_COMMIT` frame: idempotency replay (§2.3), sequential
356
486
  * atomic apply (§6.4), realtime notification for applied commits.
@@ -366,20 +496,7 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
366
496
  error.code === 'sync.idempotency_cache_miss') {
367
497
  // §6.3: answer the retryable cache-miss for this commit rather than
368
498
  // re-applying. Not persisted — a retry may find a readable record.
369
- return {
370
- type: 'PUSH_RESULT',
371
- clientCommitId: frame.clientCommitId,
372
- status: 'rejected',
373
- results: [
374
- {
375
- opIndex: 0,
376
- status: 'error',
377
- code: 'sync.idempotency_cache_miss',
378
- message: error.message,
379
- retryable: true,
380
- },
381
- ],
382
- };
499
+ return idempotencyCacheMissFrame(frame.clientCommitId, error);
383
500
  }
384
501
  throw error;
385
502
  }
@@ -390,10 +507,38 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
390
507
  const blobCtx = { store: ctx.blobs, partition };
391
508
  const crdtMergers = ctx.crdtMergers;
392
509
  const validators = ctx.validators;
510
+ const commitValidator = ctx.commitValidator;
393
511
  const tx = await storage.begin(partition);
512
+ const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
394
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
+ }
395
539
  const results = [];
396
540
  const changes = [];
541
+ const validatedOperations = [];
397
542
  let terminated;
398
543
  for (let opIndex = 0; opIndex < frame.operations.length; opIndex++) {
399
544
  const op = frame.operations[opIndex];
@@ -405,25 +550,43 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
405
550
  break;
406
551
  }
407
552
  results.push({ opIndex, status: 'applied' });
553
+ validatedOperations.push(outcome.operation);
408
554
  if (outcome.change !== undefined)
409
555
  changes.push(outcome.change);
410
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
+ }
411
563
  if (terminated !== undefined) {
412
564
  // §6.3 rejected: only the terminating operation's record; §6.4:
413
565
  // every write of the commit rolls back.
414
- await tx.rollback();
415
566
  const stored = {
416
567
  status: 'rejected',
417
568
  results: [terminated],
418
569
  };
419
- const rejectionTx = await storage.begin(partition);
420
- try {
421
- await rejectionTx.putPushResult(clientId, frame.clientCommitId, stored);
422
- 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);
423
578
  }
424
- catch (error) {
425
- await rejectionTx.rollback();
426
- 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
+ }
427
590
  }
428
591
  return resultFrame(frame.clientCommitId, stored, false);
429
592
  }
@@ -20,10 +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 { CommitValidator, ValidatorRegistry } from './validate.js';
23
24
  export interface RealtimeHubConfig {
24
25
  readonly schema: ServerSchema;
25
26
  readonly storage: ServerStorage;
26
27
  readonly resolveScopes: ResolveScopes;
28
+ /** §6.7 validators used by sync rounds carried over this socket. */
29
+ readonly validators?: ValidatorRegistry;
30
+ /** §6.8 whole-commit validator shared with HTTP sync rounds. */
31
+ readonly commitValidator?: CommitValidator;
27
32
  readonly clock?: () => number;
28
33
  /** Deltas larger than this become `delta-too-large` wake-ups (§8.2). */
29
34
  readonly maxDeltaBytes?: number;
package/dist/realtime.js CHANGED
@@ -716,6 +716,12 @@ export class RealtimeHub {
716
716
  storage: this.#config.storage,
717
717
  segments,
718
718
  resolveScopes: this.#config.resolveScopes,
719
+ ...(this.#config.validators !== undefined
720
+ ? { validators: this.#config.validators }
721
+ : {}),
722
+ ...(this.#config.commitValidator !== undefined
723
+ ? { commitValidator: this.#config.commitValidator }
724
+ : {}),
719
725
  ...(this.#config.clock !== undefined
720
726
  ? { clock: this.#config.clock }
721
727
  : {}),
@@ -111,6 +111,7 @@ export function serializePushResult(result) {
111
111
  code: record.code,
112
112
  message: record.message,
113
113
  retryable: record.retryable,
114
+ ...(record.details !== undefined ? { details: record.details } : {}),
114
115
  };
115
116
  }
116
117
  return { opIndex: record.opIndex, status: record.status };
@@ -137,6 +138,7 @@ export function deserializePushResult(text) {
137
138
  code: record.code ?? '',
138
139
  message: record.message ?? '',
139
140
  retryable: record.retryable ?? false,
141
+ ...(record.details !== undefined ? { details: record.details } : {}),
140
142
  };
141
143
  }
142
144
  return { opIndex: record.opIndex, status: 'applied' };