@syncular/server 0.15.22 → 0.15.23

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
@@ -173,6 +173,21 @@ Whole-commit validation checks a client-proposed commit; it does not grant
173
173
  authority. Privileged operations such as connecting facilities still belong in
174
174
  explicit server-authoritative commands.
175
175
 
176
+ ## Seed idempotency and safe revisioning
177
+
178
+ `seedMutations` uses the real push path and a stable `clientId`/`commitId`.
179
+ Both applied and rejected outcomes are terminal for that key. A rejected call
180
+ throws `SeedMutationError`, whose structured `code`, `opIndex`, `replayed`,
181
+ `recordedAtMs`, and `cacheIdentity` fields distinguish a fresh policy failure
182
+ from replay of an older cached rejection.
183
+
184
+ After correcting a development seed definition, advance an explicit seed
185
+ revision (`catalog-v1` to `catalog-v2`) and rerun it; do not delete the database
186
+ or unrelated rows. This does not apply to application commands. After an
187
+ unknown command outcome, reuse the original idempotency key because changing it
188
+ can execute the operation twice. The full inspection and recovery recipe is in
189
+ the public [server guide](https://syncular.dev/guide-server/#seeding-data).
190
+
176
191
  The task-oriented [concurrency and conflict-correction guide](https://syncular.dev/guide-concurrency-correction/)
177
192
  shows version projection, aggregate rollback, corrected replacement commits,
178
193
  explicit acknowledgement, and restart-safe recovery UI together.
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
  }
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',
@@ -496,13 +515,24 @@ async function persistRejectedPushResult(storage, partition, clientId, clientCom
496
515
  if (canonical === undefined) {
497
516
  throw new Error('push rejection finalization did not persist an outcome');
498
517
  }
499
- return canonical;
518
+ return {
519
+ stored: canonical,
520
+ replayed: canonical.cacheIdentity !== stored.cacheIdentity,
521
+ };
500
522
  }
501
523
  /**
502
524
  * Process one `PUSH_COMMIT` frame: idempotency replay (§2.3), sequential
503
525
  * atomic apply (§6.4), realtime notification for applied commits.
504
526
  */
505
527
  export async function processPushCommit(ctx, schema, resolved, clientId, frame) {
528
+ return (await processPushCommitWithTrace(ctx, schema, resolved, clientId, frame)).frame;
529
+ }
530
+ /**
531
+ * Host-observable variant of `processPushCommit`. The SSP2 wire frame keeps
532
+ * rejected replays as `status: rejected`; this companion result preserves the
533
+ * cache provenance needed by structured events and server helpers.
534
+ */
535
+ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId, frame) {
506
536
  const { storage, partition } = ctx;
507
537
  let persisted;
508
538
  try {
@@ -513,12 +543,15 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
513
543
  error.code === 'sync.idempotency_cache_miss') {
514
544
  // §6.3: answer the retryable cache-miss for this commit rather than
515
545
  // re-applying. Not persisted — a retry may find a readable record.
516
- return idempotencyCacheMissFrame(frame.clientCommitId, error);
546
+ return {
547
+ frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
548
+ replayed: false,
549
+ };
517
550
  }
518
551
  throw error;
519
552
  }
520
553
  if (persisted !== undefined) {
521
- return resultFrame(frame.clientCommitId, persisted, true);
554
+ return processedPushCommit(frame.clientCommitId, persisted, true);
522
555
  }
523
556
  const createdAtMs = clockOf(ctx)();
524
557
  const blobCtx = { store: ctx.blobs, partition };
@@ -541,14 +574,17 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
541
574
  const serializedPersisted = await storage.getPushResult(partition, clientId, frame.clientCommitId);
542
575
  if (serializedPersisted !== undefined) {
543
576
  await tx.rollback();
544
- return resultFrame(frame.clientCommitId, serializedPersisted, true);
577
+ return processedPushCommit(frame.clientCommitId, serializedPersisted, true);
545
578
  }
546
579
  }
547
580
  catch (error) {
548
581
  if (error instanceof SyncError &&
549
582
  error.code === 'sync.idempotency_cache_miss') {
550
583
  await tx.rollback();
551
- return idempotencyCacheMissFrame(frame.clientCommitId, error);
584
+ return {
585
+ frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
586
+ replayed: false,
587
+ };
552
588
  }
553
589
  throw error;
554
590
  }
@@ -580,10 +616,10 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
580
616
  if (terminated !== undefined) {
581
617
  // §6.3 rejected: only the terminating operation's record; §6.4:
582
618
  // every write of the commit rolls back.
583
- const stored = {
619
+ const stored = newStoredPushResult(createdAtMs, {
584
620
  status: 'rejected',
585
621
  results: [terminated],
586
- };
622
+ });
587
623
  if (commitValidator !== undefined) {
588
624
  // Discard candidate rows and persist the rejection while retaining the
589
625
  // same partition lock. This closes the duplicate-request race between
@@ -596,9 +632,9 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
596
632
  else {
597
633
  await tx.rollback();
598
634
  const canonical = await persistRejectedPushResult(storage, partition, clientId, frame.clientCommitId, stored);
599
- return resultFrame(frame.clientCommitId, canonical, canonical !== stored);
635
+ return processedPushCommit(frame.clientCommitId, canonical.stored, canonical.replayed);
600
636
  }
601
- return resultFrame(frame.clientCommitId, stored, false);
637
+ return processedPushCommit(frame.clientCommitId, stored, false);
602
638
  }
603
639
  const commitSeq = await tx.appendCommit({
604
640
  clientId,
@@ -607,7 +643,11 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
607
643
  createdAtMs,
608
644
  changes,
609
645
  });
610
- const stored = { status: 'applied', commitSeq, results };
646
+ const stored = newStoredPushResult(createdAtMs, {
647
+ status: 'applied',
648
+ commitSeq,
649
+ results,
650
+ });
611
651
  await tx.putPushResult(clientId, frame.clientCommitId, stored);
612
652
  await tx.commit();
613
653
  if (ctx.realtime !== undefined && changes.length > 0) {
@@ -618,12 +658,12 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
618
658
  changes,
619
659
  });
620
660
  }
621
- return resultFrame(frame.clientCommitId, stored, false);
661
+ return processedPushCommit(frame.clientCommitId, stored, false);
622
662
  }
623
663
  catch (error) {
624
664
  await tx.rollback();
625
665
  if (error instanceof StorageConstraintError) {
626
- const stored = {
666
+ const stored = newStoredPushResult(createdAtMs, {
627
667
  status: 'rejected',
628
668
  results: [
629
669
  {
@@ -634,9 +674,9 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
634
674
  retryable: false,
635
675
  },
636
676
  ],
637
- };
677
+ });
638
678
  const canonical = await persistRejectedPushResult(storage, partition, clientId, frame.clientCommitId, stored);
639
- return resultFrame(frame.clientCommitId, canonical, canonical !== stored);
679
+ return processedPushCommit(frame.clientCommitId, canonical.stored, canonical.replayed);
640
680
  }
641
681
  throw error;
642
682
  }
package/dist/seed.d.ts CHANGED
@@ -26,9 +26,34 @@ export interface SeedTarget {
26
26
  /** The client commit id (default `'seed-commit-1'`). */
27
27
  readonly commitId?: string;
28
28
  }
29
+ export interface SeedMutationErrorOptions {
30
+ readonly clientId: string;
31
+ readonly clientCommitId: string;
32
+ readonly opIndex: number;
33
+ readonly code: string;
34
+ readonly replayed: boolean;
35
+ readonly retryable: boolean;
36
+ readonly message: string;
37
+ readonly recordedAtMs?: number;
38
+ readonly cacheIdentity?: string;
39
+ }
40
+ /** Structured terminal failure from the real push path used by a seed. */
41
+ export declare class SeedMutationError extends Error {
42
+ readonly name = "SeedMutationError";
43
+ readonly clientId: string;
44
+ readonly clientCommitId: string;
45
+ readonly opIndex: number;
46
+ /** Exact protocol or host-validator rejection code. */
47
+ readonly code: string;
48
+ readonly replayed: boolean;
49
+ readonly retryable: boolean;
50
+ readonly recordedAtMs?: number;
51
+ readonly cacheIdentity?: string;
52
+ constructor(options: SeedMutationErrorOptions);
53
+ }
29
54
  /**
30
55
  * Seed `mutations` into a partition through the real push path. Throws a
31
- * `SyncError` when the push is rejected or any operation fails, so a broken
32
- * seed fails loud at boot instead of silently serving an empty database.
56
+ * `SeedMutationError` when the push is rejected and `SyncError` for malformed
57
+ * helper input, so a broken seed fails loud instead of serving an empty store.
33
58
  */
34
59
  export declare function seedMutations(config: SyncServerConfig, target: SeedTarget, mutations: readonly SeedMutation[]): Promise<void>;
package/dist/seed.js CHANGED
@@ -11,7 +11,36 @@
11
11
  */
12
12
  import { decodeMessage, encodeMessage, encodeRow, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
13
13
  import { SyncError } from './errors.js';
14
+ import { composeEvents } from './events-ring.js';
14
15
  import { handleSyncRequest } from './handler.js';
16
+ /** Structured terminal failure from the real push path used by a seed. */
17
+ export class SeedMutationError extends Error {
18
+ name = 'SeedMutationError';
19
+ clientId;
20
+ clientCommitId;
21
+ opIndex;
22
+ /** Exact protocol or host-validator rejection code. */
23
+ code;
24
+ replayed;
25
+ retryable;
26
+ recordedAtMs;
27
+ cacheIdentity;
28
+ constructor(options) {
29
+ super(options.message);
30
+ this.clientId = options.clientId;
31
+ this.clientCommitId = options.clientCommitId;
32
+ this.opIndex = options.opIndex;
33
+ this.code = options.code;
34
+ this.replayed = options.replayed;
35
+ this.retryable = options.retryable;
36
+ if (options.recordedAtMs !== undefined) {
37
+ this.recordedAtMs = options.recordedAtMs;
38
+ }
39
+ if (options.cacheIdentity !== undefined) {
40
+ this.cacheIdentity = options.cacheIdentity;
41
+ }
42
+ }
43
+ }
15
44
  const MAPPABLE_RE = /^_*[A-Za-z][A-Za-z0-9_]*$/;
16
45
  /** Pinned §12 schema alias used by generated row types and every client host. */
17
46
  function snakeToCamel(name) {
@@ -35,8 +64,8 @@ function snakeToCamel(name) {
35
64
  }
36
65
  /**
37
66
  * Seed `mutations` into a partition through the real push path. Throws a
38
- * `SyncError` when the push is rejected or any operation fails, so a broken
39
- * seed fails loud at boot instead of silently serving an empty database.
67
+ * `SeedMutationError` when the push is rejected and `SyncError` for malformed
68
+ * helper input, so a broken seed fails loud instead of serving an empty store.
40
69
  */
41
70
  export async function seedMutations(config, target, mutations) {
42
71
  const clientId = target.clientId ?? 'seed';
@@ -88,11 +117,28 @@ export async function seedMutations(config, target, mutations) {
88
117
  accept: 0b0011,
89
118
  },
90
119
  ];
120
+ let terminalEvent;
121
+ const capture = {
122
+ emit(event) {
123
+ if ((event.type === 'push.rejected' || event.type === 'push.conflicted') &&
124
+ event.clientId === clientId &&
125
+ event.clientCommitId === clientCommitId) {
126
+ terminalEvent = event;
127
+ }
128
+ },
129
+ };
91
130
  const response = await handleSyncRequest(encodeMessage({
92
131
  wireVersion: PROTOCOL_WIRE_VERSION,
93
132
  msgKind: 'request',
94
133
  frames,
95
- }), { ...config, partition: target.partition, actorId: target.actorId });
134
+ }), {
135
+ ...config,
136
+ partition: target.partition,
137
+ actorId: target.actorId,
138
+ events: config.events === undefined
139
+ ? capture
140
+ : composeEvents(config.events, capture),
141
+ });
96
142
  // Fail loud: surface the first rejected/failed operation.
97
143
  const message = decodeMessage(response);
98
144
  const result = message.frames.find((frame) => frame.type === 'PUSH_RESULT' && frame.clientCommitId === clientCommitId);
@@ -101,9 +147,27 @@ export async function seedMutations(config, target, mutations) {
101
147
  }
102
148
  if (result.status === 'rejected') {
103
149
  const failed = result.results.find((r) => r.status !== 'applied');
104
- const detail = failed !== undefined && 'code' in failed
105
- ? ` (op ${failed.opIndex}: ${failed.code} — ${failed.message})`
106
- : '';
107
- throw new SyncError('sync.invalid_request', `seedMutations: the seed commit was rejected${detail}`);
150
+ const code = failed?.code ?? 'sync.invalid_request';
151
+ const opIndex = failed?.opIndex ?? 0;
152
+ const retryable = failed?.status === 'error' ? failed.retryable : false;
153
+ const detail = failed === undefined
154
+ ? ''
155
+ : ` (op ${opIndex}: ${code} — ${failed.message})`;
156
+ const replayed = terminalEvent?.replay ?? false;
157
+ throw new SeedMutationError({
158
+ clientId,
159
+ clientCommitId,
160
+ opIndex,
161
+ code,
162
+ replayed,
163
+ retryable,
164
+ message: `seedMutations: the seed commit was rejected${replayed ? ' (cached replay)' : ''}${detail}`,
165
+ ...(terminalEvent?.recordedAtMs !== undefined
166
+ ? { recordedAtMs: terminalEvent.recordedAtMs }
167
+ : {}),
168
+ ...(terminalEvent?.cacheIdentity !== undefined
169
+ ? { cacheIdentity: terminalEvent.cacheIdentity }
170
+ : {}),
171
+ });
108
172
  }
109
173
  }
@@ -93,6 +93,12 @@ export function serializePushResult(result) {
93
93
  return JSON.stringify({
94
94
  status: result.status,
95
95
  ...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
96
+ ...(result.recordedAtMs !== undefined
97
+ ? { recordedAtMs: result.recordedAtMs }
98
+ : {}),
99
+ ...(result.cacheIdentity !== undefined
100
+ ? { cacheIdentity: result.cacheIdentity }
101
+ : {}),
96
102
  results: result.results.map((record) => {
97
103
  if (record.status === 'conflict') {
98
104
  return {
@@ -146,6 +152,12 @@ export function deserializePushResult(text) {
146
152
  return {
147
153
  status: parsed.status,
148
154
  ...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
155
+ ...(parsed.recordedAtMs !== undefined
156
+ ? { recordedAtMs: parsed.recordedAtMs }
157
+ : {}),
158
+ ...(parsed.cacheIdentity !== undefined
159
+ ? { cacheIdentity: parsed.cacheIdentity }
160
+ : {}),
149
161
  results,
150
162
  };
151
163
  }
package/dist/storage.d.ts CHANGED
@@ -64,6 +64,10 @@ export interface StoredPushResult {
64
64
  readonly status: 'applied' | 'rejected';
65
65
  /** Present iff `status` is `applied`. */
66
66
  readonly commitSeq?: number;
67
+ /** Host clock when this terminal idempotency outcome was first recorded. */
68
+ readonly recordedAtMs?: number;
69
+ /** Privacy-safe identity used to distinguish this stored outcome from a race. */
70
+ readonly cacheIdentity?: string;
67
71
  readonly results: readonly PushOperationResult[];
68
72
  }
69
73
  export interface ClientSubscription {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.15.22",
3
+ "version": "0.15.23",
4
4
  "description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -53,7 +53,7 @@
53
53
  "!dist/**/*.test.d.ts"
54
54
  ],
55
55
  "dependencies": {
56
- "@syncular/core": "0.15.22"
56
+ "@syncular/core": "0.15.23"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@electric-sql/pglite": "^0.5.4"
package/src/events.ts CHANGED
@@ -63,12 +63,22 @@ export interface PushRejectedEvent extends PushEventBase {
63
63
  readonly type: 'push.rejected';
64
64
  readonly code: string;
65
65
  readonly opIndex: number;
66
+ /** True when the rejection was replayed from the idempotency cache. */
67
+ readonly replay: boolean;
68
+ /** Original host time for outcomes recorded by a metadata-aware server. */
69
+ readonly recordedAtMs?: number;
70
+ /** Privacy-safe identity of the stored outcome, when available. */
71
+ readonly cacheIdentity?: string;
66
72
  }
67
73
 
68
74
  /** A push commit terminated by a version conflict (§6.2). */
69
75
  export interface PushConflictedEvent extends PushEventBase {
70
76
  readonly type: 'push.conflicted';
71
77
  readonly opIndex: number;
78
+ /** True when the conflict was replayed from the idempotency cache. */
79
+ readonly replay: boolean;
80
+ readonly recordedAtMs?: number;
81
+ readonly cacheIdentity?: string;
72
82
  }
73
83
 
74
84
  /** One emitted segment within a pull subscription section. */
package/src/handler.ts CHANGED
@@ -44,7 +44,7 @@ import {
44
44
  type SubscriptionPlan,
45
45
  subscriptionSection,
46
46
  } from './pull';
47
- import { processPushCommit } from './push';
47
+ import { type ProcessedPushCommit, processPushCommitWithTrace } from './push';
48
48
  import type { CompiledSchema } from './schema';
49
49
  import { compileSchema } from './schema';
50
50
  import { computeEffective, type ResolvedScopes } from './scopes';
@@ -339,8 +339,9 @@ function emitPushEvent(
339
339
  ctx: SyncRequestContext,
340
340
  clientId: string,
341
341
  push: PushCommitFrame,
342
- frame: PushResultFrame,
342
+ processed: ProcessedPushCommit,
343
343
  ): void {
344
+ const { frame } = processed;
344
345
  const base = {
345
346
  atMs: clockOf(ctx)(),
346
347
  partition: ctx.partition,
@@ -354,7 +355,7 @@ function emitPushEvent(
354
355
  type: 'push.applied',
355
356
  ...base,
356
357
  ...(frame.commitSeq !== undefined ? { commitSeq: frame.commitSeq } : {}),
357
- replay: frame.status === 'cached',
358
+ replay: processed.replayed,
358
359
  });
359
360
  return;
360
361
  }
@@ -365,6 +366,13 @@ function emitPushEvent(
365
366
  type: 'push.conflicted',
366
367
  ...base,
367
368
  opIndex: record.opIndex,
369
+ replay: processed.replayed,
370
+ ...(processed.recordedAtMs !== undefined
371
+ ? { recordedAtMs: processed.recordedAtMs }
372
+ : {}),
373
+ ...(processed.cacheIdentity !== undefined
374
+ ? { cacheIdentity: processed.cacheIdentity }
375
+ : {}),
368
376
  });
369
377
  return;
370
378
  }
@@ -376,6 +384,13 @@ function emitPushEvent(
376
384
  ? record.code
377
385
  : 'sync.invalid_request',
378
386
  opIndex: record?.opIndex ?? 0,
387
+ replay: processed.replayed,
388
+ ...(processed.recordedAtMs !== undefined
389
+ ? { recordedAtMs: processed.recordedAtMs }
390
+ : {}),
391
+ ...(processed.cacheIdentity !== undefined
392
+ ? { cacheIdentity: processed.cacheIdentity }
393
+ : {}),
379
394
  });
380
395
  }
381
396
 
@@ -423,15 +438,16 @@ async function* streamResponse(
423
438
  try {
424
439
  // Push half (§6): one PUSH_RESULT per PUSH_COMMIT, in request order.
425
440
  for (const push of plan.pushes) {
426
- const frame = await processPushCommit(
441
+ const processed = await processPushCommitWithTrace(
427
442
  ctx,
428
443
  schema,
429
444
  plan.resolved,
430
445
  plan.header.clientId,
431
446
  push,
432
447
  );
448
+ const { frame } = processed;
433
449
  if (events !== undefined) {
434
- emitPushEvent(events, ctx, plan.header.clientId, push, frame);
450
+ emitPushEvent(events, ctx, plan.header.clientId, push, processed);
435
451
  }
436
452
  yield encodeResponseFrame(frame);
437
453
  const details = pushResultDetailsFrame(frame);
@@ -193,6 +193,12 @@ function serializePushResult(result: StoredPushResult): unknown {
193
193
  return {
194
194
  status: result.status,
195
195
  ...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
196
+ ...(result.recordedAtMs !== undefined
197
+ ? { recordedAtMs: result.recordedAtMs }
198
+ : {}),
199
+ ...(result.cacheIdentity !== undefined
200
+ ? { cacheIdentity: result.cacheIdentity }
201
+ : {}),
196
202
  results: result.results.map((record) => {
197
203
  if (record.status === 'conflict') {
198
204
  return {
@@ -223,6 +229,8 @@ function deserializePushResult(value: unknown): StoredPushResult {
223
229
  const parsed = value as {
224
230
  status: 'applied' | 'rejected';
225
231
  commitSeq?: number;
232
+ recordedAtMs?: number;
233
+ cacheIdentity?: string;
226
234
  results: SerializedResult[];
227
235
  };
228
236
  const results: PushOperationResult[] = parsed.results.map((record) => {
@@ -251,6 +259,12 @@ function deserializePushResult(value: unknown): StoredPushResult {
251
259
  return {
252
260
  status: parsed.status,
253
261
  ...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
262
+ ...(parsed.recordedAtMs !== undefined
263
+ ? { recordedAtMs: parsed.recordedAtMs }
264
+ : {}),
265
+ ...(parsed.cacheIdentity !== undefined
266
+ ? { cacheIdentity: parsed.cacheIdentity }
267
+ : {}),
254
268
  results,
255
269
  };
256
270
  }
package/src/push.ts CHANGED
@@ -750,6 +750,42 @@ function resultFrame(
750
750
  };
751
751
  }
752
752
 
753
+ export interface ProcessedPushCommit {
754
+ readonly frame: PushResultFrame;
755
+ /** True when this request observed an already-recorded idempotency outcome. */
756
+ readonly replayed: boolean;
757
+ readonly recordedAtMs?: number;
758
+ readonly cacheIdentity?: string;
759
+ }
760
+
761
+ function processedPushCommit(
762
+ clientCommitId: string,
763
+ stored: StoredPushResult,
764
+ replayed: boolean,
765
+ ): ProcessedPushCommit {
766
+ return {
767
+ frame: resultFrame(clientCommitId, stored, replayed),
768
+ replayed,
769
+ ...(stored.recordedAtMs !== undefined
770
+ ? { recordedAtMs: stored.recordedAtMs }
771
+ : {}),
772
+ ...(stored.cacheIdentity !== undefined
773
+ ? { cacheIdentity: stored.cacheIdentity }
774
+ : {}),
775
+ };
776
+ }
777
+
778
+ function newStoredPushResult(
779
+ recordedAtMs: number,
780
+ result: Omit<StoredPushResult, 'recordedAtMs' | 'cacheIdentity'>,
781
+ ): StoredPushResult {
782
+ return {
783
+ ...result,
784
+ recordedAtMs,
785
+ cacheIdentity: crypto.randomUUID(),
786
+ };
787
+ }
788
+
753
789
  function idempotencyCacheMissFrame(
754
790
  clientCommitId: string,
755
791
  error: SyncError,
@@ -776,7 +812,7 @@ async function persistRejectedPushResult(
776
812
  clientId: string,
777
813
  clientCommitId: string,
778
814
  stored: StoredPushResult,
779
- ): Promise<StoredPushResult> {
815
+ ): Promise<{ readonly stored: StoredPushResult; readonly replayed: boolean }> {
780
816
  const rejectionTx = await storage.begin(partition);
781
817
  try {
782
818
  await rejectionTx.putPushResult(clientId, clientCommitId, stored);
@@ -793,7 +829,10 @@ async function persistRejectedPushResult(
793
829
  if (canonical === undefined) {
794
830
  throw new Error('push rejection finalization did not persist an outcome');
795
831
  }
796
- return canonical;
832
+ return {
833
+ stored: canonical,
834
+ replayed: canonical.cacheIdentity !== stored.cacheIdentity,
835
+ };
797
836
  }
798
837
 
799
838
  export interface AppliedCommitEvent {
@@ -811,6 +850,23 @@ export async function processPushCommit(
811
850
  clientId: string,
812
851
  frame: PushCommitFrame,
813
852
  ): Promise<PushResultFrame> {
853
+ return (
854
+ await processPushCommitWithTrace(ctx, schema, resolved, clientId, frame)
855
+ ).frame;
856
+ }
857
+
858
+ /**
859
+ * Host-observable variant of `processPushCommit`. The SSP2 wire frame keeps
860
+ * rejected replays as `status: rejected`; this companion result preserves the
861
+ * cache provenance needed by structured events and server helpers.
862
+ */
863
+ export async function processPushCommitWithTrace(
864
+ ctx: SyncRequestContext,
865
+ schema: CompiledSchema,
866
+ resolved: ResolvedScopes,
867
+ clientId: string,
868
+ frame: PushCommitFrame,
869
+ ): Promise<ProcessedPushCommit> {
814
870
  const { storage, partition } = ctx;
815
871
  let persisted: StoredPushResult | undefined;
816
872
  try {
@@ -826,12 +882,15 @@ export async function processPushCommit(
826
882
  ) {
827
883
  // §6.3: answer the retryable cache-miss for this commit rather than
828
884
  // re-applying. Not persisted — a retry may find a readable record.
829
- return idempotencyCacheMissFrame(frame.clientCommitId, error);
885
+ return {
886
+ frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
887
+ replayed: false,
888
+ };
830
889
  }
831
890
  throw error;
832
891
  }
833
892
  if (persisted !== undefined) {
834
- return resultFrame(frame.clientCommitId, persisted, true);
893
+ return processedPushCommit(frame.clientCommitId, persisted, true);
835
894
  }
836
895
 
837
896
  const createdAtMs = clockOf(ctx)();
@@ -863,7 +922,11 @@ export async function processPushCommit(
863
922
  );
864
923
  if (serializedPersisted !== undefined) {
865
924
  await tx.rollback();
866
- return resultFrame(frame.clientCommitId, serializedPersisted, true);
925
+ return processedPushCommit(
926
+ frame.clientCommitId,
927
+ serializedPersisted,
928
+ true,
929
+ );
867
930
  }
868
931
  } catch (error) {
869
932
  if (
@@ -871,7 +934,10 @@ export async function processPushCommit(
871
934
  error.code === 'sync.idempotency_cache_miss'
872
935
  ) {
873
936
  await tx.rollback();
874
- return idempotencyCacheMissFrame(frame.clientCommitId, error);
937
+ return {
938
+ frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
939
+ replayed: false,
940
+ };
875
941
  }
876
942
  throw error;
877
943
  }
@@ -923,10 +989,10 @@ export async function processPushCommit(
923
989
  if (terminated !== undefined) {
924
990
  // §6.3 rejected: only the terminating operation's record; §6.4:
925
991
  // every write of the commit rolls back.
926
- const stored: StoredPushResult = {
992
+ const stored = newStoredPushResult(createdAtMs, {
927
993
  status: 'rejected',
928
994
  results: [terminated],
929
- };
995
+ });
930
996
  if (commitValidator !== undefined) {
931
997
  // Discard candidate rows and persist the rejection while retaining the
932
998
  // same partition lock. This closes the duplicate-request race between
@@ -946,13 +1012,13 @@ export async function processPushCommit(
946
1012
  frame.clientCommitId,
947
1013
  stored,
948
1014
  );
949
- return resultFrame(
1015
+ return processedPushCommit(
950
1016
  frame.clientCommitId,
951
- canonical,
952
- canonical !== stored,
1017
+ canonical.stored,
1018
+ canonical.replayed,
953
1019
  );
954
1020
  }
955
- return resultFrame(frame.clientCommitId, stored, false);
1021
+ return processedPushCommit(frame.clientCommitId, stored, false);
956
1022
  }
957
1023
 
958
1024
  const commitSeq = await tx.appendCommit({
@@ -962,7 +1028,11 @@ export async function processPushCommit(
962
1028
  createdAtMs,
963
1029
  changes,
964
1030
  });
965
- const stored: StoredPushResult = { status: 'applied', commitSeq, results };
1031
+ const stored = newStoredPushResult(createdAtMs, {
1032
+ status: 'applied',
1033
+ commitSeq,
1034
+ results,
1035
+ });
966
1036
  await tx.putPushResult(clientId, frame.clientCommitId, stored);
967
1037
  await tx.commit();
968
1038
  if (ctx.realtime !== undefined && changes.length > 0) {
@@ -973,11 +1043,11 @@ export async function processPushCommit(
973
1043
  changes,
974
1044
  });
975
1045
  }
976
- return resultFrame(frame.clientCommitId, stored, false);
1046
+ return processedPushCommit(frame.clientCommitId, stored, false);
977
1047
  } catch (error) {
978
1048
  await tx.rollback();
979
1049
  if (error instanceof StorageConstraintError) {
980
- const stored: StoredPushResult = {
1050
+ const stored = newStoredPushResult(createdAtMs, {
981
1051
  status: 'rejected',
982
1052
  results: [
983
1053
  {
@@ -988,7 +1058,7 @@ export async function processPushCommit(
988
1058
  retryable: false,
989
1059
  },
990
1060
  ],
991
- };
1061
+ });
992
1062
  const canonical = await persistRejectedPushResult(
993
1063
  storage,
994
1064
  partition,
@@ -996,7 +1066,11 @@ export async function processPushCommit(
996
1066
  frame.clientCommitId,
997
1067
  stored,
998
1068
  );
999
- return resultFrame(frame.clientCommitId, canonical, canonical !== stored);
1069
+ return processedPushCommit(
1070
+ frame.clientCommitId,
1071
+ canonical.stored,
1072
+ canonical.replayed,
1073
+ );
1000
1074
  }
1001
1075
  throw error;
1002
1076
  }
package/src/seed.ts CHANGED
@@ -21,6 +21,8 @@ import {
21
21
  } from '@syncular/core';
22
22
  import type { SyncServerConfig } from './context';
23
23
  import { SyncError } from './errors';
24
+ import type { SyncularServerEvent, SyncularServerEvents } from './events';
25
+ import { composeEvents } from './events-ring';
24
26
  import { handleSyncRequest } from './handler';
25
27
 
26
28
  /** One app-shaped seed mutation — the same vocabulary as client mutations. */
@@ -54,6 +56,48 @@ export interface SeedTarget {
54
56
  readonly commitId?: string;
55
57
  }
56
58
 
59
+ export interface SeedMutationErrorOptions {
60
+ readonly clientId: string;
61
+ readonly clientCommitId: string;
62
+ readonly opIndex: number;
63
+ readonly code: string;
64
+ readonly replayed: boolean;
65
+ readonly retryable: boolean;
66
+ readonly message: string;
67
+ readonly recordedAtMs?: number;
68
+ readonly cacheIdentity?: string;
69
+ }
70
+
71
+ /** Structured terminal failure from the real push path used by a seed. */
72
+ export class SeedMutationError extends Error {
73
+ override readonly name = 'SeedMutationError';
74
+ readonly clientId: string;
75
+ readonly clientCommitId: string;
76
+ readonly opIndex: number;
77
+ /** Exact protocol or host-validator rejection code. */
78
+ readonly code: string;
79
+ readonly replayed: boolean;
80
+ readonly retryable: boolean;
81
+ readonly recordedAtMs?: number;
82
+ readonly cacheIdentity?: string;
83
+
84
+ constructor(options: SeedMutationErrorOptions) {
85
+ super(options.message);
86
+ this.clientId = options.clientId;
87
+ this.clientCommitId = options.clientCommitId;
88
+ this.opIndex = options.opIndex;
89
+ this.code = options.code;
90
+ this.replayed = options.replayed;
91
+ this.retryable = options.retryable;
92
+ if (options.recordedAtMs !== undefined) {
93
+ this.recordedAtMs = options.recordedAtMs;
94
+ }
95
+ if (options.cacheIdentity !== undefined) {
96
+ this.cacheIdentity = options.cacheIdentity;
97
+ }
98
+ }
99
+ }
100
+
57
101
  const MAPPABLE_RE = /^_*[A-Za-z][A-Za-z0-9_]*$/;
58
102
 
59
103
  /** Pinned §12 schema alias used by generated row types and every client host. */
@@ -79,8 +123,8 @@ function snakeToCamel(name: string): string {
79
123
 
80
124
  /**
81
125
  * Seed `mutations` into a partition through the real push path. Throws a
82
- * `SyncError` when the push is rejected or any operation fails, so a broken
83
- * seed fails loud at boot instead of silently serving an empty database.
126
+ * `SeedMutationError` when the push is rejected and `SyncError` for malformed
127
+ * helper input, so a broken seed fails loud instead of serving an empty store.
84
128
  */
85
129
  export async function seedMutations(
86
130
  config: SyncServerConfig,
@@ -154,13 +198,37 @@ export async function seedMutations(
154
198
  accept: 0b0011,
155
199
  },
156
200
  ];
201
+ type SeedTerminalEvent = Extract<
202
+ SyncularServerEvent,
203
+ { type: 'push.rejected' | 'push.conflicted' }
204
+ >;
205
+ let terminalEvent: SeedTerminalEvent | undefined;
206
+ const capture: SyncularServerEvents = {
207
+ emit(event) {
208
+ if (
209
+ (event.type === 'push.rejected' || event.type === 'push.conflicted') &&
210
+ event.clientId === clientId &&
211
+ event.clientCommitId === clientCommitId
212
+ ) {
213
+ terminalEvent = event;
214
+ }
215
+ },
216
+ };
157
217
  const response = await handleSyncRequest(
158
218
  encodeMessage({
159
219
  wireVersion: PROTOCOL_WIRE_VERSION,
160
220
  msgKind: 'request',
161
221
  frames,
162
222
  }),
163
- { ...config, partition: target.partition, actorId: target.actorId },
223
+ {
224
+ ...config,
225
+ partition: target.partition,
226
+ actorId: target.actorId,
227
+ events:
228
+ config.events === undefined
229
+ ? capture
230
+ : composeEvents(config.events, capture),
231
+ },
164
232
  );
165
233
 
166
234
  // Fail loud: surface the first rejected/failed operation.
@@ -177,13 +245,30 @@ export async function seedMutations(
177
245
  }
178
246
  if (result.status === 'rejected') {
179
247
  const failed = result.results.find((r) => r.status !== 'applied');
248
+ const code = failed?.code ?? 'sync.invalid_request';
249
+ const opIndex = failed?.opIndex ?? 0;
250
+ const retryable = failed?.status === 'error' ? failed.retryable : false;
180
251
  const detail =
181
- failed !== undefined && 'code' in failed
182
- ? ` (op ${failed.opIndex}: ${failed.code} — ${failed.message})`
183
- : '';
184
- throw new SyncError(
185
- 'sync.invalid_request',
186
- `seedMutations: the seed commit was rejected${detail}`,
187
- );
252
+ failed === undefined
253
+ ? ''
254
+ : ` (op ${opIndex}: ${code} — ${failed.message})`;
255
+ const replayed = terminalEvent?.replay ?? false;
256
+ throw new SeedMutationError({
257
+ clientId,
258
+ clientCommitId,
259
+ opIndex,
260
+ code,
261
+ replayed,
262
+ retryable,
263
+ message: `seedMutations: the seed commit was rejected${
264
+ replayed ? ' (cached replay)' : ''
265
+ }${detail}`,
266
+ ...(terminalEvent?.recordedAtMs !== undefined
267
+ ? { recordedAtMs: terminalEvent.recordedAtMs }
268
+ : {}),
269
+ ...(terminalEvent?.cacheIdentity !== undefined
270
+ ? { cacheIdentity: terminalEvent.cacheIdentity }
271
+ : {}),
272
+ });
188
273
  }
189
274
  }
@@ -134,6 +134,12 @@ export function serializePushResult(result: StoredPushResult): string {
134
134
  return JSON.stringify({
135
135
  status: result.status,
136
136
  ...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
137
+ ...(result.recordedAtMs !== undefined
138
+ ? { recordedAtMs: result.recordedAtMs }
139
+ : {}),
140
+ ...(result.cacheIdentity !== undefined
141
+ ? { cacheIdentity: result.cacheIdentity }
142
+ : {}),
137
143
  results: result.results.map((record) => {
138
144
  if (record.status === 'conflict') {
139
145
  return {
@@ -164,6 +170,8 @@ export function deserializePushResult(text: string): StoredPushResult {
164
170
  const parsed = JSON.parse(text) as {
165
171
  status: 'applied' | 'rejected';
166
172
  commitSeq?: number;
173
+ recordedAtMs?: number;
174
+ cacheIdentity?: string;
167
175
  results: SerializedResult[];
168
176
  };
169
177
  const results: PushOperationResult[] = parsed.results.map((record) => {
@@ -192,6 +200,12 @@ export function deserializePushResult(text: string): StoredPushResult {
192
200
  return {
193
201
  status: parsed.status,
194
202
  ...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
203
+ ...(parsed.recordedAtMs !== undefined
204
+ ? { recordedAtMs: parsed.recordedAtMs }
205
+ : {}),
206
+ ...(parsed.cacheIdentity !== undefined
207
+ ? { cacheIdentity: parsed.cacheIdentity }
208
+ : {}),
195
209
  results,
196
210
  };
197
211
  }
package/src/storage.ts CHANGED
@@ -70,6 +70,10 @@ export interface StoredPushResult {
70
70
  readonly status: 'applied' | 'rejected';
71
71
  /** Present iff `status` is `applied`. */
72
72
  readonly commitSeq?: number;
73
+ /** Host clock when this terminal idempotency outcome was first recorded. */
74
+ readonly recordedAtMs?: number;
75
+ /** Privacy-safe identity used to distinguish this stored outcome from a race. */
76
+ readonly cacheIdentity?: string;
73
77
  readonly results: readonly PushOperationResult[];
74
78
  }
75
79