@rulvar/testing 1.32.0 → 1.33.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/dist/index.d.ts CHANGED
@@ -150,7 +150,11 @@ interface VcrRow {
150
150
  * it when every row of the group carries one; absent in cassettes
151
151
  * recorded before v1.32.0, whose same hash rows keep file order.
152
152
  * An aborted or failed call claims a number but appends no row, so
153
- * gaps in the numbering are valid.
153
+ * gaps in the numbering are valid. An appending `record()` session
154
+ * seeds its counters past the numbers already on disk, so the
155
+ * numbering continues across sequential sessions; a duplicate
156
+ * number inside a fully numbered group refuses replay as ambiguous
157
+ * (v1.32.0 review P2).
154
158
  */
155
159
  occurrence?: number;
156
160
  requestHash: string;
@@ -195,8 +199,19 @@ declare function requestHash(req: ChatRequest): string;
195
199
  * the `stream()` call itself and persists it on the completed row,
196
200
  * so replay can restore the caller to response association even when
197
201
  * concurrent identical calls completed out of order (v1.31.0 review
198
- * P2). The wrapped adapters are drop-in: same ids, providers, caps,
199
- * and event streams.
202
+ * P2). A later `record()` call on the same cassette file is an
203
+ * appending session: the existing file is read and validated first
204
+ * (a target that was never a cassette, a header whose hashVersion is
205
+ * not the one this build records under, and a file whose occurrence
206
+ * numbering is already ambiguous all refuse with a typed
207
+ * ConfigError), and every hash counter is seeded past the numbers
208
+ * already on disk, so the numbering continues where the file left
209
+ * off instead of restarting at zero (v1.32.0 review P2). One
210
+ * recorder session may be active on a cassette at a time: two
211
+ * concurrently constructed recorders seed identically and claim
212
+ * colliding numbers, which replay refuses as ambiguous instead of
213
+ * silently serving either order. The wrapped adapters are drop-in:
214
+ * same ids, providers, caps, and event streams.
200
215
  */
201
216
  declare function record(options: {
202
217
  adapters: ProviderAdapter[];
@@ -262,7 +277,11 @@ declare function readCassette(path: string): VcrCassette;
262
277
  * identical calls whose live completions were appended out of order
263
278
  * still replay to the callers that made them (v1.31.0 review P2); a
264
279
  * group with any unnumbered row (recorded before v1.32.0) keeps file
265
- * order.
280
+ * order. A duplicate occurrence inside a fully numbered group
281
+ * refuses the whole cassette with a typed ConfigError naming the
282
+ * adapter and hash: it means two recorder sessions wrote the file
283
+ * concurrently, and serving either order would hand a caller the
284
+ * wrong exchange (v1.32.0 review P2).
266
285
  * A call after the last occurrence is a miss: under `onMiss: 'throw'`
267
286
  * it raises a VcrMissError whose `recordedOccurrences` says the hash
268
287
  * WAS recorded but is exhausted, and under `'passthrough'` it
package/dist/index.js CHANGED
@@ -330,6 +330,43 @@ function headerLine() {
330
330
  });
331
331
  }
332
332
  /**
333
+ * Groups rows by `(adapterId, requestHash)` and orders every fully
334
+ * numbered group by its recorded occurrence numbers. Same hash rows
335
+ * sit in the file in COMPLETION order; when every row of a group
336
+ * carries the occurrence number claimed at stream call time, the
337
+ * group is served in that order instead, so concurrent identical
338
+ * calls that finished out of order still replay to the callers that
339
+ * made them (v1.31.0 review P2). A group with any unnumbered row
340
+ * (recorded before v1.32.0) keeps file order, and gaps in the
341
+ * numbering (an aborted or failed call claims a number but appends
342
+ * no row) are valid. A DUPLICATE number inside a fully numbered
343
+ * group refuses the whole cassette: it means two recorder sessions
344
+ * wrote the file concurrently (the documented contract is one active
345
+ * recorder per cassette), and serving either order would silently
346
+ * hand a caller the wrong exchange (v1.32.0 review P2). Both replay
347
+ * and an appending record session group through here, so the refusal
348
+ * fires before anything is served or appended.
349
+ */
350
+ function groupRows(rows, cassette) {
351
+ const byAdapter = /* @__PURE__ */ new Map();
352
+ for (const row of rows) {
353
+ const forAdapter = byAdapter.get(row.adapterId) ?? /* @__PURE__ */ new Map();
354
+ const occurrences = forAdapter.get(row.requestHash) ?? [];
355
+ occurrences.push(row);
356
+ forAdapter.set(row.requestHash, occurrences);
357
+ byAdapter.set(row.adapterId, forAdapter);
358
+ }
359
+ for (const [adapterId, forAdapter] of byAdapter) for (const [hash, occurrences] of forAdapter) {
360
+ if (!occurrences.every((row) => row.occurrence !== void 0)) continue;
361
+ occurrences.sort((a, b) => (a.occurrence ?? 0) - (b.occurrence ?? 0));
362
+ for (let index = 1; index < occurrences.length; index += 1) {
363
+ const number = occurrences[index]?.occurrence;
364
+ if (number !== void 0 && number === occurrences[index - 1]?.occurrence) throw new ConfigError(`${cassette} records occurrence ${String(number)} twice for adapter '${adapterId}' hash ${hash.slice(0, 12)}; two recorder sessions likely wrote this cassette concurrently, so the replay order would be ambiguous; record the cassette again`);
365
+ }
366
+ }
367
+ return byAdapter;
368
+ }
369
+ /**
333
370
  * Wraps live adapters for recording: every stream that completes with
334
371
  * exactly one terminal event (finish or error) appends one redacted
335
372
  * row to the cassette JSONL. A stream that ends without a terminal
@@ -341,14 +378,37 @@ function headerLine() {
341
378
  * the `stream()` call itself and persists it on the completed row,
342
379
  * so replay can restore the caller to response association even when
343
380
  * concurrent identical calls completed out of order (v1.31.0 review
344
- * P2). The wrapped adapters are drop-in: same ids, providers, caps,
345
- * and event streams.
381
+ * P2). A later `record()` call on the same cassette file is an
382
+ * appending session: the existing file is read and validated first
383
+ * (a target that was never a cassette, a header whose hashVersion is
384
+ * not the one this build records under, and a file whose occurrence
385
+ * numbering is already ambiguous all refuse with a typed
386
+ * ConfigError), and every hash counter is seeded past the numbers
387
+ * already on disk, so the numbering continues where the file left
388
+ * off instead of restarting at zero (v1.32.0 review P2). One
389
+ * recorder session may be active on a cassette at a time: two
390
+ * concurrently constructed recorders seed identically and claim
391
+ * colliding numbers, which replay refuses as ambiguous instead of
392
+ * silently serving either order. The wrapped adapters are drop-in:
393
+ * same ids, providers, caps, and event streams.
346
394
  */
347
395
  function record(options) {
348
396
  const redact = options.redact ? (value) => defaultRedact(options.redact ? options.redact(value) : value) : defaultRedact;
349
- if (!existsSync(options.cassette)) writeFileSync(options.cassette, `${headerLine()}\n`, "utf8");
397
+ const seeds = /* @__PURE__ */ new Map();
398
+ if (existsSync(options.cassette)) {
399
+ const existing = readCassette(options.cassette);
400
+ if (existing.header.hashVersion !== CURRENT_HASH_VERSION) throw new ConfigError(`${options.cassette} was recorded under hashVersion ${String(existing.header.hashVersion)} and this build records under ${String(CURRENT_HASH_VERSION)}; appending would mix two identity profiles under one header, so record the cassette again from scratch`);
401
+ for (const [adapterId, forAdapter] of groupRows(existing.rows, options.cassette)) {
402
+ const forSeeds = /* @__PURE__ */ new Map();
403
+ for (const [hash, rows] of forAdapter) {
404
+ const numbered = rows.map((row) => row.occurrence).filter((value) => value !== void 0);
405
+ forSeeds.set(hash, numbered.length === 0 ? 0 : Math.max(...numbered) + 1);
406
+ }
407
+ seeds.set(adapterId, forSeeds);
408
+ }
409
+ } else writeFileSync(options.cassette, `${headerLine()}\n`, "utf8");
350
410
  return options.adapters.map((adapter) => {
351
- const occurrences = /* @__PURE__ */ new Map();
411
+ const occurrences = new Map(seeds.get(adapter.id) ?? []);
352
412
  return {
353
413
  ...adapter,
354
414
  id: adapter.id,
@@ -644,7 +704,11 @@ function readCassette(path) {
644
704
  * identical calls whose live completions were appended out of order
645
705
  * still replay to the callers that made them (v1.31.0 review P2); a
646
706
  * group with any unnumbered row (recorded before v1.32.0) keeps file
647
- * order.
707
+ * order. A duplicate occurrence inside a fully numbered group
708
+ * refuses the whole cassette with a typed ConfigError naming the
709
+ * adapter and hash: it means two recorder sessions wrote the file
710
+ * concurrently, and serving either order would hand a caller the
711
+ * wrong exchange (v1.32.0 review P2).
648
712
  * A call after the last occurrence is a miss: under `onMiss: 'throw'`
649
713
  * it raises a VcrMissError whose `recordedOccurrences` says the hash
650
714
  * WAS recorded but is exhausted, and under `'passthrough'` it
@@ -682,15 +746,7 @@ function replay(options) {
682
746
  const last = row.events[row.events.length - 1];
683
747
  if (terminals !== 1 || last === void 0 || !isTerminalEvent(last)) throw new ConfigError(`${options.cassette} row ${String(index + 1)} (adapter '${row.adapterId}', hash ${row.requestHash.slice(0, 12)}) does not record one completed exchange: expected exactly one trailing terminal event (finish or error), found ${String(terminals)}; record the cassette again on a current engine`);
684
748
  });
685
- const byAdapter = /* @__PURE__ */ new Map();
686
- for (const row of rows) {
687
- const forAdapter = byAdapter.get(row.adapterId) ?? /* @__PURE__ */ new Map();
688
- const occurrences = forAdapter.get(row.requestHash) ?? [];
689
- occurrences.push(row);
690
- forAdapter.set(row.requestHash, occurrences);
691
- byAdapter.set(row.adapterId, forAdapter);
692
- }
693
- for (const forAdapter of byAdapter.values()) for (const occurrences of forAdapter.values()) if (occurrences.every((row) => row.occurrence !== void 0)) occurrences.sort((a, b) => (a.occurrence ?? 0) - (b.occurrence ?? 0));
749
+ const byAdapter = groupRows(rows, options.cassette);
694
750
  const live = new Map((options.adapters ?? []).map((adapter) => [adapter.id, adapter]));
695
751
  return [.../* @__PURE__ */ new Set([...byAdapter.keys(), ...live.keys()])].map((adapterId) => {
696
752
  const recorded = byAdapter.get(adapterId) ?? /* @__PURE__ */ new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/testing",
3
- "version": "1.32.0",
3
+ "version": "1.33.0",
4
4
  "description": "Rulvar test harness: createTestEngine, FakeAdapter, VCR cassettes, replay-strict runs, matchers.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -26,7 +26,7 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@rulvar/core": "1.32.0"
29
+ "@rulvar/core": "1.33.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^22.20.0",