@syncular/server 0.30.16 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/context.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  import type { ScopeMap } from '@syncular/core';
8
8
  import type { BlobStore } from './blob-store';
9
9
  import type { CrdtMergerRegistry } from './crdt-merger';
10
- import type { SyncularErrorHandler } from './errors';
10
+ import type { SyncularErrorHandler, SyncularErrorMapper } from './errors';
11
11
  import type { SyncularServerEvents } from './events';
12
12
  import type { LeaseStore } from './lease-store';
13
13
  import type { AnyReactionPlanner } from './reactions';
@@ -182,11 +182,10 @@ export interface SyncServerConfig {
182
182
  */
183
183
  readonly blobUploadUrls?: BlobUploadPresignConfig;
184
184
  /**
185
- * §5.3 sqlite-image builder, injected so the pull path never
186
- * statically imports `bun:sqlite`. Absent ⇒ the sqlite-image lane is off
187
- * (bit-2 clients are served the rows lane) — the Workers/edge posture. A
188
- * Bun or Node host wires `buildSqliteImage` from
189
- * `@syncular/server/sqlite`.
185
+ * §5.3 sqlite-image builder. The host explicitly imports and supplies
186
+ * `buildSqliteImage` from `@syncular/server/sqlite` on Bun or Node.
187
+ * Absent ⇒ serve matching stored images or negotiate the rows lane.
188
+ * The neutral core never imports a runtime-specific builder.
190
189
  */
191
190
  readonly sqliteImageBuilder?: SqliteImageBuilder;
192
191
  readonly realtime?: RealtimeNotifier;
@@ -203,6 +202,17 @@ export interface SyncServerConfig {
203
202
  * handler never changes the response.
204
203
  */
205
204
  readonly onError?: SyncularErrorHandler;
205
+ /**
206
+ * Optional synchronous adapter error mapper (§10.2). After `onError`
207
+ * observes an adapter exception, `mapError` may return a catalog
208
+ * `SyncError` (including structured `details`, e.g. `retryAfterMs`) that
209
+ * the adapter answers instead of `sync.internal_error`; `undefined` keeps
210
+ * the default. A throw, a non-`SyncError` return, or malformed `details`
211
+ * JSON is contained as `sync.internal_error`. Unlike `onError`, this hook
212
+ * may affect the response. It does not change the remote-operation
213
+ * `operation.*` envelope.
214
+ */
215
+ readonly mapError?: SyncularErrorMapper;
206
216
  }
207
217
 
208
218
  /** Per-request context: the config plus host-authenticated identity. */
package/src/d1-storage.ts CHANGED
@@ -68,6 +68,12 @@ import {
68
68
  rewriteRowSql,
69
69
  rewriteValues,
70
70
  SCHEMA_META_DDL_SQLITE,
71
+ SYNC_COLUMN_VERSIONS_COLUMN,
72
+ SYNC_PARTITION_COLUMN,
73
+ SYNC_PAYLOAD_COLUMN,
74
+ SYNC_ROW_ID_COLUMN,
75
+ SYNC_SCOPES_COLUMN,
76
+ SYNC_VERSION_COLUMN,
71
77
  type StoredColumnLayout,
72
78
  scanRowPageSql,
73
79
  schemaDdl,
@@ -86,10 +92,12 @@ import {
86
92
  collectCommitWindowPage,
87
93
  deserializePushResult,
88
94
  placeholders,
95
+ type SqliteChangeRecord,
89
96
  type SqliteCommitWindowRecord,
90
97
  type SqliteRowRecord,
91
98
  serializePushResult,
92
99
  sqliteDdlStatements,
100
+ toStoredChange,
93
101
  toStoredRow,
94
102
  } from './sqlite-dialect';
95
103
  import type {
@@ -97,8 +105,11 @@ import type {
97
105
  AuthoritativeQueryResult,
98
106
  CheckpointDeclaration,
99
107
  ClientCursorInfo,
108
+ ClientPushResult,
100
109
  ClientRecord,
101
110
  ClientSubscription,
111
+ CommitChangeSummary,
112
+ CommitDetail,
102
113
  CommitMetadata,
103
114
  CommitMetadataQuery,
104
115
  CommitWindowQuery,
@@ -113,6 +124,9 @@ import type {
113
124
  ReactionFailureUpdate,
114
125
  ReactionListQuery,
115
126
  ReactionPruneQuery,
127
+ RowChangeRecord,
128
+ RowChangesQuery,
129
+ RowListQuery,
116
130
  RowScanQuery,
117
131
  ScopeActivityQuery,
118
132
  ScopeCommitActivity,
@@ -660,13 +674,19 @@ class D1Transaction implements StorageTransaction {
660
674
  row,
661
675
  });
662
676
  const p = this.#partition;
663
- this.#buffer_(deleteSqliteRowScopesSql(compiled), [
664
- p,
665
- table,
666
- row.rowId,
667
- p,
668
- row.rowId,
669
- ]);
677
+ // Scope index maintenance: delete only the old keys the new scope map no
678
+ // longer carries, then insert the new keys. Keys present in both are left
679
+ // alone, so the `INSERT OR IGNORE` below finds them and writes nothing.
680
+ // This DELETE precedes the row upsert in the same atomic batch, and its
681
+ // subquery reads the row's pre-upsert scope map.
682
+ const scopesJson = JSON.stringify(row.scopes);
683
+ this.#buffer_(
684
+ `${deleteSqliteRowScopesSql(compiled)}
685
+ AND (var, value) NOT IN (
686
+ SELECT key, value FROM json_each(?)
687
+ )`,
688
+ [p, table, row.rowId, p, row.rowId, scopesJson],
689
+ );
670
690
  this.#buffer_(
671
691
  upsertSql(compiled, 'sqlite'),
672
692
  upsertValues(compiled, p, row, 'sqlite'),
@@ -2310,9 +2330,18 @@ export class D1ServerStorage implements ServerStorage {
2310
2330
  partition: string,
2311
2331
  record: ClientRecord,
2312
2332
  ): Promise<void> {
2333
+ // Update the existing record in place, so the table keeps one row per
2334
+ // (partition, client_id). Every non-key field is written from the new
2335
+ // record; a delete-and-insert would drop and recreate the row instead.
2313
2336
  await this.#db
2314
2337
  .prepare(
2315
- 'INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)',
2338
+ `INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)
2339
+ ON CONFLICT(partition, client_id) DO UPDATE SET
2340
+ actor_id=excluded.actor_id,
2341
+ wire_version=excluded.wire_version,
2342
+ cursor=excluded.cursor,
2343
+ subscriptions=excluded.subscriptions,
2344
+ updated_at_ms=excluded.updated_at_ms`,
2316
2345
  )
2317
2346
  .bind(
2318
2347
  partition,
@@ -2464,17 +2493,22 @@ export class D1ServerStorage implements ServerStorage {
2464
2493
  partition: string,
2465
2494
  query: CommitMetadataQuery,
2466
2495
  ): Promise<CommitMetadata[]> {
2496
+ // Newest-first window, resumed by `afterSeq` (exclusive lower bound) and
2497
+ // ended by `beforeSeq` (exclusive upper bound) when paging older. An
2498
+ // omitted bound is the largest safe commitSeq, so one statement serves
2499
+ // both the bounded and unbounded read.
2500
+ const beforeSeq = query.beforeSeq ?? Number.MAX_SAFE_INTEGER;
2467
2501
  const { results: rows } = query.table
2468
2502
  ? await this.#db
2469
2503
  .prepare(
2470
2504
  `SELECT c.commit_seq, c.client_id, c.client_commit_id, c.actor_id, c.created_at_ms
2471
2505
  FROM sync_commits c
2472
- WHERE c.partition=? AND c.commit_seq>?
2506
+ WHERE c.partition=? AND c.commit_seq>? AND c.commit_seq<?
2473
2507
  AND EXISTS (SELECT 1 FROM sync_changes ch
2474
2508
  WHERE ch.partition=c.partition AND ch.commit_seq=c.commit_seq AND ch.tbl=?)
2475
2509
  ORDER BY c.commit_seq DESC LIMIT ?`,
2476
2510
  )
2477
- .bind(partition, query.afterSeq, query.table, query.limit)
2511
+ .bind(partition, query.afterSeq, beforeSeq, query.table, query.limit)
2478
2512
  .all<{
2479
2513
  commit_seq: number;
2480
2514
  client_id: string;
@@ -2486,10 +2520,10 @@ export class D1ServerStorage implements ServerStorage {
2486
2520
  .prepare(
2487
2521
  `SELECT commit_seq, client_id, client_commit_id, actor_id, created_at_ms
2488
2522
  FROM sync_commits
2489
- WHERE partition=? AND commit_seq>?
2523
+ WHERE partition=? AND commit_seq>? AND commit_seq<?
2490
2524
  ORDER BY commit_seq DESC LIMIT ?`,
2491
2525
  )
2492
- .bind(partition, query.afterSeq, query.limit)
2526
+ .bind(partition, query.afterSeq, beforeSeq, query.limit)
2493
2527
  .all<{
2494
2528
  commit_seq: number;
2495
2529
  client_id: string;
@@ -2498,6 +2532,46 @@ export class D1ServerStorage implements ServerStorage {
2498
2532
  created_at_ms: number;
2499
2533
  }>();
2500
2534
  const out: CommitMetadata[] = [];
2535
+ // `changeSample`: extra statement(s) for the whole page — the first
2536
+ // `sample` changes (change-index order) of every listed commit.
2537
+ const sampleCount =
2538
+ query.changeSample !== undefined &&
2539
+ Number.isInteger(query.changeSample) &&
2540
+ query.changeSample > 0
2541
+ ? query.changeSample
2542
+ : 0;
2543
+ const samples = new Map<number, CommitChangeSummary[]>();
2544
+ if (sampleCount > 0 && rows.length > 0) {
2545
+ const seqs = rows.map((row) => row.commit_seq);
2546
+ // D1 caps the bound parameters of one statement, so a page larger than
2547
+ // that cap splits into a few chunk statements, never one per commit.
2548
+ const chunkSize = D1_MAX_BIND_PARAMS - 2;
2549
+ for (let offset = 0; offset < seqs.length; offset += chunkSize) {
2550
+ const chunk = seqs.slice(offset, offset + chunkSize);
2551
+ const { results: changes } = await this.#db
2552
+ .prepare(
2553
+ `SELECT commit_seq, tbl, row_id, op FROM sync_changes
2554
+ WHERE partition=? AND commit_seq IN (${placeholders(chunk.length)}) AND idx<?
2555
+ ORDER BY commit_seq, idx`,
2556
+ )
2557
+ .bind(partition, ...chunk, sampleCount)
2558
+ .all<{
2559
+ commit_seq: number;
2560
+ tbl: string;
2561
+ row_id: string;
2562
+ op: number;
2563
+ }>();
2564
+ for (const change of changes) {
2565
+ const sample = samples.get(change.commit_seq) ?? [];
2566
+ sample.push({
2567
+ table: change.tbl,
2568
+ rowId: change.row_id,
2569
+ op: change.op === 1 ? 'upsert' : 'delete',
2570
+ });
2571
+ samples.set(change.commit_seq, sample);
2572
+ }
2573
+ }
2574
+ }
2501
2575
  for (const row of rows) {
2502
2576
  const { results: changes } = await this.#db
2503
2577
  .prepare(
@@ -2513,11 +2587,135 @@ export class D1ServerStorage implements ServerStorage {
2513
2587
  createdAtMs: row.created_at_ms,
2514
2588
  changeCount: changes.reduce((sum, c) => sum + c.n, 0),
2515
2589
  tables: changes.map((c) => c.tbl),
2590
+ ...(sampleCount > 0
2591
+ ? { sample: samples.get(row.commit_seq) ?? [] }
2592
+ : {}),
2516
2593
  });
2517
2594
  }
2518
2595
  return out;
2519
2596
  }
2520
2597
 
2598
+ async getCommit(
2599
+ partition: string,
2600
+ commitSeq: number,
2601
+ ): Promise<CommitDetail | undefined> {
2602
+ const meta = await this.#db
2603
+ .prepare(
2604
+ 'SELECT client_id, client_commit_id, actor_id, created_at_ms FROM sync_commits WHERE partition=? AND commit_seq=?',
2605
+ )
2606
+ .bind(partition, commitSeq)
2607
+ .first<{
2608
+ client_id: string;
2609
+ client_commit_id: string;
2610
+ actor_id: string;
2611
+ created_at_ms: number;
2612
+ }>();
2613
+ if (meta === null) return undefined;
2614
+ const { results: changeRows } = await this.#db
2615
+ .prepare(
2616
+ 'SELECT tbl, row_id, op, row_version, scopes, payload FROM sync_changes WHERE partition=? AND commit_seq=? ORDER BY idx',
2617
+ )
2618
+ .bind(partition, commitSeq)
2619
+ .all<SqliteChangeRecord>();
2620
+ const changes = changeRows.map(toStoredChange);
2621
+ return {
2622
+ commitSeq,
2623
+ clientId: meta.client_id,
2624
+ clientCommitId: meta.client_commit_id,
2625
+ actorId: meta.actor_id,
2626
+ createdAtMs: meta.created_at_ms,
2627
+ changeCount: changes.length,
2628
+ tables: [...new Set(changes.map((change) => change.table))].sort(),
2629
+ changes,
2630
+ };
2631
+ }
2632
+
2633
+ async listRowChanges(
2634
+ partition: string,
2635
+ query: RowChangesQuery,
2636
+ ): Promise<RowChangeRecord[]> {
2637
+ // Retained-log read for one row: no dedicated index (the write path pays
2638
+ // nothing for it), bounded by retention (§4.6).
2639
+ const { results: rows } = await this.#db
2640
+ .prepare(
2641
+ `SELECT ch.commit_seq, ch.op, ch.row_version, c.actor_id, c.client_id, c.created_at_ms
2642
+ FROM sync_changes ch
2643
+ JOIN sync_commits c
2644
+ ON c.partition=ch.partition AND c.commit_seq=ch.commit_seq
2645
+ WHERE ch.partition=? AND ch.tbl=? AND ch.row_id=?
2646
+ ORDER BY ch.commit_seq DESC, ch.idx DESC LIMIT ?`,
2647
+ )
2648
+ .bind(partition, query.table, query.rowId, query.limit)
2649
+ .all<{
2650
+ commit_seq: number;
2651
+ op: number;
2652
+ row_version: number | null;
2653
+ actor_id: string;
2654
+ client_id: string;
2655
+ created_at_ms: number;
2656
+ }>();
2657
+ return rows.map((row) => ({
2658
+ commitSeq: row.commit_seq,
2659
+ op: row.op === 1 ? 'upsert' : 'delete',
2660
+ ...(row.row_version !== null ? { rowVersion: row.row_version } : {}),
2661
+ actorId: row.actor_id,
2662
+ clientId: row.client_id,
2663
+ createdAtMs: row.created_at_ms,
2664
+ }));
2665
+ }
2666
+
2667
+ async listRows(partition: string, query: RowListQuery): Promise<StoredRow[]> {
2668
+ // Keyset page over the row table's (partition, rowId) primary key: an
2669
+ // index range read, no scope filter, no per-row lookup.
2670
+ const { results } = await this.#schemaDatabase()
2671
+ .prepare(
2672
+ `SELECT ${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, ${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, ${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, ${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, ${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions
2673
+ FROM ${quoteIdent(this.table(query.table).name)}
2674
+ WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=? AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}>?
2675
+ ORDER BY ${quoteIdent(SYNC_ROW_ID_COLUMN)} LIMIT ?`,
2676
+ )
2677
+ .bind(partition, query.afterRowId ?? '', query.limit)
2678
+ .all<SqliteRowRecord>();
2679
+ return results.map(toStoredRow);
2680
+ }
2681
+
2682
+ async countRows(partition: string, table: string): Promise<number> {
2683
+ const record = await this.#schemaDatabase()
2684
+ .prepare(
2685
+ `SELECT count(*) AS n FROM ${quoteIdent(this.table(table).name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=?`,
2686
+ )
2687
+ .bind(partition)
2688
+ .first<{ n: number }>();
2689
+ return record?.n ?? 0;
2690
+ }
2691
+
2692
+ async listPushResults(
2693
+ partition: string,
2694
+ clientCommitId: string,
2695
+ ): Promise<ClientPushResult[]> {
2696
+ // Retained outcomes for one clientCommitId: no dedicated index, ordered
2697
+ // by client so the read is deterministic.
2698
+ const { results } = await this.#schemaDatabase()
2699
+ .prepare(
2700
+ 'SELECT client_id, result FROM sync_push_results WHERE partition=? AND client_commit_id=? ORDER BY client_id',
2701
+ )
2702
+ .bind(partition, clientCommitId)
2703
+ .all<{ client_id: string; result: string }>();
2704
+ return results.map((record) => {
2705
+ try {
2706
+ return {
2707
+ clientId: record.client_id,
2708
+ result: deserializePushResult(record.result),
2709
+ };
2710
+ } catch {
2711
+ throw syncError(
2712
+ 'sync.idempotency_cache_miss',
2713
+ 'persisted push result unreadable (§6.3)',
2714
+ );
2715
+ }
2716
+ });
2717
+ }
2718
+
2521
2719
  async scopeActivity(
2522
2720
  partition: string,
2523
2721
  query: ScopeActivityQuery,
@@ -2556,25 +2754,6 @@ export class D1ServerStorage implements ServerStorage {
2556
2754
  return out;
2557
2755
  }
2558
2756
 
2559
- async getRowScopes(
2560
- partition: string,
2561
- table: string,
2562
- rowId: string,
2563
- ): Promise<
2564
- { serverVersion: number; scopes: Record<string, string> } | undefined
2565
- > {
2566
- const db = this.#schemaDatabase();
2567
- const record = await db
2568
- .prepare(selectRowScopesSql(this.table(table), 'sqlite'))
2569
- .bind(partition, rowId)
2570
- .first<{ server_version: number; scopes: string }>();
2571
- if (record === null) return undefined;
2572
- return {
2573
- serverVersion: record.server_version,
2574
- scopes: JSON.parse(record.scopes) as Record<string, string>,
2575
- };
2576
- }
2577
-
2578
2757
  async listPartitions(): Promise<string[]> {
2579
2758
  return (await this.listPartitionRegistry()).map((entry) => entry.partition);
2580
2759
  }
package/src/errors.ts CHANGED
@@ -282,10 +282,10 @@ export class SyncError extends Error {
282
282
  readonly details?: string;
283
283
 
284
284
  constructor(code: string, message?: string, details?: string) {
285
- const entry = ERROR_CATALOG[code];
286
- if (entry === undefined) {
285
+ if (!Object.hasOwn(ERROR_CATALOG, code)) {
287
286
  throw new Error(`unknown error code ${code} (not in the §10.2 catalog)`);
288
287
  }
288
+ const entry = ERROR_CATALOG[code]!;
289
289
  super(message ?? code);
290
290
  this.code = code;
291
291
  this.category = entry.category;
@@ -344,19 +344,59 @@ export type SyncularErrorHandler = (
344
344
  context: { readonly route: SyncularErrorRoute },
345
345
  ) => void;
346
346
 
347
+ /**
348
+ * Optional synchronous error mapper. After `onError` has observed the
349
+ * original, `mapError` may translate an adapter exception to a catalog
350
+ * `SyncError` (with `details` metadata such as `retryAfterMs`) that the
351
+ * adapter answers instead of `sync.internal_error`. Return `undefined` to
352
+ * keep the default. A throw, a non-`SyncError` return, or `details` that is
353
+ * not a JSON document is contained as `sync.internal_error`.
354
+ */
355
+ export type SyncularErrorMapper = (
356
+ error: unknown,
357
+ context: { readonly route: SyncularErrorRoute },
358
+ ) => SyncError | undefined;
359
+
347
360
  /**
348
361
  * The protocol error for an exception an adapter caught. A `SyncError`
349
- * passes through unchanged. Anything else goes to `onError` and becomes
350
- * `sync.internal_error` (§10.2), whose message never carries the exception
351
- * text.
362
+ * passes through unchanged. Anything else goes to `onError` and answers
363
+ * `sync.internal_error` (§10.2) unless the optional `mapError` hook returns a
364
+ * catalog `SyncError` to answer instead. The default `sync.internal_error`
365
+ * message never carries the exception text; a mapped error's message is the
366
+ * host's choice.
352
367
  */
353
368
  export function adapterSyncError(
354
369
  error: unknown,
355
370
  onError: SyncularErrorHandler | undefined,
356
371
  route: SyncularErrorRoute,
372
+ mapError?: SyncularErrorMapper,
357
373
  ): SyncError {
358
374
  if (error instanceof SyncError) return error;
359
375
  reportError(error, onError, route);
376
+ try {
377
+ const mapped = mapError?.(error, { route });
378
+ if (mapped instanceof SyncError) {
379
+ const { code, message, details } = mapped;
380
+ let detailsValid = details === undefined;
381
+ if (!detailsValid && typeof details === 'string') {
382
+ JSON.parse(details);
383
+ detailsValid = true;
384
+ }
385
+ if (
386
+ typeof code === 'string' &&
387
+ typeof message === 'string' &&
388
+ Object.hasOwn(ERROR_CATALOG, code) &&
389
+ detailsValid
390
+ ) {
391
+ // Reconstruct from the validated fields: the catalog owns
392
+ // retryability and status, so a mutated `SyncError` cannot smuggle a
393
+ // different entry onto the wire.
394
+ return new SyncError(code, message, details);
395
+ }
396
+ }
397
+ } catch {
398
+ // A throwing mapper or getter, or malformed details, keeps the default.
399
+ }
360
400
  return new SyncError('sync.internal_error', 'internal server error');
361
401
  }
362
402
 
@@ -3,8 +3,8 @@
3
3
  * without any infrastructure dependency.
4
4
  *
5
5
  * `RingBufferEvents` is a `SyncularServerEvents` sink that retains the last
6
- * N events in memory and exposes a `query({type?, sinceMs?, clientId?,
7
- * actorId?, limit})` plus a `subscribe(listener)` hook for live tails. It
6
+ * N events in memory and exposes a `query({type?, partition?, sinceMs?,
7
+ * clientId?, actorId?, limit})` plus a `subscribe(listener)` hook for live tails. It
8
8
  * composes with any other sink through `composeEvents(...sinks)`, so a host
9
9
  * can keep `consoleJsonEvents()` (or a Sentry adapter) AND feed the console
10
10
  * event tail from the same emissions. Fire-and-forget discipline is
@@ -20,6 +20,10 @@ import {
20
20
  export interface RingEventQuery {
21
21
  /** Restrict to one event `type` (e.g. `push.applied`). */
22
22
  readonly type?: SyncularServerEvent['type'];
23
+ /** Restrict to one partition (every event carries one). */
24
+ readonly partition?: string;
25
+ /** Restrict to the events of one sync request. */
26
+ readonly requestId?: string;
23
27
  /** Only events with `atMs >= sinceMs`. */
24
28
  readonly sinceMs?: number;
25
29
  /** Restrict to events carrying this `clientId` (not every type does). */
@@ -41,6 +45,12 @@ export function matchesRingQuery(
41
45
  query: Omit<RingEventQuery, 'limit'>,
42
46
  ): boolean {
43
47
  if (query.type !== undefined && event.type !== query.type) return false;
48
+ if (query.partition !== undefined && event.partition !== query.partition) {
49
+ return false;
50
+ }
51
+ if (query.requestId !== undefined && event.requestId !== query.requestId) {
52
+ return false;
53
+ }
44
54
  if (query.sinceMs !== undefined && event.atMs < query.sinceMs) return false;
45
55
  const carrier = event as { clientId?: string; actorId?: string };
46
56
  if (query.clientId !== undefined && carrier.clientId !== query.clientId) {
@@ -67,6 +77,7 @@ export class RingBufferEvents implements SyncularServerEvents {
67
77
  /** Index of the oldest element in the circular buffer. */
68
78
  #head = 0;
69
79
  #size = 0;
80
+ #evictedThroughMs: number | undefined;
70
81
  /** Live subscribers (the SSE tail). Each notify is guarded. */
71
82
  readonly #listeners = new Set<(event: SyncularServerEvent) => void>();
72
83
 
@@ -84,6 +95,8 @@ export class RingBufferEvents implements SyncularServerEvents {
84
95
  this.#size += 1;
85
96
  } else {
86
97
  // Full: overwrite the oldest and advance the head.
98
+ const evicted = this.#buffer[this.#head];
99
+ if (evicted !== undefined) this.#markEvicted(evicted.atMs);
87
100
  this.#buffer[this.#head] = event;
88
101
  this.#head = (this.#head + 1) % this.#capacity;
89
102
  }
@@ -119,6 +132,21 @@ export class RingBufferEvents implements SyncularServerEvents {
119
132
  return this.#size;
120
133
  }
121
134
 
135
+ /**
136
+ * The newest `atMs` among events the ring dropped (evicted when full, or
137
+ * cleared); `undefined` while nothing was dropped. A window that starts
138
+ * at or before it is incomplete.
139
+ */
140
+ get evictedThroughMs(): number | undefined {
141
+ return this.#evictedThroughMs;
142
+ }
143
+
144
+ #markEvicted(atMs: number): void {
145
+ if (this.#evictedThroughMs === undefined || atMs > this.#evictedThroughMs) {
146
+ this.#evictedThroughMs = atMs;
147
+ }
148
+ }
149
+
122
150
  /**
123
151
  * Newest-first slice of the retained events. Filters by `type` and
124
152
  * `sinceMs`, then caps to `limit`. Returns a fresh array — the caller may
@@ -139,6 +167,7 @@ export class RingBufferEvents implements SyncularServerEvents {
139
167
 
140
168
  /** Drop all retained events. */
141
169
  clear(): void {
170
+ for (const event of this.#buffer) this.#markEvicted(event.atMs);
142
171
  this.#buffer = [];
143
172
  this.#head = 0;
144
173
  this.#size = 0;
package/src/events.ts CHANGED
@@ -29,11 +29,21 @@ export interface RequestHandledEvent {
29
29
  /**
30
30
  * `ok` — response streamed to END;
31
31
  * `schema_floor` — §2.4 required-schema answer;
32
- * `reset` — §2.1 log-epoch reset answer;
32
+ * `epoch_acquired` — §2.1 answer to a request without `logEpoch` (a new
33
+ * or wiped replica's first round);
34
+ * `reset` — §2.1 answer to a `logEpoch` that no longer matches the log;
33
35
  * `rejected` — request validation failed before any bytes (§1.7);
34
36
  * `error` — in-band ERROR frame (§1.6) or a thrown host failure.
35
37
  */
36
- readonly outcome: 'ok' | 'schema_floor' | 'reset' | 'rejected' | 'error';
38
+ readonly outcome:
39
+ | 'ok'
40
+ | 'schema_floor'
41
+ | 'epoch_acquired'
42
+ | 'reset'
43
+ | 'rejected'
44
+ | 'error';
45
+ /** The REQ_HEADER clientId; absent when the request failed to decode. */
46
+ readonly clientId?: string;
37
47
  /** §10.2 code for `rejected`/`error`; `"internal"` for non-SyncErrors. */
38
48
  readonly errorCode?: string;
39
49
  readonly pushCommits: number;
@@ -64,6 +74,9 @@ export interface PushRejectedEvent extends PushEventBase {
64
74
  readonly type: 'push.rejected';
65
75
  readonly code: string;
66
76
  readonly opIndex: number;
77
+ /** The terminating operation's table and rowId. */
78
+ readonly table?: string;
79
+ readonly rowId?: string;
67
80
  /** True when the rejection was replayed from the idempotency cache. */
68
81
  readonly replay: boolean;
69
82
  /** Original host time for outcomes recorded by a metadata-aware server. */
@@ -76,6 +89,9 @@ export interface PushRejectedEvent extends PushEventBase {
76
89
  export interface PushConflictedEvent extends PushEventBase {
77
90
  readonly type: 'push.conflicted';
78
91
  readonly opIndex: number;
92
+ /** The conflicting operation's table and rowId. */
93
+ readonly table?: string;
94
+ readonly rowId?: string;
79
95
  /** True when the conflict was replayed from the idempotency cache. */
80
96
  readonly replay: boolean;
81
97
  readonly recordedAtMs?: number;
@@ -306,7 +322,13 @@ export interface LeaseRevokedEvent {
306
322
  readonly leaseId: string;
307
323
  }
308
324
 
309
- export type SyncularServerEvent =
325
+ /**
326
+ * Every event. `requestId` is present on the events emitted while serving
327
+ * one sync request (`request.handled`, `push.*`, `pull.served`,
328
+ * `lease.issued`, `reaction.queued`, `scopes.resolve_failed`) and shared by
329
+ * all of them, so a sink can group one request's events.
330
+ */
331
+ export type SyncularServerEvent = (
310
332
  | RequestHandledEvent
311
333
  | PushAppliedEvent
312
334
  | PushRejectedEvent
@@ -329,7 +351,8 @@ export type SyncularServerEvent =
329
351
  | PruneCompletedEvent
330
352
  | ScopesResolveFailedEvent
331
353
  | LeaseIssuedEvent
332
- | LeaseRevokedEvent;
354
+ | LeaseRevokedEvent
355
+ ) & { readonly requestId?: string };
333
356
 
334
357
  /**
335
358
  * The seam. Optional on the server config — when absent, no event object
package/src/handler.ts CHANGED
@@ -373,7 +373,7 @@ async function planRequest(
373
373
 
374
374
  /** Mutable outcome box shared with the instrumented stream wrapper. */
375
375
  interface RequestReport {
376
- outcome: 'ok' | 'schema_floor' | 'reset' | 'error';
376
+ outcome: 'ok' | 'schema_floor' | 'epoch_acquired' | 'reset' | 'error';
377
377
  errorCode?: string;
378
378
  }
379
379
 
@@ -421,11 +421,14 @@ function emitPushEvent(
421
421
  }
422
422
  // Rejected (§6.3): exactly one terminating operation record.
423
423
  const record = frame.results[0];
424
+ const op = record !== undefined ? push.operations[record.opIndex] : undefined;
425
+ const target = op !== undefined ? { table: op.table, rowId: op.rowId } : {};
424
426
  if (record?.status === 'conflict') {
425
427
  emitEvent(events, {
426
428
  type: 'push.conflicted',
427
429
  ...base,
428
430
  opIndex: record.opIndex,
431
+ ...target,
429
432
  replay: processed.replayed,
430
433
  ...(processed.recordedAtMs !== undefined
431
434
  ? { recordedAtMs: processed.recordedAtMs }
@@ -444,6 +447,7 @@ function emitPushEvent(
444
447
  ? record.code
445
448
  : 'sync.invalid_request',
446
449
  opIndex: record?.opIndex ?? 0,
450
+ ...target,
447
451
  replay: processed.replayed,
448
452
  ...(processed.recordedAtMs !== undefined
449
453
  ? { recordedAtMs: processed.recordedAtMs }
@@ -491,7 +495,10 @@ async function* streamResponse(
491
495
  plan.wireVersion,
492
496
  );
493
497
  if (plan.epochReset) {
494
- if (report !== undefined) report.outcome = 'reset';
498
+ if (report !== undefined) {
499
+ report.outcome =
500
+ plan.header.logEpoch === undefined ? 'epoch_acquired' : 'reset';
501
+ }
495
502
  yield END_FRAME_BYTES;
496
503
  return;
497
504
  }
@@ -729,14 +736,20 @@ export async function createSyncResponseStream(
729
736
  bytes: Uint8Array,
730
737
  ctx: SyncRequestContext,
731
738
  ): Promise<AsyncIterable<Uint8Array>> {
732
- const events = ctx.events;
733
- if (events === undefined) return createStreamCore(bytes, ctx, undefined);
739
+ const sink = ctx.events;
740
+ if (sink === undefined) return createStreamCore(bytes, ctx, undefined);
734
741
  // Instrumented path: measure with the ctx clock, count bytes at the
735
- // seam, emit exactly one `request.handled` per request.
742
+ // seam, emit exactly one `request.handled` per request. Every event of
743
+ // this request carries one `requestId`.
744
+ const requestId = crypto.randomUUID();
745
+ const events: SyncularServerEvents = {
746
+ emit: (event) => sink.emit({ ...event, requestId }),
747
+ };
748
+ const requestCtx = { ...ctx, events };
736
749
  const clock = clockOf(ctx);
737
750
  const startedAtMs = clock();
738
751
  try {
739
- return await createStreamCore(bytes, ctx, events, startedAtMs);
752
+ return await createStreamCore(bytes, requestCtx, events, startedAtMs);
740
753
  } catch (error) {
741
754
  emitEvent(events, {
742
755
  type: 'request.handled',
@@ -878,6 +891,7 @@ async function* instrumentedStream(
878
891
  bytesIn,
879
892
  bytesOut,
880
893
  outcome: report.outcome,
894
+ clientId: plan.header.clientId,
881
895
  ...(report.errorCode !== undefined
882
896
  ? { errorCode: report.errorCode }
883
897
  : {}),