@syncular/server 0.15.22 → 0.15.24

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
@@ -19,7 +19,7 @@ The supported set, and what deliberately does **not** get an adapter:
19
19
  | Runtime | Adapter | Transport | Storage | Status |
20
20
  | --- | --- | --- | --- | --- |
21
21
  | **Bun / Node** | `@syncular/server-hono` | HTTP (`POST /sync`, segments, blobs) **+ WS realtime** (§8, host-driven upgrade) | any: `SqliteServerStorage`, `PostgresServerStorage`, memory | **Supported now** — the reference deployment; runs the full conformance catalog on both bindings. |
22
- | **Cloudflare Workers** | `@syncular/server-workers` | HTTP binding via Hono (Workers-native) **+ WS realtime** (§8, Durable Object host with hibernation) | `D1ServerStorage` (D1); R2-as-S3 for segments/blobs (§5.4 delegated presign) | **Supported now** — this rung. Realtime rides a **Durable Object** (`SyncularRealtimeDO`), opt-in; HTTP-only is also fully conformant (below). |
22
+ | **Cloudflare Workers** | `@syncular/server-workers` | HTTP binding via Hono (Workers-native) **+ optional WS realtime** (§8) | `D1ServerStorage` behind one per-partition Durable Object queue; R2-as-S3 for segments/blobs | **Supported now** — D1 sync writes always traverse the DO; WebSocket upgrades remain optional. |
23
23
  | Raw Deno / edge-misc | — | — | — | **Not adapted** (policy below). |
24
24
 
25
25
  **The policy for "not adapted".** Untested ≠ unsupported forever. The core
@@ -30,23 +30,20 @@ HTTP via the fetch-handler round-trip tests), and we do not claim runtimes we
30
30
  do not test. Deno is a plausible future adapter the day someone runs the
31
31
  catalog on it; until then it is neutral-core-friendly, not supported.
32
32
 
33
- **Workers realtime — the Durable Object.** SPEC §1.1's two bindings are two
34
- framings of one handler; an **HTTP-only deployment is fully conformant**
35
- (clients that cannot open the socket sync over `POST /sync`, identical
36
- semantics a smaller complete deployment, not a degraded one), so realtime on
37
- Workers is opt-in. When enabled it rides a **Durable Object**
38
- (`SyncularRealtimeDO`): one DO per partition hosting the `RealtimeHub` (the DO
39
- id derived from the partition, so a partition's sockets and its commit fan-out
40
- are co-located and single-threaded — also the natural per-partition write
41
- serialization point the D1 storage wants); WebSocket **hibernation** so idle
42
- sockets don't bill wall time (the existing `RealtimeSession` is the
33
+ **Workers coordination and realtime — the Durable Object.** HTTP-only is fully
34
+ conformant at the protocol level, but D1 still requires a per-partition Durable
35
+ Object queue for every `/sync` round that may push. WebSocket upgrades are
36
+ optional; the write coordinator is not. `SyncularRealtimeDO` hosts that FIFO
37
+ and, when realtime is enabled, the `RealtimeHub`. The DO id derives from the
38
+ partition, so unrelated partitions remain concurrent. WebSocket
39
+ **hibernation** keeps idle sockets from billing wall time (the existing
40
+ `RealtimeSession` is the
43
41
  per-connection state machine, driven from the hibernation callbacks and
44
42
  rehydrated from a minimal socket attachment + the D1 client record on wake);
45
43
  storage via the same **D1** binding so realtime rounds and `POST /sync` rounds
46
44
  share one commit log and one segment store; commit fan-out (§8.2) runs in-DO
47
- (no LISTEN/NOTIFY needed — writes and sockets are co-located), and an HTTP push
48
- landing in a plain isolate wakes the partition's DO (the in-platform
49
- LISTEN/NOTIFY analogue). Full shape, wiring, hibernation semantics, and the
45
+ (no LISTEN/NOTIFY needed — writes and sockets are co-located). Full shape,
46
+ wiring, hibernation semantics, and the
50
47
  manual real-workerd smoke recipe in `@syncular/server-workers/README.md`.
51
48
 
52
49
  **No relay (decision).** There is deliberately no *relay* — no bridge that
@@ -162,17 +159,33 @@ re-checks idempotency after taking that lock and persists a rejected outcome
162
159
  while the lock is retained, so overlapping duplicate deliveries cannot rerun
163
160
  the callback.
164
161
 
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.
162
+ SQLite and PostgreSQL provide that serialization directly for every push. D1
163
+ does not expose an interactive lock: `D1ServerStorage` fails closed unless it
164
+ is constructed inside an explicit per-partition coordinator with
165
+ `{ pushApplySerialized: true }` (normally the packaged Durable Object FIFO).
166
+ Do not set that assertion on a stateless Worker. Custom storages must implement
167
+ the pre-operation partition lock, locked idempotency re-check, atomic rejection
168
+ finalization, and—when `commitValidator` is used—candidate scans.
171
169
 
172
170
  Whole-commit validation checks a client-proposed commit; it does not grant
173
171
  authority. Privileged operations such as connecting facilities still belong in
174
172
  explicit server-authoritative commands.
175
173
 
174
+ ## Seed idempotency and safe revisioning
175
+
176
+ `seedMutations` uses the real push path and a stable `clientId`/`commitId`.
177
+ Both applied and rejected outcomes are terminal for that key. A rejected call
178
+ throws `SeedMutationError`, whose structured `code`, `opIndex`, `replayed`,
179
+ `recordedAtMs`, and `cacheIdentity` fields distinguish a fresh policy failure
180
+ from replay of an older cached rejection.
181
+
182
+ After correcting a development seed definition, advance an explicit seed
183
+ revision (`catalog-v1` to `catalog-v2`) and rerun it; do not delete the database
184
+ or unrelated rows. This does not apply to application commands. After an
185
+ unknown command outcome, reuse the original idempotency key because changing it
186
+ can execute the operation twice. The full inspection and recovery recipe is in
187
+ the public [server guide](https://syncular.dev/guide-server/#seeding-data).
188
+
176
189
  The task-oriented [concurrency and conflict-correction guide](https://syncular.dev/guide-concurrency-correction/)
177
190
  shows version projection, aggregate rollback, corrected replacement commits,
178
191
  explicit acknowledgement, and restart-safe recovery UI together.
@@ -16,9 +16,15 @@ export interface D1Database {
16
16
  export interface D1ServerStorageOptions {
17
17
  /**
18
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.
19
+ * Required for every push because D1 exposes no interactive transaction
20
+ * lock. Set this only inside an explicit per-partition request queue,
21
+ * Durable Object, or equivalent coordinator; the default fails closed.
22
+ */
23
+ readonly pushApplySerialized?: boolean;
24
+ /**
25
+ * @deprecated Use `pushApplySerialized`. This alias remains valid only
26
+ * because the old assertion already promised that every partition write,
27
+ * not merely validator callbacks, was externally serialized.
22
28
  */
23
29
  readonly commitValidationSerialized?: boolean;
24
30
  }
@@ -32,11 +32,11 @@ var _a;
32
32
  * reading `max_commit_seq` live and buffering the `+1` write. Under a single
33
33
  * Worker request this is exact. Two concurrent pushes to one partition need an
34
34
  * external serialization point (normally a per-partition Durable Object); a
35
- * realtime notifier alone does not serialize HTTP writes. A stateless
36
- * HTTP-only deployment SHOULD front same-partition writes with a coordinating
37
- * primitive (a DO or a Queue). For §6.8 whole-commit validation this becomes
38
- * mandatory and fail-closed: the coordinator must explicitly set
39
- * `commitValidationSerialized`, because D1 cannot provide the required lock.
35
+ * realtime notifier alone does not serialize HTTP writes. Every deployment
36
+ * that accepts D1 pushes MUST front same-partition sync rounds with a
37
+ * coordinating primitive (a DO or a Queue). The adapter fails closed unless
38
+ * that coordinator explicitly sets `pushApplySerialized`, because D1 cannot
39
+ * provide the required pre-operation lock.
40
40
  * This mirrors PostgreSQL's per-partition row lock, achieved by placement
41
41
  * rather than a lock D1 does not expose.
42
42
  */
@@ -58,12 +58,12 @@ class D1Transaction {
58
58
  #db;
59
59
  #partition;
60
60
  #resolveTable;
61
- #commitValidationSerialized;
61
+ #pushApplySerialized;
62
62
  #buffer = [];
63
63
  #open = true;
64
64
  /** Live snapshot of `max_commit_seq`, advanced within this transaction. */
65
65
  #maxCommitSeq;
66
- #commitValidationCheckpoint;
66
+ #pushApplyCheckpoint;
67
67
  #lastApplicationOpIndex;
68
68
  /**
69
69
  * Read-your-own-writes overlay (§6.2 needs `getRow` to see buffered writes
@@ -71,11 +71,11 @@ class D1Transaction {
71
71
  * `tbl\u0000rowId` → the pending state, consulted by `getRow` before D1.
72
72
  */
73
73
  #pending = new Map();
74
- constructor(db, partition, resolveTable, commitValidationSerialized) {
74
+ constructor(db, partition, resolveTable, pushApplySerialized) {
75
75
  this.#db = db;
76
76
  this.#partition = partition;
77
77
  this.#resolveTable = resolveTable;
78
- this.#commitValidationSerialized = commitValidationSerialized;
78
+ this.#pushApplySerialized = pushApplySerialized;
79
79
  }
80
80
  #assertOpen() {
81
81
  if (!this.#open)
@@ -188,20 +188,20 @@ class D1Transaction {
188
188
  .sort((left, right) => left.rowId.localeCompare(right.rowId))
189
189
  .slice(0, query.limit);
190
190
  }
191
- async lockPartitionForCommitValidation() {
191
+ async lockPartitionForPush() {
192
192
  this.#assertOpen();
193
- if (!this.#commitValidationSerialized) {
194
- throw new Error('D1 whole-commit validation requires externally serialized partition writes');
193
+ if (!this.#pushApplySerialized) {
194
+ throw new Error('D1 push apply requires externally serialized partition writes');
195
195
  }
196
196
  // D1 has no interactive lock. The caller explicitly asserted that every
197
197
  // write for this partition is already serialized (normally by its DO).
198
- this.#commitValidationCheckpoint = this.#buffer.length;
198
+ this.#pushApplyCheckpoint = this.#buffer.length;
199
199
  }
200
200
  async commitRejectedPushResult(clientId, clientCommitId, result) {
201
201
  this.#assertOpen();
202
- const checkpoint = this.#commitValidationCheckpoint;
202
+ const checkpoint = this.#pushApplyCheckpoint;
203
203
  if (checkpoint === undefined) {
204
- throw new Error('whole-commit rejection requires its validation checkpoint');
204
+ throw new Error('push rejection requires its apply checkpoint');
205
205
  }
206
206
  this.#buffer.length = checkpoint;
207
207
  this.#pending.clear();
@@ -352,16 +352,21 @@ class D1Transaction {
352
352
  }
353
353
  async commit() {
354
354
  this.#assertOpen();
355
- this.#open = false;
356
- if (this.#buffer.length === 0)
355
+ if (this.#buffer.length === 0) {
356
+ this.#open = false;
357
357
  return;
358
+ }
358
359
  const statements = this.#buffer.map((entry) => this.#db.prepare(entry.sql).bind(...entry.params));
359
360
  // One atomic D1 batch — the §6.4 all-or-nothing commit.
360
361
  try {
361
362
  await this.#db.batch(statements);
363
+ this.#open = false;
362
364
  }
363
365
  catch (error) {
364
366
  if (isD1ConstraintError(error)) {
367
+ // D1 batches are atomic. Keep this logical transaction open so the
368
+ // push layer can discard its buffered candidates and persist the
369
+ // terminal rejection while the external partition queue is retained.
365
370
  throw new StorageConstraintError(error, this.#lastApplicationOpIndex);
366
371
  }
367
372
  throw error;
@@ -382,14 +387,15 @@ _a = D1Transaction;
382
387
  const D1_MAX_BIND_PARAMS = 100;
383
388
  export class D1ServerStorage {
384
389
  #db;
385
- #commitValidationSerialized;
390
+ #pushApplySerialized;
386
391
  /** Set by `ensureSchema`: app-table lookup for the relational row store. */
387
392
  #tables;
388
393
  #schemaVersion;
389
394
  constructor(db, options = {}) {
390
395
  this.#db = db;
391
- this.#commitValidationSerialized =
392
- options.commitValidationSerialized === true;
396
+ this.#pushApplySerialized =
397
+ options.pushApplySerialized === true ||
398
+ options.commitValidationSerialized === true;
393
399
  }
394
400
  /** Apply the schema DDL (idempotent). Call once before use. */
395
401
  async migrate() {
@@ -512,7 +518,7 @@ export class D1ServerStorage {
512
518
  }
513
519
  }
514
520
  async begin(partition) {
515
- return new D1Transaction(this.#db, partition, (name) => this.table(name), this.#commitValidationSerialized);
521
+ return new D1Transaction(this.#db, partition, (name) => this.table(name), this.#pushApplySerialized);
516
522
  }
517
523
  async getMaxCommitSeq(partition) {
518
524
  const row = await this.#db
package/dist/events.d.ts CHANGED
@@ -59,11 +59,21 @@ export interface PushRejectedEvent extends PushEventBase {
59
59
  readonly type: 'push.rejected';
60
60
  readonly code: string;
61
61
  readonly opIndex: number;
62
+ /** True when the rejection was replayed from the idempotency cache. */
63
+ readonly replay: boolean;
64
+ /** Original host time for outcomes recorded by a metadata-aware server. */
65
+ readonly recordedAtMs?: number;
66
+ /** Privacy-safe identity of the stored outcome, when available. */
67
+ readonly cacheIdentity?: string;
62
68
  }
63
69
  /** A push commit terminated by a version conflict (§6.2). */
64
70
  export interface PushConflictedEvent extends PushEventBase {
65
71
  readonly type: 'push.conflicted';
66
72
  readonly opIndex: number;
73
+ /** True when the conflict was replayed from the idempotency cache. */
74
+ readonly replay: boolean;
75
+ readonly recordedAtMs?: number;
76
+ readonly cacheIdentity?: string;
67
77
  }
68
78
  /** One emitted segment within a pull subscription section. */
69
79
  export interface PullSegmentSummary {
package/dist/handler.js CHANGED
@@ -15,7 +15,7 @@ import { SyncError, syncError } from './errors.js';
15
15
  import { emitEvent, } from './events.js';
16
16
  import { END_FRAME_BYTES, encodeResponseFrame, RESPONSE_ENVELOPE_HEADER, } from './frame-bytes.js';
17
17
  import { ACCEPT_EXTERNAL_ROWS, ACCEPT_INLINE_ROWS, clampPullLimits, subscriptionSection, } from './pull.js';
18
- import { processPushCommit } from './push.js';
18
+ import { processPushCommitWithTrace } from './push.js';
19
19
  import { compileSchema } from './schema.js';
20
20
  import { computeEffective } from './scopes.js';
21
21
  function validateResolvedKeys(allowed, schema) {
@@ -199,7 +199,8 @@ function pushResultDetailsFrame(frame) {
199
199
  entries,
200
200
  };
201
201
  }
202
- function emitPushEvent(events, ctx, clientId, push, frame) {
202
+ function emitPushEvent(events, ctx, clientId, push, processed) {
203
+ const { frame } = processed;
203
204
  const base = {
204
205
  atMs: clockOf(ctx)(),
205
206
  partition: ctx.partition,
@@ -213,7 +214,7 @@ function emitPushEvent(events, ctx, clientId, push, frame) {
213
214
  type: 'push.applied',
214
215
  ...base,
215
216
  ...(frame.commitSeq !== undefined ? { commitSeq: frame.commitSeq } : {}),
216
- replay: frame.status === 'cached',
217
+ replay: processed.replayed,
217
218
  });
218
219
  return;
219
220
  }
@@ -224,6 +225,13 @@ function emitPushEvent(events, ctx, clientId, push, frame) {
224
225
  type: 'push.conflicted',
225
226
  ...base,
226
227
  opIndex: record.opIndex,
228
+ replay: processed.replayed,
229
+ ...(processed.recordedAtMs !== undefined
230
+ ? { recordedAtMs: processed.recordedAtMs }
231
+ : {}),
232
+ ...(processed.cacheIdentity !== undefined
233
+ ? { cacheIdentity: processed.cacheIdentity }
234
+ : {}),
227
235
  });
228
236
  return;
229
237
  }
@@ -234,6 +242,13 @@ function emitPushEvent(events, ctx, clientId, push, frame) {
234
242
  ? record.code
235
243
  : 'sync.invalid_request',
236
244
  opIndex: record?.opIndex ?? 0,
245
+ replay: processed.replayed,
246
+ ...(processed.recordedAtMs !== undefined
247
+ ? { recordedAtMs: processed.recordedAtMs }
248
+ : {}),
249
+ ...(processed.cacheIdentity !== undefined
250
+ ? { cacheIdentity: processed.cacheIdentity }
251
+ : {}),
237
252
  });
238
253
  }
239
254
  async function* streamResponse(plan, ctx, schema, report) {
@@ -276,9 +291,10 @@ async function* streamResponse(plan, ctx, schema, report) {
276
291
  try {
277
292
  // Push half (§6): one PUSH_RESULT per PUSH_COMMIT, in request order.
278
293
  for (const push of plan.pushes) {
279
- const frame = await processPushCommit(ctx, schema, plan.resolved, plan.header.clientId, push);
294
+ const processed = await processPushCommitWithTrace(ctx, schema, plan.resolved, plan.header.clientId, push);
295
+ const { frame } = processed;
280
296
  if (events !== undefined) {
281
- emitPushEvent(events, ctx, plan.header.clientId, push, frame);
297
+ emitPushEvent(events, ctx, plan.header.clientId, push, processed);
282
298
  }
283
299
  yield encodeResponseFrame(frame);
284
300
  const details = pushResultDetailsFrame(frame);
@@ -91,6 +91,12 @@ function serializePushResult(result) {
91
91
  return {
92
92
  status: result.status,
93
93
  ...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
94
+ ...(result.recordedAtMs !== undefined
95
+ ? { recordedAtMs: result.recordedAtMs }
96
+ : {}),
97
+ ...(result.cacheIdentity !== undefined
98
+ ? { cacheIdentity: result.cacheIdentity }
99
+ : {}),
94
100
  results: result.results.map((record) => {
95
101
  if (record.status === 'conflict') {
96
102
  return {
@@ -144,6 +150,12 @@ function deserializePushResult(value) {
144
150
  return {
145
151
  status: parsed.status,
146
152
  ...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
153
+ ...(parsed.recordedAtMs !== undefined
154
+ ? { recordedAtMs: parsed.recordedAtMs }
155
+ : {}),
156
+ ...(parsed.cacheIdentity !== undefined
157
+ ? { cacheIdentity: parsed.cacheIdentity }
158
+ : {}),
147
159
  results,
148
160
  };
149
161
  }
@@ -233,7 +245,7 @@ class PostgresTransaction {
233
245
  #partition;
234
246
  #resolveTable;
235
247
  #open = true;
236
- #commitValidationSavepoint = false;
248
+ #pushApplySavepoint = false;
237
249
  /** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
238
250
  #resolve;
239
251
  #reject;
@@ -297,22 +309,22 @@ class PostgresTransaction {
297
309
  this.#assertOpen();
298
310
  return scanRowsByIndexOn(this.#client, this.#resolveTable(query.table), this.#partition, query);
299
311
  }
300
- async lockPartitionForCommitValidation() {
312
+ async lockPartitionForPush() {
301
313
  this.#assertOpen();
302
314
  await this.#client.query(`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
303
315
  ON CONFLICT (partition) DO NOTHING`, [this.#partition]);
304
316
  await this.#client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [this.#partition]);
305
- await this.#client.query('SAVEPOINT syncular_commit_validation_candidate');
306
- this.#commitValidationSavepoint = true;
317
+ await this.#client.query('SAVEPOINT syncular_push_candidate');
318
+ this.#pushApplySavepoint = true;
307
319
  }
308
320
  async commitRejectedPushResult(clientId, clientCommitId, result) {
309
321
  this.#assertOpen();
310
- if (!this.#commitValidationSavepoint) {
311
- throw new Error('whole-commit rejection requires its validation savepoint');
322
+ if (!this.#pushApplySavepoint) {
323
+ throw new Error('push rejection requires its apply savepoint');
312
324
  }
313
- await this.#client.query('ROLLBACK TO SAVEPOINT syncular_commit_validation_candidate');
314
- await this.#client.query('RELEASE SAVEPOINT syncular_commit_validation_candidate');
315
- this.#commitValidationSavepoint = false;
325
+ await this.#client.query('ROLLBACK TO SAVEPOINT syncular_push_candidate');
326
+ await this.#client.query('RELEASE SAVEPOINT syncular_push_candidate');
327
+ this.#pushApplySavepoint = false;
316
328
  await this.putPushResult(clientId, clientCommitId, result);
317
329
  await this.commit();
318
330
  }
package/dist/push.d.ts CHANGED
@@ -22,6 +22,13 @@ import type { SyncRequestContext } from './context.js';
22
22
  import type { CompiledSchema } from './schema.js';
23
23
  import type { ResolvedScopes } from './scopes.js';
24
24
  import type { StoredCommit } from './storage.js';
25
+ export interface ProcessedPushCommit {
26
+ readonly frame: PushResultFrame;
27
+ /** True when this request observed an already-recorded idempotency outcome. */
28
+ readonly replayed: boolean;
29
+ readonly recordedAtMs?: number;
30
+ readonly cacheIdentity?: string;
31
+ }
25
32
  export interface AppliedCommitEvent {
26
33
  readonly commit: StoredCommit;
27
34
  }
@@ -30,3 +37,9 @@ export interface AppliedCommitEvent {
30
37
  * atomic apply (§6.4), realtime notification for applied commits.
31
38
  */
32
39
  export declare function processPushCommit(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, frame: PushCommitFrame): Promise<PushResultFrame>;
40
+ /**
41
+ * Host-observable variant of `processPushCommit`. The SSP2 wire frame keeps
42
+ * rejected replays as `status: rejected`; this companion result preserves the
43
+ * cache provenance needed by structured events and server helpers.
44
+ */
45
+ export declare function processPushCommitWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, frame: PushCommitFrame): Promise<ProcessedPushCommit>;
package/dist/push.js CHANGED
@@ -466,6 +466,25 @@ function resultFrame(clientCommitId, stored, replay) {
466
466
  results: [...stored.results],
467
467
  };
468
468
  }
469
+ function processedPushCommit(clientCommitId, stored, replayed) {
470
+ return {
471
+ frame: resultFrame(clientCommitId, stored, replayed),
472
+ replayed,
473
+ ...(stored.recordedAtMs !== undefined
474
+ ? { recordedAtMs: stored.recordedAtMs }
475
+ : {}),
476
+ ...(stored.cacheIdentity !== undefined
477
+ ? { cacheIdentity: stored.cacheIdentity }
478
+ : {}),
479
+ };
480
+ }
481
+ function newStoredPushResult(recordedAtMs, result) {
482
+ return {
483
+ ...result,
484
+ recordedAtMs,
485
+ cacheIdentity: crypto.randomUUID(),
486
+ };
487
+ }
469
488
  function idempotencyCacheMissFrame(clientCommitId, error) {
470
489
  return {
471
490
  type: 'PUSH_RESULT',
@@ -482,27 +501,19 @@ function idempotencyCacheMissFrame(clientCommitId, error) {
482
501
  ],
483
502
  };
484
503
  }
485
- async function persistRejectedPushResult(storage, partition, clientId, clientCommitId, stored) {
486
- const rejectionTx = await storage.begin(partition);
487
- try {
488
- await rejectionTx.putPushResult(clientId, clientCommitId, stored);
489
- await rejectionTx.commit();
490
- }
491
- catch (error) {
492
- await rejectionTx.rollback();
493
- throw error;
494
- }
495
- const canonical = await storage.getPushResult(partition, clientId, clientCommitId);
496
- if (canonical === undefined) {
497
- throw new Error('push rejection finalization did not persist an outcome');
498
- }
499
- return canonical;
500
- }
501
504
  /**
502
505
  * Process one `PUSH_COMMIT` frame: idempotency replay (§2.3), sequential
503
506
  * atomic apply (§6.4), realtime notification for applied commits.
504
507
  */
505
508
  export async function processPushCommit(ctx, schema, resolved, clientId, frame) {
509
+ return (await processPushCommitWithTrace(ctx, schema, resolved, clientId, frame)).frame;
510
+ }
511
+ /**
512
+ * Host-observable variant of `processPushCommit`. The SSP2 wire frame keeps
513
+ * rejected replays as `status: rejected`; this companion result preserves the
514
+ * cache provenance needed by structured events and server helpers.
515
+ */
516
+ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId, frame) {
506
517
  const { storage, partition } = ctx;
507
518
  let persisted;
508
519
  try {
@@ -513,12 +524,15 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
513
524
  error.code === 'sync.idempotency_cache_miss') {
514
525
  // §6.3: answer the retryable cache-miss for this commit rather than
515
526
  // re-applying. Not persisted — a retry may find a readable record.
516
- return idempotencyCacheMissFrame(frame.clientCommitId, error);
527
+ return {
528
+ frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
529
+ replayed: false,
530
+ };
517
531
  }
518
532
  throw error;
519
533
  }
520
534
  if (persisted !== undefined) {
521
- return resultFrame(frame.clientCommitId, persisted, true);
535
+ return processedPushCommit(frame.clientCommitId, persisted, true);
522
536
  }
523
537
  const createdAtMs = clockOf(ctx)();
524
538
  const blobCtx = { store: ctx.blobs, partition };
@@ -526,32 +540,35 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
526
540
  const validators = ctx.validators;
527
541
  const commitValidator = ctx.commitValidator;
528
542
  const tx = await storage.begin(partition);
543
+ const lockPartitionForPush = tx.lockPartitionForPush?.bind(tx) ??
544
+ tx.lockPartitionForCommitValidation?.bind(tx);
529
545
  const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
530
546
  try {
531
- if (commitValidator !== undefined) {
532
- if (tx.lockPartitionForCommitValidation === undefined ||
533
- commitRejectedPushResult === undefined) {
534
- throw new Error('storage transaction does not support atomic whole-commit validation finalization');
535
- }
536
- await tx.lockPartitionForCommitValidation();
537
- // The optimistic lookup above may have raced another request for the
538
- // same idempotency key. Re-check after acquiring partition serialization
539
- // so a concurrent duplicate never reruns the aggregate validator.
540
- try {
541
- const serializedPersisted = await storage.getPushResult(partition, clientId, frame.clientCommitId);
542
- if (serializedPersisted !== undefined) {
543
- await tx.rollback();
544
- return resultFrame(frame.clientCommitId, serializedPersisted, true);
545
- }
547
+ if (lockPartitionForPush === undefined ||
548
+ commitRejectedPushResult === undefined) {
549
+ throw new Error('storage transaction does not support serialized push apply and atomic rejection finalization');
550
+ }
551
+ await lockPartitionForPush();
552
+ // The optimistic lookup above may have raced another delivery. Re-check
553
+ // only after acquiring partition serialization and before any operation
554
+ // read, validation, merge, or staged write.
555
+ try {
556
+ const serializedPersisted = await storage.getPushResult(partition, clientId, frame.clientCommitId);
557
+ if (serializedPersisted !== undefined) {
558
+ await tx.rollback();
559
+ return processedPushCommit(frame.clientCommitId, serializedPersisted, true);
546
560
  }
547
- catch (error) {
548
- if (error instanceof SyncError &&
549
- error.code === 'sync.idempotency_cache_miss') {
550
- await tx.rollback();
551
- return idempotencyCacheMissFrame(frame.clientCommitId, error);
552
- }
553
- throw error;
561
+ }
562
+ catch (error) {
563
+ if (error instanceof SyncError &&
564
+ error.code === 'sync.idempotency_cache_miss') {
565
+ await tx.rollback();
566
+ return {
567
+ frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
568
+ replayed: false,
569
+ };
554
570
  }
571
+ throw error;
555
572
  }
556
573
  const results = [];
557
574
  const changes = [];
@@ -580,25 +597,18 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
580
597
  if (terminated !== undefined) {
581
598
  // §6.3 rejected: only the terminating operation's record; §6.4:
582
599
  // every write of the commit rolls back.
583
- const stored = {
600
+ const stored = newStoredPushResult(createdAtMs, {
584
601
  status: 'rejected',
585
602
  results: [terminated],
586
- };
587
- if (commitValidator !== undefined) {
588
- // Discard candidate rows and persist the rejection while retaining the
589
- // same partition lock. This closes the duplicate-request race between
590
- // rollback and the durable idempotency outcome.
591
- if (commitRejectedPushResult === undefined) {
592
- throw new Error('storage transaction lost whole-commit rejection finalization support');
593
- }
594
- await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
595
- }
596
- else {
597
- await tx.rollback();
598
- const canonical = await persistRejectedPushResult(storage, partition, clientId, frame.clientCommitId, stored);
599
- return resultFrame(frame.clientCommitId, canonical, canonical !== stored);
603
+ });
604
+ // Discard candidates and persist the rejection while retaining the same
605
+ // partition lock. There is no unlock gap in which a duplicate can rerun.
606
+ await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
607
+ const canonical = await storage.getPushResult(partition, clientId, frame.clientCommitId);
608
+ if (canonical === undefined) {
609
+ throw new Error('push rejection finalization did not persist an outcome');
600
610
  }
601
- return resultFrame(frame.clientCommitId, stored, false);
611
+ return processedPushCommit(frame.clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
602
612
  }
603
613
  const commitSeq = await tx.appendCommit({
604
614
  clientId,
@@ -607,7 +617,11 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
607
617
  createdAtMs,
608
618
  changes,
609
619
  });
610
- const stored = { status: 'applied', commitSeq, results };
620
+ const stored = newStoredPushResult(createdAtMs, {
621
+ status: 'applied',
622
+ commitSeq,
623
+ results,
624
+ });
611
625
  await tx.putPushResult(clientId, frame.clientCommitId, stored);
612
626
  await tx.commit();
613
627
  if (ctx.realtime !== undefined && changes.length > 0) {
@@ -618,12 +632,11 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
618
632
  changes,
619
633
  });
620
634
  }
621
- return resultFrame(frame.clientCommitId, stored, false);
635
+ return processedPushCommit(frame.clientCommitId, stored, false);
622
636
  }
623
637
  catch (error) {
624
- await tx.rollback();
625
638
  if (error instanceof StorageConstraintError) {
626
- const stored = {
639
+ const stored = newStoredPushResult(createdAtMs, {
627
640
  status: 'rejected',
628
641
  results: [
629
642
  {
@@ -634,10 +647,19 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
634
647
  retryable: false,
635
648
  },
636
649
  ],
637
- };
638
- const canonical = await persistRejectedPushResult(storage, partition, clientId, frame.clientCommitId, stored);
639
- return resultFrame(frame.clientCommitId, canonical, canonical !== stored);
650
+ });
651
+ if (commitRejectedPushResult === undefined) {
652
+ await tx.rollback();
653
+ throw new Error('storage transaction lost atomic push rejection finalization support');
654
+ }
655
+ await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
656
+ const canonical = await storage.getPushResult(partition, clientId, frame.clientCommitId);
657
+ if (canonical === undefined) {
658
+ throw new Error('push rejection finalization did not persist an outcome');
659
+ }
660
+ return processedPushCommit(frame.clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
640
661
  }
662
+ await tx.rollback();
641
663
  throw error;
642
664
  }
643
665
  }