@syncular/server 0.31.0 → 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/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,
@@ -2479,17 +2493,22 @@ export class D1ServerStorage implements ServerStorage {
2479
2493
  partition: string,
2480
2494
  query: CommitMetadataQuery,
2481
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;
2482
2501
  const { results: rows } = query.table
2483
2502
  ? await this.#db
2484
2503
  .prepare(
2485
2504
  `SELECT c.commit_seq, c.client_id, c.client_commit_id, c.actor_id, c.created_at_ms
2486
2505
  FROM sync_commits c
2487
- WHERE c.partition=? AND c.commit_seq>?
2506
+ WHERE c.partition=? AND c.commit_seq>? AND c.commit_seq<?
2488
2507
  AND EXISTS (SELECT 1 FROM sync_changes ch
2489
2508
  WHERE ch.partition=c.partition AND ch.commit_seq=c.commit_seq AND ch.tbl=?)
2490
2509
  ORDER BY c.commit_seq DESC LIMIT ?`,
2491
2510
  )
2492
- .bind(partition, query.afterSeq, query.table, query.limit)
2511
+ .bind(partition, query.afterSeq, beforeSeq, query.table, query.limit)
2493
2512
  .all<{
2494
2513
  commit_seq: number;
2495
2514
  client_id: string;
@@ -2501,10 +2520,10 @@ export class D1ServerStorage implements ServerStorage {
2501
2520
  .prepare(
2502
2521
  `SELECT commit_seq, client_id, client_commit_id, actor_id, created_at_ms
2503
2522
  FROM sync_commits
2504
- WHERE partition=? AND commit_seq>?
2523
+ WHERE partition=? AND commit_seq>? AND commit_seq<?
2505
2524
  ORDER BY commit_seq DESC LIMIT ?`,
2506
2525
  )
2507
- .bind(partition, query.afterSeq, query.limit)
2526
+ .bind(partition, query.afterSeq, beforeSeq, query.limit)
2508
2527
  .all<{
2509
2528
  commit_seq: number;
2510
2529
  client_id: string;
@@ -2513,6 +2532,46 @@ export class D1ServerStorage implements ServerStorage {
2513
2532
  created_at_ms: number;
2514
2533
  }>();
2515
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
+ }
2516
2575
  for (const row of rows) {
2517
2576
  const { results: changes } = await this.#db
2518
2577
  .prepare(
@@ -2528,11 +2587,135 @@ export class D1ServerStorage implements ServerStorage {
2528
2587
  createdAtMs: row.created_at_ms,
2529
2588
  changeCount: changes.reduce((sum, c) => sum + c.n, 0),
2530
2589
  tables: changes.map((c) => c.tbl),
2590
+ ...(sampleCount > 0
2591
+ ? { sample: samples.get(row.commit_seq) ?? [] }
2592
+ : {}),
2531
2593
  });
2532
2594
  }
2533
2595
  return out;
2534
2596
  }
2535
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
+
2536
2719
  async scopeActivity(
2537
2720
  partition: string,
2538
2721
  query: ScopeActivityQuery,
@@ -2571,25 +2754,6 @@ export class D1ServerStorage implements ServerStorage {
2571
2754
  return out;
2572
2755
  }
2573
2756
 
2574
- async getRowScopes(
2575
- partition: string,
2576
- table: string,
2577
- rowId: string,
2578
- ): Promise<
2579
- { serverVersion: number; scopes: Record<string, string> } | undefined
2580
- > {
2581
- const db = this.#schemaDatabase();
2582
- const record = await db
2583
- .prepare(selectRowScopesSql(this.table(table), 'sqlite'))
2584
- .bind(partition, rowId)
2585
- .first<{ server_version: number; scopes: string }>();
2586
- if (record === null) return undefined;
2587
- return {
2588
- serverVersion: record.server_version,
2589
- scopes: JSON.parse(record.scopes) as Record<string, string>,
2590
- };
2591
- }
2592
-
2593
2757
  async listPartitions(): Promise<string[]> {
2594
2758
  return (await this.listPartitionRegistry()).map((entry) => entry.partition);
2595
2759
  }
@@ -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
  : {}),