@moqtap/trace 0.3.0 → 0.4.2

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/binary.ts CHANGED
@@ -2,13 +2,8 @@ import { Encoder } from 'cbor-x'
2
2
  import { cborItemLength, MalformedCborError } from './cbor-scan.js'
3
3
  import type {
4
4
  DerivationKind,
5
- DetailLevel,
6
- DropPolicy,
7
- PeerRole,
8
- Perspective,
9
5
  SamplingInfo,
10
6
  SegmentInfo,
11
- Side,
12
7
  SubscriptionRef,
13
8
  Trace,
14
9
  TraceEvent,
@@ -30,11 +25,32 @@ import { TRACE_ID_LENGTH } from './types.js'
30
25
  // every payload-bearing field this package wrote: raw wire bytes, object
31
26
  // payloads, track names, trace ids. Readers still accept the tagged form,
32
27
  // because files carrying it already exist.
28
+ //
29
+ // `mapsAsObjects` has one cost this package cannot pay off, and it belongs
30
+ // here, where the decoder is chosen, rather than where the damage shows.
31
+ // cbor-x runs every decoded map key through an internal `safeKey`, which
32
+ // rewrites the text key `"__proto__"` to `"__proto_"` and stringifies a
33
+ // non-text key. Both are silent, both happen before any code in this file
34
+ // runs, and either can collide with a real key of the rewritten name and lose
35
+ // a value: a map carrying both `"__proto__"` and `"__proto_"` decodes to one
36
+ // entry, and so does one carrying both `1` and `"1"`. There is no option for
37
+ // it — in cbor-x 1.6.4 `safeKey` is unconditional on every `mapsAsObjects:
38
+ // true` path, and the one decoder that skips it is `mapsAsObjects: false`,
39
+ // which hands back a `Map` for every map in the file and is a different reader
40
+ // from this one. So a store here faithfully re-emits the renamed key, and this
41
+ // reader writes `"__proto_"` where the file said `"__proto__"` — including
42
+ // inside `"custom"`, which it otherwise hands back key for key, and inside any
43
+ // preserved value at any depth. SPEC.md, "Shapes a CBOR library may normalise
44
+ // before you see them", part 3: the normalisation happens below this
45
+ // implementation, so the reader is not non-conformant and nothing may depend
46
+ // on the outcome — but it is not invisible either, and the Rust reader keeps
47
+ // the two keys apart. The *write* half of the same key is ours, and is not
48
+ // excused: see {@link setKey}.
33
49
  const codec = new Encoder({ useRecords: false, mapsAsObjects: true, tagUint8Array: false })
34
50
  const encode = (value: unknown): Uint8Array => codec.encode(value)
35
- const decode = (bytes: Uint8Array): unknown => codec.decode(bytes)
51
+ export const decode = (bytes: Uint8Array): unknown => codec.decode(bytes)
36
52
 
37
- const MAGIC = new Uint8Array([0x4d, 0x4f, 0x51, 0x54, 0x52, 0x41, 0x43, 0x45]) // "MOQTRACE"
53
+ export const MAGIC = new Uint8Array([0x4d, 0x4f, 0x51, 0x54, 0x52, 0x41, 0x43, 0x45]) // "MOQTRACE"
38
54
 
39
55
  /** Format version this package writes. */
40
56
  export const FORMAT_VERSION = 2
@@ -48,7 +64,7 @@ export const FORMAT_VERSION = 2
48
64
  */
49
65
  export const SUPPORTED_VERSIONS: readonly number[] = [1, FORMAT_VERSION]
50
66
 
51
- const PREAMBLE_SIZE = 16 // 8 magic + 4 version + 4 header length
67
+ export const PREAMBLE_SIZE = 16 // 8 magic + 4 version + 4 header length
52
68
 
53
69
  // Event type string ↔ integer mapping
54
70
  const EVENT_TYPE_TO_INT: Record<Exclude<TraceEvent['type'], 'unknown'>, number> = {
@@ -82,6 +98,97 @@ const INT_TO_EVENT_TYPE: Record<number, Exclude<TraceEvent['type'], 'unknown'>>
82
98
  /** Keys the common event fields own; everything else belongs to the variant. */
83
99
  const COMMON_EVENT_KEYS = new Set(['n', 't', 'p', 'e'])
84
100
 
101
+ /**
102
+ * One decoded map — an event, the header, or one of the header's inner maps —
103
+ * and the keys the decode has taken from it.
104
+ *
105
+ * Everything left over is a key this version does not recognise, and goes to
106
+ * `extra` rather than being dropped. "Left over" is narrower than "not named by
107
+ * this document", and the difference is the whole point: a defined key whose
108
+ * value is of a type the reader cannot use was not taken either, and is
109
+ * preserved exactly as a key nobody had heard of would be — SPEC.md,
110
+ * "A defined key whose value has an unusable type is treated as unrecognised."
111
+ *
112
+ * Only the decode writes to the set, so there is no separate list of owned keys
113
+ * to keep in step with it. A list would make adding a key to an event *reduce*
114
+ * what this reader preserves: a listed key counts as owned whatever it
115
+ * carries, so a wrong-typed one would be kept out of `extra` while the decode
116
+ * either threw on it, losing the whole file, or coerced it into a value the
117
+ * file never carried.
118
+ */
119
+ interface Decoding {
120
+ readonly obj: Record<string, unknown>
121
+ readonly used: Set<string>
122
+ }
123
+
124
+ /**
125
+ * Put a key carried by a file onto a plain object, as an own property.
126
+ *
127
+ * `target[key] = value` is not this, for one key in the whole of JavaScript.
128
+ * `__proto__` reaches the accessor `Object.prototype` defines, which sets the
129
+ * object's prototype instead of creating a property: `Object.hasOwn(target,
130
+ * '__proto__')` is then false, `Object.entries` does not list it, and the
131
+ * entry is gone. A caller whose store came from `JSON.parse('{"__proto__":
132
+ * "x"}')` — a real own property on the way in, since JSON has no such
133
+ * special case — therefore wrote a file with the key missing, silently, on
134
+ * the one code path whose entire purpose is preservation.
135
+ *
136
+ * `defineProperty` makes the own property for every key alike, and cbor-x
137
+ * encodes it: a store entry named `__proto__` reaches the file as the text key
138
+ * `"__proto__"`, which is what the Rust implementation writes for the same
139
+ * trace. Used for every key that comes from a file or from a store — never for
140
+ * a field name this format defines, which is a literal in the source and can
141
+ * be neither `__proto__` nor anything else surprising.
142
+ *
143
+ * This is only the write half. Reading `"__proto__"` back out of that file
144
+ * hands us `"__proto_"`, below this code and past helping — see the note on
145
+ * the codec above.
146
+ */
147
+ function setKey(target: Record<string, unknown>, key: string, value: unknown): void {
148
+ Object.defineProperty(target, key, {
149
+ value,
150
+ writable: true,
151
+ enumerable: true,
152
+ configurable: true,
153
+ })
154
+ }
155
+
156
+ /** Every key on a decoded map that the decode did not take. */
157
+ function unrecognisedKeys(src: Decoding): Record<string, unknown> | undefined {
158
+ let extra: Record<string, unknown> | undefined
159
+ for (const [key, value] of Object.entries(src.obj)) {
160
+ if (src.used.has(key)) continue
161
+ extra ??= {}
162
+ setKey(extra, key, value)
163
+ }
164
+ return extra
165
+ }
166
+
167
+ /**
168
+ * Write a map's unrecognised-key store back into it, after its own keys.
169
+ *
170
+ * Last, so the map's own keys keep the positions a reader expects and the file
171
+ * stays diffable against one written without a store. An entry naming a key the
172
+ * map already wrote is dropped: a CBOR map carrying one key twice is a map no
173
+ * two readers need agree on, and the field is what a reader produced.
174
+ *
175
+ * The test is what the map *wrote*, not which keys the format defines. A
176
+ * defined key whose value the reader could not use is held in the store and no
177
+ * field wrote it, so on a defined-key test it would be dropped here — silently
178
+ * undoing the preservation the read side had just performed.
179
+ */
180
+ function withStore(
181
+ map: Record<string, unknown>,
182
+ store: Record<string, unknown> | undefined,
183
+ ): Record<string, unknown> {
184
+ if (store == null) return map
185
+ for (const [key, value] of Object.entries(store)) {
186
+ if (Object.hasOwn(map, key)) continue
187
+ setKey(map, key, value)
188
+ }
189
+ return map
190
+ }
191
+
85
192
  /** The stream ended part-way through an item — the file was truncated. */
86
193
  export class TruncatedTraceError extends Error {
87
194
  /** Byte offset at which the incomplete item begins. */
@@ -106,23 +213,123 @@ export class TruncatedTraceError extends Error {
106
213
  }
107
214
  }
108
215
 
216
+ /**
217
+ * A header carried no usable value for a key there is no header without.
218
+ *
219
+ * Five keys reach here: the four the format requires — `"protocol"`,
220
+ * `"perspective"`, `"detail"`, `"startTime"` — and `"segment.sequence"`, the
221
+ * sole ordering key of a segmented stream. Every other key in the header is
222
+ * optional, and an optional key's value never fails a file: an unusable one
223
+ * goes to the store its map keeps and the read continues.
224
+ *
225
+ * The alternative is worse than the error. A reader that fills a missing
226
+ * required key with `undefined`, an empty string or `0` returns a header the
227
+ * file never carried, and nothing downstream can tell it from a real one — a
228
+ * fabricated trace reported as evidence. `"segment.sequence"` is the sharpest
229
+ * case: the default `0` turns a segment whose place in the stream is unknown
230
+ * into one that claims to be first.
231
+ *
232
+ * Malformed here means malformed for *that segment*. {@link ReadOptions.recover}
233
+ * skips it and carries on at the next one, on the same terms as a malformed
234
+ * event.
235
+ *
236
+ * The writer throws the same error for the same key, before it emits anything.
237
+ * `"startTime"` and `"segment.sequence"` are `number` fields and TypeScript
238
+ * admits `-5` and `1.5` into both, so a header built by hand could be written
239
+ * to a file this package's own reader then refused — a writer emitting files
240
+ * only some other tool can open, out of a caller mistake nothing reported at
241
+ * the point it was made. One error type for both directions because it is one
242
+ * fault about one key, and a read-modify-write catches it once.
243
+ */
244
+ export class MalformedHeaderError extends Error {
245
+ /** The key at fault, dotted for one inside `"segment"`. */
246
+ readonly key: string
247
+
248
+ /**
249
+ * @param key the key at fault
250
+ * @param fault what was wrong with it, as a predicate on the key
251
+ */
252
+ constructor(key: string, fault: string) {
253
+ // Absent and present-but-unusable are the same outcome — there is no
254
+ // header to construct either way — but they are different faults, and one
255
+ // message for both told whoever has to fix the file the wrong thing.
256
+ // "must be a text string" on a header that carries no `"protocol"` at all
257
+ // sends them looking at a value that is not there, and it is the message a
258
+ // person debugging a broken capture reads.
259
+ super(`Malformed header: ${JSON.stringify(key)} ${fault}`)
260
+ this.name = 'MalformedHeaderError'
261
+ this.key = key
262
+ }
263
+ }
264
+
109
265
  // --- Header serialization ---
110
266
 
267
+ /**
268
+ * Write a required header integer, or refuse to write the file.
269
+ *
270
+ * The writer's half of {@link requireHeaderUint}, and deliberately the same
271
+ * test: `"startTime"` and `"segment.sequence"` are the two header keys read as
272
+ * unsigned integers *and* required, so a negative or fractional one produces a
273
+ * file whose own reader throws `MalformedHeaderError` on it. This package
274
+ * emitting a trace it cannot open is a worse failure than a rejected call —
275
+ * the file is written, the process exits 0, and the fault surfaces wherever
276
+ * someone later tries to read it, if anyone does. The Rust implementation
277
+ * cannot reach the state at all: its field is a `u64`.
278
+ *
279
+ * It refuses only these two, and only the field. Every other integer in the
280
+ * header is optional, and a negative `"endTime"` written into one is read back
281
+ * into that map's store rather than failing the read — the value survives and
282
+ * so does the file, which is the contract the reader already states for an
283
+ * optional key. A store entry is never checked here either, for the reason
284
+ * SPEC.md gives for ranking a reader's obligations above a writer's: a value
285
+ * routed to a store is one the reader could not use and must hand back
286
+ * unchanged, and a writer that validated it would delete exactly what the
287
+ * store exists to carry. So a file that legitimately carries such a value can
288
+ * still be read, edited and written back — nothing that survives a read is
289
+ * refused on the way out.
290
+ */
291
+ function requiredUint(value: number, reportedAs: string): number | bigint {
292
+ if (!Number.isInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
293
+ throw new MalformedHeaderError(reportedAs, 'must be an unsigned integer')
294
+ }
295
+ return int(value)
296
+ }
297
+
111
298
  function segmentToCbor(segment: SegmentInfo): Record<string, unknown> {
112
- const map: Record<string, unknown> = { sequence: int(segment.sequence) }
299
+ const map: Record<string, unknown> = {
300
+ sequence: requiredUint(segment.sequence, 'segment.sequence'),
301
+ }
113
302
  if (segment.durationMs != null) map.durationMs = int(segment.durationMs)
114
303
  if (segment.streamId != null) map.streamId = segment.streamId
115
304
  if (segment.continues != null) map.continues = segment.continues
116
- return map
305
+ return withStore(map, segment.extra)
117
306
  }
118
307
 
119
- function cborToSegment(obj: Record<string, unknown>): SegmentInfo {
120
- return {
121
- sequence: Number(obj.sequence ?? 0),
122
- ...(obj.durationMs != null ? { durationMs: Number(obj.durationMs) } : {}),
123
- ...(obj.streamId != null ? { streamId: obj.streamId as string } : {}),
124
- ...(obj.continues != null ? { continues: obj.continues as boolean } : {}),
308
+ /**
309
+ * Read the `"segment"` map, or answer `undefined` for a value that is not one.
310
+ *
311
+ * `undefined` leaves the key untaken, so a `"segment"` that is not a map goes
312
+ * to the *header's* store and the trace reads as non-segmented — which is what
313
+ * the format asks for. Failing the file instead would turn one unreadable
314
+ * metadata value into the loss of every event behind it.
315
+ *
316
+ * `"sequence"` is the exception, and the only key inside here that can fail a
317
+ * header: it is the sole ordering key of a segmented stream, so a reader that
318
+ * cannot read it cannot place the segment, and defaulting it to `0` would
319
+ * invent an order the file never had.
320
+ */
321
+ function cborToSegment(value: unknown): SegmentInfo | undefined {
322
+ const obj = asMap(value)
323
+ if (obj == null) return undefined
324
+ const src: Decoding = { obj, used: new Set() }
325
+ const segment: SegmentInfo = {
326
+ sequence: requireHeaderUint(src, 'sequence', 'segment.sequence'),
327
+ ...optional(src, 'durationMs', 'durationMs', asUintNumber),
328
+ ...optional(src, 'streamId', 'streamId', asText),
329
+ ...optional(src, 'continues', 'continues', asBoolean),
125
330
  }
331
+ const extra = unrecognisedKeys(src)
332
+ return extra == null ? segment : { ...segment, extra }
126
333
  }
127
334
 
128
335
  function samplingToCbor(sampling: SamplingInfo): Record<string, unknown> {
@@ -135,20 +342,31 @@ function samplingToCbor(sampling: SamplingInfo): Record<string, unknown> {
135
342
  if (sampling.rule != null) map.rule = sampling.rule
136
343
  if (sampling.ruleLang != null) map.ruleLang = sampling.ruleLang
137
344
  if (sampling.appliesTo != null) map.appliesTo = sampling.appliesTo.map(int)
138
- return map
345
+ return withStore(map, sampling.extra)
139
346
  }
140
347
 
141
- function cborToSampling(obj: Record<string, unknown>): SamplingInfo {
142
- return {
143
- ...(obj.effectiveRate != null ? { effectiveRate: Number(obj.effectiveRate) } : {}),
144
- ...(obj.maxEventsPerSec != null ? { maxEventsPerSec: Number(obj.maxEventsPerSec) } : {}),
145
- ...(obj.dropPolicy != null ? { dropPolicy: obj.dropPolicy as DropPolicy } : {}),
146
- ...(obj.droppedTotal != null ? { droppedTotal: Number(obj.droppedTotal) } : {}),
147
- ...(obj.droppedSegment != null ? { droppedSegment: Number(obj.droppedSegment) } : {}),
148
- ...(obj.rule != null ? { rule: obj.rule as string } : {}),
149
- ...(obj.ruleLang != null ? { ruleLang: obj.ruleLang as string } : {}),
150
- ...(obj.appliesTo != null ? { appliesTo: (obj.appliesTo as number[]).map(Number) } : {}),
348
+ /** The same for `"sampling"`; every key inside it is optional. */
349
+ function cborToSampling(value: unknown): SamplingInfo | undefined {
350
+ const obj = asMap(value)
351
+ if (obj == null) return undefined
352
+ const src: Decoding = { obj, used: new Set() }
353
+ const sampling: SamplingInfo = {
354
+ // A rate is a fraction, so this is the one header number that is not an
355
+ // integer — and the one whose range the format states, so it is the one
356
+ // key here checked for more than its type. It is tested rather than
357
+ // converted: `Number('half')` is `NaN`, a rate no file carried and none
358
+ // can be written back from.
359
+ ...optional(src, 'effectiveRate', 'effectiveRate', asRate),
360
+ ...optional(src, 'maxEventsPerSec', 'maxEventsPerSec', asUintNumber),
361
+ ...optional(src, 'dropPolicy', 'dropPolicy', asText),
362
+ ...optional(src, 'droppedTotal', 'droppedTotal', asUintNumber),
363
+ ...optional(src, 'droppedSegment', 'droppedSegment', asUintNumber),
364
+ ...optional(src, 'rule', 'rule', asText),
365
+ ...optional(src, 'ruleLang', 'ruleLang', asText),
366
+ ...optional(src, 'appliesTo', 'appliesTo', asUintNumbers),
151
367
  }
368
+ const extra = unrecognisedKeys(src)
369
+ return extra == null ? sampling : { ...sampling, extra }
152
370
  }
153
371
 
154
372
  function headerToCbor(header: TraceHeader): Record<string, unknown> {
@@ -156,7 +374,7 @@ function headerToCbor(header: TraceHeader): Record<string, unknown> {
156
374
  protocol: header.protocol,
157
375
  perspective: header.perspective,
158
376
  detail: header.detail,
159
- startTime: int(header.startTime),
377
+ startTime: requiredUint(header.startTime, 'startTime'),
160
378
  }
161
379
  if (header.endTime != null) map.endTime = int(header.endTime)
162
380
  if (header.transport != null) map.transport = header.transport
@@ -166,28 +384,46 @@ function headerToCbor(header: TraceHeader): Record<string, unknown> {
166
384
  if (header.segment != null) map.segment = segmentToCbor(header.segment)
167
385
  if (header.sampling != null) map.sampling = samplingToCbor(header.sampling)
168
386
  if (header.custom != null) map.custom = header.custom
169
- return map
387
+ return withStore(map, header.extra)
170
388
  }
171
389
 
172
- function cborToHeader(obj: Record<string, unknown>): TraceHeader {
173
- return {
174
- protocol: obj.protocol as string,
175
- perspective: obj.perspective as Perspective,
176
- detail: obj.detail as DetailLevel,
177
- startTime: Number(obj.startTime),
178
- ...(obj.endTime != null ? { endTime: Number(obj.endTime) } : {}),
179
- ...(obj.transport != null ? { transport: obj.transport as string } : {}),
180
- ...(obj.source != null ? { source: obj.source as string } : {}),
181
- ...(obj.endpoint != null ? { endpoint: obj.endpoint as string } : {}),
182
- ...(obj.sessionId != null ? { sessionId: obj.sessionId as string } : {}),
183
- ...(obj.segment != null
184
- ? { segment: cborToSegment(obj.segment as Record<string, unknown>) }
185
- : {}),
186
- ...(obj.sampling != null
187
- ? { sampling: cborToSampling(obj.sampling as Record<string, unknown>) }
188
- : {}),
189
- ...(obj.custom != null ? { custom: obj.custom as Record<string, unknown> } : {}),
390
+ /**
391
+ * Decode the header, keeping every key the decode could not use.
392
+ *
393
+ * Three maps here have keys the format names — this one, `"segment"` and
394
+ * `"sampling"` — and each keeps its own store, because a private key on
395
+ * `"segment"` and a key of the same name at the top level are different keys.
396
+ * `"custom"` keeps none: every key in it belongs to whoever wrote the trace.
397
+ *
398
+ * The four required keys throw rather than default. Everything else that is
399
+ * unusable stays in the store of the map it appeared in, and the read
400
+ * continues: an optional key's type never fails a file.
401
+ *
402
+ * @throws {MalformedHeaderError} when a required key is absent or unusable.
403
+ */
404
+ export function cborToHeader(obj: Record<string, unknown>): TraceHeader {
405
+ const src: Decoding = { obj, used: new Set() }
406
+ const header: TraceHeader = {
407
+ protocol: requireHeaderText(src, 'protocol'),
408
+ perspective: requireHeaderText(src, 'perspective'),
409
+ detail: requireHeaderText(src, 'detail'),
410
+ startTime: requireHeaderUint(src, 'startTime'),
411
+ ...optional(src, 'endTime', 'endTime', asUintNumber),
412
+ ...optional(src, 'transport', 'transport', asText),
413
+ ...optional(src, 'source', 'source', asText),
414
+ ...optional(src, 'endpoint', 'endpoint', asText),
415
+ ...optional(src, 'sessionId', 'sessionId', asText),
416
+ ...optional(src, 'segment', 'segment', cborToSegment),
417
+ ...optional(src, 'sampling', 'sampling', cborToSampling),
418
+ // Not `Record<string, unknown>` by assertion but by test. Asserted, a
419
+ // `"custom"` that is not a map would reach this field wearing that type,
420
+ // and the first caller to iterate its keys would meet a shape it was told
421
+ // could not occur. The bytes survive either way; only the test keeps the
422
+ // field's type true.
423
+ ...optional(src, 'custom', 'custom', asMap),
190
424
  }
425
+ const extra = unrecognisedKeys(src)
426
+ return extra == null ? header : { ...header, extra }
191
427
  }
192
428
 
193
429
  // --- Event serialization ---
@@ -200,7 +436,16 @@ function cborToSubscriptionRef(value: unknown): SubscriptionRef {
200
436
  if (!Array.isArray(value) || value.length !== 2) {
201
437
  throw new Error('Subscription reference must be [peer, requestId]')
202
438
  }
203
- return { peer: value[0] as string, requestId: BigInt(value[1] as bigint | number) }
439
+ // Both halves are required, so an unusable one is a malformed event rather
440
+ // than something to route elsewhere — there is no subscription to name
441
+ // without them. Checked rather than converted, for the reason `asUint` gives:
442
+ // `BigInt(true)` is `1n`, a request id no file ever carried.
443
+ const peer = asText(value[0])
444
+ const requestId = asUint(value[1])
445
+ if (peer == null || requestId == null) {
446
+ throw new Error('Subscription reference must be a [text, unsigned integer] pair')
447
+ }
448
+ return { peer, requestId }
204
449
  }
205
450
 
206
451
  /**
@@ -231,12 +476,14 @@ function int(value: number): number | bigint {
231
476
  }
232
477
 
233
478
  /**
234
- * Read a trace id, refusing any length but the one the format fixes.
479
+ * Write a trace id, refusing any length but the one the format fixes.
235
480
  *
236
- * A wrong-length value is malformed rather than something to pad or truncate:
237
- * the identifier exists so two implementations independently derive
238
- * byte-identical values for one subscription chain, and a reader that quietly
239
- * reshapes it breaks exactly the property it is there for.
481
+ * A wrong-length value is not something to pad or truncate: the identifier
482
+ * exists so two implementations independently derive byte-identical values for
483
+ * one subscription chain, and reshaping it breaks exactly the property it is
484
+ * there for. The reader's counterpart is {@link asTraceId}, which keeps a
485
+ * wrong-length value instead of refusing it — `"traceId"` is optional, and an
486
+ * optional key's type never fails a file.
240
487
  */
241
488
  function checkTraceId(value: unknown): Uint8Array {
242
489
  const bytes = toBytes(value)
@@ -258,7 +505,15 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
258
505
  case 'control': {
259
506
  base.d = event.direction
260
507
  base.mt = int(event.messageType)
261
- base.msg = event.message
508
+ // Always written, even when nothing was decoded: Event 0 is one of the
509
+ // types sampling must not drop, so a reader strict enough to treat a
510
+ // missing "msg" as a malformed event would discard exactly the events
511
+ // the format promises to keep. `undefined` is the one value not passed
512
+ // through — it encodes as CBOR `undefined`, which is not a map, and it
513
+ // means a caller left the field off rather than that a file carried it.
514
+ // Every other value is written as it stands, so a text `"msg"` read out
515
+ // of a pre-spec recording goes back to disk as the same text.
516
+ base.msg = event.message === undefined ? {} : event.message
262
517
  if (event.streamId != null) base.sid = event.streamId
263
518
  if (event.raw != null) base.raw = event.raw
264
519
  break
@@ -267,6 +522,13 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
267
522
  base.sid = event.streamId
268
523
  base.d = event.direction
269
524
  base.st = event.streamType
525
+ // Written only when set, and each is scoped to one stream type: a
526
+ // subgroup id on a fetch stream, or a group id on a subgroup stream,
527
+ // names something the header it came from never carried.
528
+ if (event.trackAlias != null) base.ta = event.trackAlias
529
+ if (event.subgroupId != null) base.sg = event.subgroupId
530
+ if (event.fetchRequestId != null) base.fri = event.fetchRequestId
531
+ if (event.groupId != null) base.g = event.groupId
270
532
  break
271
533
  }
272
534
  case 'stream-closed': {
@@ -298,6 +560,18 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
298
560
  case 'error': {
299
561
  base.ec = int(event.errorCode)
300
562
  base.reason = event.reason
563
+ if (event.streamId != null) base.sid = event.streamId
564
+ if (event.errorKind != null) base.ek = event.errorKind
565
+ if (event.rawLength != null) base.rawlen = int(event.rawLength)
566
+ // Written at whatever length it arrived. The format caps `"raw"` at 4096
567
+ // bytes and this is deliberately not where that happens: a serializer
568
+ // cannot tell an event a recorder just built from observed bytes from one
569
+ // that arrived by being read, so a cap here would either truncate
570
+ // evidence on a rewrite or refuse a file the reader was required to
571
+ // accept — whichever it did, it would do to the wrong events. The cap
572
+ // belongs where the event is constructed from bytes, which in this
573
+ // package is `recordError` in `recorder.ts`.
574
+ if (event.raw != null) base.raw = event.raw
301
575
  break
302
576
  }
303
577
  case 'annotation': {
@@ -333,16 +607,288 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
333
607
  case 'unknown': {
334
608
  base.e = event.eventType
335
609
  for (const [key, value] of Object.entries(event.fields)) {
336
- base[key] = value
610
+ setKey(base, key, value)
337
611
  }
338
612
  break
339
613
  }
340
614
  }
341
615
 
616
+ // Last, so the event's own keys keep the positions a reader expects and the
617
+ // file stays diffable against one written without them. An entry naming a key
618
+ // this event already wrote from a field is dropped: a CBOR map with a
619
+ // duplicate key is malformed, and the field is what a reader produced.
620
+ //
621
+ // The test is what the event *wrote*, not what its type owns. A defined key
622
+ // whose value the reader could not use is held in `extra` and no field wrote
623
+ // it, so on the owned-key test it would be dropped here — silently undoing
624
+ // the preservation the read side had just performed.
625
+ if (event.type !== 'unknown' && event.extra != null) {
626
+ for (const [key, value] of Object.entries(event.extra)) {
627
+ if (COMMON_EVENT_KEYS.has(key) || Object.hasOwn(base, key)) continue
628
+ setKey(base, key, value)
629
+ }
630
+ }
631
+
342
632
  return base
343
633
  }
344
634
 
345
- function cborToEvent(obj: Record<string, unknown>): TraceEvent {
635
+ // --- Reading one key ---
636
+ //
637
+ // Each of these answers `undefined` for a value it cannot use, and the caller
638
+ // then leaves the key untaken so that `unrecognisedKeys` preserves it.
639
+ //
640
+ // They are type *tests*, not conversion attempts. The distinction is not
641
+ // pedantic even where a conversion throws on the obvious cases: `BigInt`
642
+ // answers `1n` for `true` and `4n` for the text `"4"`, so a `try`/`catch`
643
+ // around it would still put an identifier on the event that the file never
644
+ // carried — and the Rust reader, which coerces neither, would then read the
645
+ // same file differently.
646
+
647
+ /** A CBOR unsigned integer, as a `bigint`. */
648
+ function asUint(value: unknown): bigint | undefined {
649
+ if (typeof value === 'bigint') return value >= 0n ? value : undefined
650
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) return undefined
651
+ return BigInt(value)
652
+ }
653
+
654
+ /**
655
+ * The same, where the field it feeds is a JS `number`.
656
+ *
657
+ * A value past `Number.MAX_SAFE_INTEGER` is refused rather than rounded: the
658
+ * rounded number is not what the file said, and refusing keeps the digits
659
+ * intact in `extra`.
660
+ */
661
+ function asUintNumber(value: unknown): number | undefined {
662
+ const int = asUint(value)
663
+ return int == null || int > BigInt(Number.MAX_SAFE_INTEGER) ? undefined : Number(int)
664
+ }
665
+
666
+ /** A CBOR text string. */
667
+ function asText(value: unknown): string | undefined {
668
+ return typeof value === 'string' ? value : undefined
669
+ }
670
+
671
+ /** A CBOR boolean. */
672
+ function asBoolean(value: unknown): boolean | undefined {
673
+ return typeof value === 'boolean' ? value : undefined
674
+ }
675
+
676
+ /**
677
+ * A CBOR number, for the one header key defined as a float.
678
+ *
679
+ * Non-finite values are refused along with everything that is not a number:
680
+ * CBOR can carry a NaN or an infinity, but neither is a fraction of the events
681
+ * retained, and a field holding one states a rate the file never gave.
682
+ *
683
+ * No test covers the finite check, and none can: {@link asRate} is the only
684
+ * caller, and its `> 0 && <= 1` already rejects NaN and both infinities, so
685
+ * deleting `Number.isFinite` here changes no observable behaviour today. It
686
+ * stays because the redundancy belongs to the caller and not to this function.
687
+ * `asRate` bounds its key by meaning, which the format does for that key and
688
+ * for no other; the next float key added here would arrive through this type
689
+ * test with no range behind it, and would take a NaN into its field. Rather
690
+ * than a test that cannot fail, this note.
691
+ */
692
+ function asNumber(value: unknown): number | undefined {
693
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
694
+ }
695
+
696
+ /**
697
+ * A sampling rate: a number in `(0.0, 1.0]`, and no other number.
698
+ *
699
+ * The one value in the header the format bounds by meaning rather than by
700
+ * type, and the bound is checked here because consumers divide by it. A `0.0`
701
+ * handed to a caller reconstructing true event counts is a division by zero,
702
+ * and a `1.5` is a count larger than what was recorded — each a wrong answer
703
+ * produced silently, from a header the reader had already decided to trust.
704
+ *
705
+ * The interval is open at `0` and closed at `1`, so `1.0` — "no rate-based
706
+ * dropping", the commonest value there is — passes and `0.0` does not. A value
707
+ * outside it is unusable on an optional key, so it goes to the sampling map's
708
+ * store with its bytes intact rather than failing the file. NaN fails both
709
+ * comparisons, which is the wanted answer twice over: it is in no interval,
710
+ * and it is no fraction of the events retained.
711
+ */
712
+ function asRate(value: unknown): number | undefined {
713
+ const rate = asNumber(value)
714
+ return rate != null && rate > 0 && rate <= 1 ? rate : undefined
715
+ }
716
+
717
+ /**
718
+ * A CBOR array of unsigned integers — the shape `"sampling.appliesTo"` takes.
719
+ *
720
+ * All or nothing, like {@link asByteStrings}. An array with one element that is
721
+ * not an event type ID is unusable *entire*: this key names the types a drop
722
+ * policy touched, and readers may treat every type absent from it as complete,
723
+ * so keeping `[3, 5]` out of `[3, "x", 5]` would report a sampled type as fully
724
+ * recorded — the opposite of what the file said, stated just as confidently.
725
+ */
726
+ function asUintNumbers(value: unknown): number[] | undefined {
727
+ if (!Array.isArray(value)) return undefined
728
+ const ids: number[] = []
729
+ for (const item of value) {
730
+ const id = asUintNumber(item)
731
+ if (id == null) return undefined
732
+ ids.push(id)
733
+ }
734
+ return ids
735
+ }
736
+
737
+ /**
738
+ * A CBOR map, as the plain object this codec decodes one to.
739
+ *
740
+ * `typeof value === 'object'` is not the test: it also admits `null`, an array,
741
+ * a byte string and a tagged value, each of which would then be handed back as
742
+ * a field map and read `undefined` for every key. The prototype is what
743
+ * separates them — a decoded map is a plain object, and everything else this
744
+ * decoder produces for a non-map is an instance of something. A `Map` fails the
745
+ * same test, which is right for the same reason: a reader that cannot hold a
746
+ * map exactly must route it to a store rather than hand back a shape its own
747
+ * declared type denies.
748
+ */
749
+ function asMap(value: unknown): Record<string, unknown> | undefined {
750
+ if (value === null || typeof value !== 'object') return undefined
751
+ const proto = Object.getPrototypeOf(value)
752
+ if (proto !== Object.prototype && proto !== null) return undefined
753
+ return value as Record<string, unknown>
754
+ }
755
+
756
+ /** A CBOR byte string, copied by {@link toBytes} so it owns its bytes. */
757
+ function asByteString(value: unknown): Uint8Array | undefined {
758
+ return value instanceof Uint8Array ? toBytes(value) : undefined
759
+ }
760
+
761
+ /** A CBOR array of byte strings — the shape a track namespace takes. */
762
+ function asByteStrings(value: unknown): Uint8Array[] | undefined {
763
+ if (!Array.isArray(value)) return undefined
764
+ const parts: Uint8Array[] = []
765
+ for (const item of value) {
766
+ const bytes = asByteString(item)
767
+ if (bytes == null) return undefined
768
+ parts.push(bytes)
769
+ }
770
+ return parts
771
+ }
772
+
773
+ /**
774
+ * A trace id: {@link TRACE_ID_LENGTH} bytes and no other length.
775
+ *
776
+ * The reader's half of {@link checkTraceId}. It keeps a wrong-length value
777
+ * rather than throwing, because `"traceId"` is optional and an optional key's
778
+ * type never fails a file; the value then survives in `extra` with its bytes
779
+ * intact, which a padded or truncated field would not.
780
+ */
781
+ function asTraceId(value: unknown): Uint8Array | undefined {
782
+ const bytes = asByteString(value)
783
+ return bytes?.length === TRACE_ID_LENGTH ? bytes : undefined
784
+ }
785
+
786
+ /** Take a key's value as it stands, whatever shape it has. */
787
+ function take(src: Decoding, key: string): unknown {
788
+ src.used.add(key)
789
+ return src.obj[key]
790
+ }
791
+
792
+ /**
793
+ * Take a required integer key, or fail the event.
794
+ *
795
+ * The one case where an unusable value is not routed to `extra`: without the
796
+ * key there is no event to build, so the event is malformed exactly as it
797
+ * would be with the key absent. That is as far as it goes — a *file* is never
798
+ * failed over an optional key's type.
799
+ */
800
+ function requireUint(src: Decoding, key: string): bigint {
801
+ const value = asUint(take(src, key))
802
+ if (value == null) {
803
+ throw new Error(`Event key ${JSON.stringify(key)} must be an unsigned integer`)
804
+ }
805
+ return value
806
+ }
807
+
808
+ /**
809
+ * Take a required header text key, or fail the header.
810
+ *
811
+ * Asserting the type instead — `obj.protocol as string` — would let a header
812
+ * with no `"protocol"` reach a caller with `undefined` sitting in a field
813
+ * declared `string`, and re-encode as CBOR `undefined`, a shape the format
814
+ * tells writers never to produce.
815
+ */
816
+ function requireHeaderText(src: Decoding, key: string): string {
817
+ const present = Object.hasOwn(src.obj, key)
818
+ const value = asText(take(src, key))
819
+ if (value == null) {
820
+ throw new MalformedHeaderError(key, present ? 'must be a text string' : 'is missing')
821
+ }
822
+ return value
823
+ }
824
+
825
+ /**
826
+ * Take a required header integer key, or fail the header.
827
+ *
828
+ * @param reportedAs the key's name in the error, dotted for one inside
829
+ * `"segment"`, where `"sequence"` alone would not say which map it was in.
830
+ */
831
+ function requireHeaderUint(src: Decoding, key: string, reportedAs = key): number {
832
+ const present = Object.hasOwn(src.obj, key)
833
+ const value = asUintNumber(take(src, key))
834
+ if (value == null) {
835
+ throw new MalformedHeaderError(
836
+ reportedAs,
837
+ present ? 'must be an unsigned integer' : 'is missing',
838
+ )
839
+ }
840
+ return value
841
+ }
842
+
843
+ /**
844
+ * Take an optional key, as the property to spread onto the event or header map
845
+ * being built — or nothing at all, when the key is absent or carries a value of
846
+ * a type this reader cannot use.
847
+ *
848
+ * Those two cases build the same result and differ in what they leave behind:
849
+ * an absent key leaves nothing, an unusable one stays untaken and is preserved
850
+ * verbatim as an unrecognised key. Neither throws. The events around this one
851
+ * decoded fine, and one key's type is no reason to lose them.
852
+ *
853
+ * `read` may itself throw, which is how `"segment.sequence"` fails a header
854
+ * from inside an otherwise optional `"segment"`.
855
+ */
856
+ function optional<K extends string, V>(
857
+ src: Decoding,
858
+ key: string,
859
+ name: K,
860
+ read: (value: unknown) => V | undefined,
861
+ ): { [P in K]?: V } {
862
+ const usable = read(src.obj[key])
863
+ if (usable === undefined) return {} as { [P in K]?: V }
864
+ src.used.add(key)
865
+ return { [name]: usable } as { [P in K]?: V }
866
+ }
867
+
868
+ /**
869
+ * Decode one event, keeping every key the decode could not use.
870
+ *
871
+ * Those keys are kept on the event rather than dropped so that reading a trace
872
+ * and writing it back does not quietly strip what a newer writer put there.
873
+ * Two kinds land there, and the second is the one easily lost: a key this
874
+ * version has never heard of, and a key it knows whose value is of a type it
875
+ * cannot use — a text `"ta"`, say. Both are unrecognised as far as meaning
876
+ * goes, and both are written back unchanged.
877
+ *
878
+ * An `UnknownEvent` is returned untouched: its `fields` already hold every
879
+ * non-common key, and adding them to `extra` too would write each one twice.
880
+ */
881
+ export function cborToEvent(obj: Record<string, unknown>): TraceEvent {
882
+ const src: Decoding = { obj, used: new Set(COMMON_EVENT_KEYS) }
883
+ const event = decodeEvent(src)
884
+ if (event.type === 'unknown') return event
885
+
886
+ const extra = unrecognisedKeys(src)
887
+ return extra == null ? event : { ...event, extra }
888
+ }
889
+
890
+ function decodeEvent(src: Decoding): TraceEvent {
891
+ const obj = src.obj
346
892
  const seq = Number(obj.n ?? 0)
347
893
  const timestamp = Number(obj.t ?? 0)
348
894
  const peerFields = obj.p != null ? { peer: obj.p as string } : {}
@@ -355,7 +901,7 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
355
901
  // downstream of it, because a read-modify-write then rewrites the type.
356
902
  const fields: Record<string, unknown> = {}
357
903
  for (const [key, value] of Object.entries(obj)) {
358
- if (!COMMON_EVENT_KEYS.has(key)) fields[key] = value
904
+ if (!COMMON_EVENT_KEYS.has(key)) setKey(fields, key, value)
359
905
  }
360
906
  return {
361
907
  type: 'unknown' as const,
@@ -374,11 +920,20 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
374
920
  seq,
375
921
  timestamp,
376
922
  ...peerFields,
377
- direction: obj.d as 0 | 1,
378
- messageType: Number(obj.mt ?? 0),
379
- message: (obj.msg ?? {}) as Record<string, unknown>,
380
- ...(obj.sid != null ? { streamId: BigInt(obj.sid as bigint | number) } : {}),
381
- ...(obj.raw != null ? { raw: toBytes(obj.raw) } : {}),
923
+ direction: take(src, 'd') as 0 | 1,
924
+ messageType: Number(take(src, 'mt') ?? 0),
925
+ // Handed over exactly as it decoded. A `"msg"` that is not a map is
926
+ // still the only decode the recording has — the corpus captures carry
927
+ // a Rust `Debug` string here — and the format requires a reader to
928
+ // preserve it rather than reject the event or substitute a map. That
929
+ // includes `null`, which is a value a writer chose to put on the wire,
930
+ // not an absence; absence is the single case normalised, to `{}`, so
931
+ // that a caller reading keys off a conforming file never meets
932
+ // `undefined`. It is the one key with no unusable shape: the field's
933
+ // type is `unknown`, so every value is one it can hold.
934
+ message: Object.hasOwn(obj, 'msg') ? take(src, 'msg') : {},
935
+ ...optional(src, 'sid', 'streamId', asUint),
936
+ ...optional(src, 'raw', 'raw', asByteString),
382
937
  }
383
938
 
384
939
  case 'stream-opened':
@@ -387,9 +942,27 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
387
942
  seq,
388
943
  timestamp,
389
944
  ...peerFields,
390
- streamId: BigInt(obj.sid as bigint | number),
391
- direction: obj.d as 0 | 1,
392
- streamType: obj.st as 0 | 1 | 2,
945
+ streamId: requireUint(src, 'sid'),
946
+ direction: take(src, 'd') as 0 | 1,
947
+ streamType: take(src, 'st') as 0 | 1 | 2,
948
+ // `bigint`, not `number`, even though a writer that used a narrow CBOR
949
+ // integer hands these back as JS numbers. They are the same
950
+ // identifiers an object-header event carries, and that event converts;
951
+ // leaving one a number here makes `opened.groupId === header.groupId`
952
+ // read `42 === 42n` and answer `false` for values that match.
953
+ //
954
+ // Tested rather than converted, and optional rather than required.
955
+ // `BigInt(obj.ta)` threw on a text or fractional value and took the
956
+ // whole file down with it, since nothing catches one event's decode; it
957
+ // answered `1n` for `true` and `4n` for `"4"`, inventing an identifier
958
+ // the file never carried; and the owned-key list then kept the
959
+ // survivors out of `extra`, so adding these four keys *lost* values
960
+ // this reader had preserved before it knew them. A value of any other
961
+ // shape now stays where it was, on the event as an unrecognised key.
962
+ ...optional(src, 'ta', 'trackAlias', asUint),
963
+ ...optional(src, 'sg', 'subgroupId', asUint),
964
+ ...optional(src, 'fri', 'fetchRequestId', asUint),
965
+ ...optional(src, 'g', 'groupId', asUint),
393
966
  }
394
967
 
395
968
  case 'stream-closed':
@@ -398,8 +971,8 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
398
971
  seq,
399
972
  timestamp,
400
973
  ...peerFields,
401
- streamId: BigInt(obj.sid as bigint | number),
402
- errorCode: Number(obj.ec ?? 0),
974
+ streamId: requireUint(src, 'sid'),
975
+ errorCode: Number(take(src, 'ec') ?? 0),
403
976
  }
404
977
 
405
978
  case 'object-header':
@@ -408,11 +981,11 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
408
981
  seq,
409
982
  timestamp,
410
983
  ...peerFields,
411
- streamId: BigInt(obj.sid as bigint | number),
412
- groupId: BigInt(obj.g as bigint | number),
413
- objectId: BigInt(obj.o as bigint | number),
414
- publisherPriority: Number(obj.pp ?? 0),
415
- objectStatus: Number(obj.os ?? 0),
984
+ streamId: requireUint(src, 'sid'),
985
+ groupId: requireUint(src, 'g'),
986
+ objectId: requireUint(src, 'o'),
987
+ publisherPriority: Number(take(src, 'pp') ?? 0),
988
+ objectStatus: Number(take(src, 'os') ?? 0),
416
989
  }
417
990
 
418
991
  case 'object-payload':
@@ -421,11 +994,11 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
421
994
  seq,
422
995
  timestamp,
423
996
  ...peerFields,
424
- streamId: BigInt(obj.sid as bigint | number),
425
- groupId: BigInt(obj.g as bigint | number),
426
- objectId: BigInt(obj.o as bigint | number),
427
- size: Number(obj.sz ?? 0),
428
- ...(obj.pl != null ? { payload: toBytes(obj.pl) } : {}),
997
+ streamId: requireUint(src, 'sid'),
998
+ groupId: requireUint(src, 'g'),
999
+ objectId: requireUint(src, 'o'),
1000
+ size: Number(take(src, 'sz') ?? 0),
1001
+ ...optional(src, 'pl', 'payload', asByteString),
429
1002
  }
430
1003
 
431
1004
  case 'state-change':
@@ -434,8 +1007,8 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
434
1007
  seq,
435
1008
  timestamp,
436
1009
  ...peerFields,
437
- from: obj.from as string,
438
- to: obj.to as string,
1010
+ from: take(src, 'from') as string,
1011
+ to: take(src, 'to') as string,
439
1012
  }
440
1013
 
441
1014
  case 'error':
@@ -444,8 +1017,24 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
444
1017
  seq,
445
1018
  timestamp,
446
1019
  ...peerFields,
447
- errorCode: Number(obj.ec ?? 0),
448
- reason: (obj.reason ?? '') as string,
1020
+ errorCode: Number(take(src, 'ec') ?? 0),
1021
+ reason: (take(src, 'reason') ?? '') as string,
1022
+ // Optional, so an unusable value costs the key and not the event: the
1023
+ // error code and reason are the point of the event and are fine.
1024
+ // `asUint` rather than a conversion, for the reason Event 1 gives —
1025
+ // `BigInt(true)` is `1n`, a stream this file never named.
1026
+ ...optional(src, 'sid', 'streamId', asUint),
1027
+ // An error kind this version has never heard of is still text, and
1028
+ // still kept: the vocabulary is open and `ErrorKind` widens to any
1029
+ // string for exactly that. What `asText` refuses is a value that is
1030
+ // not text at all.
1031
+ ...optional(src, 'ek', 'errorKind', asText),
1032
+ ...optional(src, 'rawlen', 'rawLength', asUintNumber),
1033
+ // No length check. The 4096-byte cap binds a recorder; a reader that
1034
+ // refused a longer value — or shortened it — would reject a file it was
1035
+ // required to accept, or destroy the evidence the field exists to
1036
+ // carry.
1037
+ ...optional(src, 'raw', 'raw', asByteString),
449
1038
  }
450
1039
 
451
1040
  case 'annotation':
@@ -454,8 +1043,8 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
454
1043
  seq,
455
1044
  timestamp,
456
1045
  ...peerFields,
457
- label: (obj.label ?? '') as string,
458
- data: obj.data,
1046
+ label: (take(src, 'label') ?? '') as string,
1047
+ data: take(src, 'data'),
459
1048
  }
460
1049
 
461
1050
  case 'peer-connected':
@@ -464,10 +1053,13 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
464
1053
  seq,
465
1054
  timestamp,
466
1055
  ...peerFields,
467
- ...(obj.endpoint != null ? { endpoint: obj.endpoint as string } : {}),
468
- ...(obj.transport != null ? { transport: obj.transport as string } : {}),
469
- ...(obj.role != null ? { role: obj.role as PeerRole } : {}),
470
- ...(obj.side != null ? { side: obj.side as Side } : {}),
1056
+ ...optional(src, 'endpoint', 'endpoint', asText),
1057
+ ...optional(src, 'transport', 'transport', asText),
1058
+ // A role or side this version has never heard of is still text, and
1059
+ // still kept: the unions they widen to exist for exactly that. What
1060
+ // `asText` refuses is a value that is not text at all.
1061
+ ...optional(src, 'role', 'role', asText),
1062
+ ...optional(src, 'side', 'side', asText),
471
1063
  }
472
1064
 
473
1065
  case 'peer-disconnected':
@@ -476,8 +1068,8 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
476
1068
  seq,
477
1069
  timestamp,
478
1070
  ...peerFields,
479
- errorCode: Number(obj.ec ?? 0),
480
- ...(obj.reason != null ? { reason: obj.reason as string } : {}),
1071
+ errorCode: Number(take(src, 'ec') ?? 0),
1072
+ ...optional(src, 'reason', 'reason', asText),
481
1073
  }
482
1074
 
483
1075
  case 'subscription-derivation':
@@ -486,16 +1078,16 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
486
1078
  seq,
487
1079
  timestamp,
488
1080
  ...peerFields,
489
- upstream: cborToSubscriptionRef(obj.u),
490
- downstream: ((obj.d ?? []) as unknown[]).map(cborToSubscriptionRef),
491
- kind: (obj.kind ?? '') as DerivationKind,
492
- ...(obj.traceId != null ? { traceId: checkTraceId(obj.traceId) } : {}),
493
- ...(obj.ns != null ? { namespace: (obj.ns as unknown[]).map(toBytes) } : {}),
494
- ...(obj.tn != null ? { trackName: toBytes(obj.tn) } : {}),
495
- ...(obj.tdr != null ? { tDownstreamReceived: Number(obj.tdr) } : {}),
496
- ...(obj.tus != null ? { tUpstreamSent: Number(obj.tus) } : {}),
497
- ...(obj.tuo != null ? { tUpstreamOkReceived: Number(obj.tuo) } : {}),
498
- ...(obj.tdo != null ? { tDownstreamOkSent: Number(obj.tdo) } : {}),
1081
+ upstream: cborToSubscriptionRef(take(src, 'u')),
1082
+ downstream: ((take(src, 'd') ?? []) as unknown[]).map(cborToSubscriptionRef),
1083
+ kind: (take(src, 'kind') ?? '') as DerivationKind,
1084
+ ...optional(src, 'traceId', 'traceId', asTraceId),
1085
+ ...optional(src, 'ns', 'namespace', asByteStrings),
1086
+ ...optional(src, 'tn', 'trackName', asByteString),
1087
+ ...optional(src, 'tdr', 'tDownstreamReceived', asUintNumber),
1088
+ ...optional(src, 'tus', 'tUpstreamSent', asUintNumber),
1089
+ ...optional(src, 'tuo', 'tUpstreamOkReceived', asUintNumber),
1090
+ ...optional(src, 'tdo', 'tDownstreamOkSent', asUintNumber),
499
1091
  }
500
1092
  }
501
1093
  }
@@ -512,7 +1104,7 @@ function writePreamble(headerCbor: Uint8Array): Uint8Array {
512
1104
  return buf
513
1105
  }
514
1106
 
515
- function startsWithMagic(bytes: Uint8Array, offset: number): boolean {
1107
+ export function startsWithMagic(bytes: Uint8Array, offset: number): boolean {
516
1108
  if (offset + MAGIC.length > bytes.length) return false
517
1109
  for (let i = 0; i < MAGIC.length; i++) {
518
1110
  if (bytes[offset + i] !== MAGIC[i]) return false
@@ -520,6 +1112,21 @@ function startsWithMagic(bytes: Uint8Array, offset: number): boolean {
520
1112
  return true
521
1113
  }
522
1114
 
1115
+ /**
1116
+ * Read and check a segment preamble, for a buffer that holds the whole file.
1117
+ *
1118
+ * **"Too short" means truncated here, and that is only true of a whole file.**
1119
+ * A caller reading a complete buffer has everything the file will ever have,
1120
+ * so a preamble that runs off the end is a cut recording. A caller reading a
1121
+ * *stream* is in the opposite situation: a short buffer is the ordinary state
1122
+ * between two chunks, and the missing bytes are on their way. An incremental
1123
+ * reader that reuses this function would report a truncation on every chunk
1124
+ * boundary.
1125
+ *
1126
+ * The distinction is invisible from the name, which is why it is written down:
1127
+ * `createMoqtraceReader` does its own length checks for this reason and shares
1128
+ * only the parts that carry meaning — the version check and `cborToHeader`.
1129
+ */
523
1130
  function validatePreamble(
524
1131
  bytes: Uint8Array,
525
1132
  offset = 0,
@@ -565,6 +1172,36 @@ export interface ReadOptions {
565
1172
  * segment, so a damaged one still ends the read, just without an error.
566
1173
  */
567
1174
  readonly recover?: boolean
1175
+
1176
+ /**
1177
+ * Called once for each region `recover` skipped.
1178
+ *
1179
+ * Without it a recovered read cannot be told from a clean one: the return
1180
+ * type carries no error channel, so a trace that lost a segment and a trace
1181
+ * that never had one are the same value.
1182
+ */
1183
+ readonly onRecovered?: (region: RecoveredRegion) => void
1184
+ }
1185
+
1186
+ /** A region `recover` could not read. */
1187
+ export interface RecoveredRegion {
1188
+ /** Byte offset the unreadable region began at. */
1189
+ readonly offset: number
1190
+
1191
+ /**
1192
+ * What was lost: `header` drops the whole segment, `event` cuts the segment
1193
+ * in progress short, `truncated` means the item ran past end of file.
1194
+ */
1195
+ readonly kind: 'header' | 'event' | 'truncated'
1196
+
1197
+ /**
1198
+ * Offset the read resumed at, or `undefined` when no further segment
1199
+ * followed — everything from `offset` on was then discarded.
1200
+ */
1201
+ readonly resumedAt: number | undefined
1202
+
1203
+ /** The decode failure, absent when the region was merely truncated. */
1204
+ readonly cause?: unknown
568
1205
  }
569
1206
 
570
1207
  function flatten(segments: Trace[]): Trace | undefined {
@@ -588,6 +1225,15 @@ function readSegments(bytes: Uint8Array, options?: ReadOptions): Trace[] {
588
1225
  let events: TraceEvent[] = []
589
1226
  let offset = 0
590
1227
 
1228
+ // Reports the skip before resuming, including the case where nothing follows
1229
+ // it: discarding the tail of a file is the largest loss recovery can inflict
1230
+ // and the one a caller most needs told about.
1231
+ const skip = (from: number, kind: RecoveredRegion['kind'], cause?: unknown): number => {
1232
+ const next = findNextSegment(bytes, from + 1)
1233
+ options?.onRecovered?.({ offset: from, kind, resumedAt: next < 0 ? undefined : next, cause })
1234
+ return next
1235
+ }
1236
+
591
1237
  // The first thing in the buffer must be a preamble; failing that, the
592
1238
  // errors below name why rather than reporting a stray byte.
593
1239
  validatePreamble(bytes, 0)
@@ -600,21 +1246,35 @@ function readSegments(bytes: Uint8Array, options?: ReadOptions): Trace[] {
600
1246
  const { headerLength } = validatePreamble(bytes, offset)
601
1247
  const start = offset + PREAMBLE_SIZE
602
1248
  const headerBytes = bytes.subarray(start, start + headerLength)
1249
+ let header: TraceHeader
1250
+ try {
1251
+ header = cborToHeader(decode(headerBytes) as Record<string, unknown>)
1252
+ } catch (error) {
1253
+ // A header this reader cannot build is malformed for *that segment*,
1254
+ // and the segment is not presented as read — the alternative is a
1255
+ // header the file never carried, which nothing downstream can tell
1256
+ // from a real one. `recover` skips to the next segment, on the same
1257
+ // terms as a malformed event; without it the read stops here.
1258
+ if (!recover) throw error
1259
+ const next = skip(offset, 'header', error)
1260
+ if (next < 0) return segments
1261
+ offset = next
1262
+ continue
1263
+ }
603
1264
  events = []
604
- segments.push({
605
- header: cborToHeader(decode(headerBytes) as Record<string, unknown>),
606
- events,
607
- })
1265
+ segments.push({ header, events })
608
1266
  offset = start + headerLength
609
1267
  continue
610
1268
  }
611
1269
 
612
1270
  let length: number | null
1271
+ let failure: unknown
613
1272
  try {
614
1273
  length = cborItemLength(bytes, offset)
615
1274
  } catch (error) {
616
1275
  if (!recover || !(error instanceof MalformedCborError)) throw error
617
1276
  length = null
1277
+ failure = error
618
1278
  }
619
1279
 
620
1280
  if (length != null) {
@@ -625,12 +1285,15 @@ function readSegments(bytes: Uint8Array, options?: ReadOptions): Trace[] {
625
1285
  continue
626
1286
  } catch (error) {
627
1287
  if (!recover) throw error
1288
+ failure = error
628
1289
  }
629
1290
  } else if (!recover) {
630
1291
  throw new TruncatedTraceError(offset, segments)
631
1292
  }
632
1293
 
633
- const next = findNextSegment(bytes, offset + 1)
1294
+ // No failure means `cborItemLength` returned null: the item runs past the
1295
+ // end of the file rather than being malformed where it sits.
1296
+ const next = skip(offset, failure === undefined ? 'truncated' : 'event', failure)
634
1297
  if (next < 0) return segments
635
1298
  offset = next
636
1299
  }
@@ -703,6 +1366,8 @@ export function writeMoqtraceSegments(segments: Trace[]): Uint8Array {
703
1366
  *
704
1367
  * @throws {TruncatedTraceError} if the file stops part-way through an item.
705
1368
  * The error carries everything that decoded before the cut.
1369
+ * @throws {MalformedHeaderError} if a segment header carries no usable value
1370
+ * for a required key. Pass `recover` to skip that segment instead.
706
1371
  */
707
1372
  export function readMoqtrace(bytes: Uint8Array, options?: ReadOptions): Trace {
708
1373
  const trace = flatten(readSegments(bytes, options))
@@ -717,6 +1382,8 @@ export function readMoqtrace(bytes: Uint8Array, options?: ReadOptions): Trace {
717
1382
  *
718
1383
  * A non-segmented file yields exactly one entry, whose header carries no
719
1384
  * `segment` field.
1385
+ *
1386
+ * @throws {MalformedHeaderError} as {@link readMoqtrace} does.
720
1387
  */
721
1388
  export function readMoqtraceSegments(bytes: Uint8Array, options?: ReadOptions): Trace[] {
722
1389
  return readSegments(bytes, options)
@@ -726,6 +1393,10 @@ export function readMoqtraceSegments(bytes: Uint8Array, options?: ReadOptions):
726
1393
  * Read only the header from a .moqtrace file (fast metadata peek).
727
1394
  *
728
1395
  * In a segmented file this is the first segment's header.
1396
+ *
1397
+ * @throws {MalformedHeaderError} if that header carries no usable value for a
1398
+ * required key. There is no partial header to hand back and no later segment
1399
+ * to fall forward to, so this one always throws.
729
1400
  */
730
1401
  export function readMoqtraceHeader(bytes: Uint8Array): TraceHeader {
731
1402
  const { headerLength } = validatePreamble(bytes)