@rulvar/testing 1.32.0 → 1.34.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,14 @@ 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). The numbering ends at
158
+ * `Number.MAX_SAFE_INTEGER`: a session refuses with a typed
159
+ * ConfigError to claim a number past it, before dispatching the
160
+ * provider and before touching the file (v1.33.0 review P3).
154
161
  */
155
162
  occurrence?: number;
156
163
  requestHash: string;
@@ -195,8 +202,26 @@ declare function requestHash(req: ChatRequest): string;
195
202
  * the `stream()` call itself and persists it on the completed row,
196
203
  * so replay can restore the caller to response association even when
197
204
  * 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.
205
+ * P2). A later `record()` call on the same cassette file is an
206
+ * appending session: the existing file is read and validated first
207
+ * (a target that was never a cassette, a header whose hashVersion is
208
+ * not the one this build records under, and a file whose occurrence
209
+ * numbering is already ambiguous all refuse with a typed
210
+ * ConfigError), and every hash counter is seeded past the numbers
211
+ * already on disk, so the numbering continues where the file left
212
+ * off instead of restarting at zero (v1.32.0 review P2). One
213
+ * recorder session may be active on a cassette at a time: two
214
+ * concurrently constructed recorders seed identically and claim
215
+ * colliding numbers, which replay refuses as ambiguous instead of
216
+ * silently serving either order. The numbering ends at
217
+ * `Number.MAX_SAFE_INTEGER`: a group that already numbers it refuses
218
+ * the appending session at construction, and a session whose counter
219
+ * would pass it refuses that call before dispatching the provider,
220
+ * both with a typed ConfigError and without touching the file,
221
+ * because the next float increment would stall at 2 ** 53 and
222
+ * silently duplicate one unsafe number on every following row
223
+ * (v1.33.0 review P3). The wrapped adapters are drop-in:
224
+ * same ids, providers, caps, and event streams.
200
225
  */
201
226
  declare function record(options: {
202
227
  adapters: ProviderAdapter[];
@@ -262,7 +287,11 @@ declare function readCassette(path: string): VcrCassette;
262
287
  * identical calls whose live completions were appended out of order
263
288
  * still replay to the callers that made them (v1.31.0 review P2); a
264
289
  * group with any unnumbered row (recorded before v1.32.0) keeps file
265
- * order.
290
+ * order. A duplicate occurrence inside a fully numbered group
291
+ * refuses the whole cassette with a typed ConfigError naming the
292
+ * adapter and hash: it means two recorder sessions wrote the file
293
+ * concurrently, and serving either order would hand a caller the
294
+ * wrong exchange (v1.32.0 review P2).
266
295
  * A call after the last occurrence is a miss: under `onMiss: 'throw'`
267
296
  * it raises a VcrMissError whose `recordedOccurrences` says the hash
268
297
  * 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,46 @@ 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 numbering ends at
393
+ * `Number.MAX_SAFE_INTEGER`: a group that already numbers it refuses
394
+ * the appending session at construction, and a session whose counter
395
+ * would pass it refuses that call before dispatching the provider,
396
+ * both with a typed ConfigError and without touching the file,
397
+ * because the next float increment would stall at 2 ** 53 and
398
+ * silently duplicate one unsafe number on every following row
399
+ * (v1.33.0 review P3). The wrapped adapters are drop-in:
400
+ * same ids, providers, caps, and event streams.
346
401
  */
347
402
  function record(options) {
348
403
  const redact = options.redact ? (value) => defaultRedact(options.redact ? options.redact(value) : value) : defaultRedact;
349
- if (!existsSync(options.cassette)) writeFileSync(options.cassette, `${headerLine()}\n`, "utf8");
404
+ const seeds = /* @__PURE__ */ new Map();
405
+ if (existsSync(options.cassette)) {
406
+ const existing = readCassette(options.cassette);
407
+ 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`);
408
+ for (const [adapterId, forAdapter] of groupRows(existing.rows, options.cassette)) {
409
+ const forSeeds = /* @__PURE__ */ new Map();
410
+ for (const [hash, rows] of forAdapter) {
411
+ let highest = -1;
412
+ for (const row of rows) if (row.occurrence !== void 0 && row.occurrence > highest) highest = row.occurrence;
413
+ if (highest >= Number.MAX_SAFE_INTEGER) throw new ConfigError(`${options.cassette} already numbers occurrence ${String(Number.MAX_SAFE_INTEGER)} for adapter '${adapterId}' hash ${hash.slice(0, 12)}; the numbering has reached the safe integer ceiling, so no further exchange for this request can be appended; record a fresh cassette instead`);
414
+ forSeeds.set(hash, highest + 1);
415
+ }
416
+ seeds.set(adapterId, forSeeds);
417
+ }
418
+ } else writeFileSync(options.cassette, `${headerLine()}\n`, "utf8");
350
419
  return options.adapters.map((adapter) => {
351
- const occurrences = /* @__PURE__ */ new Map();
420
+ const occurrences = new Map(seeds.get(adapter.id) ?? []);
352
421
  return {
353
422
  ...adapter,
354
423
  id: adapter.id,
@@ -357,6 +426,7 @@ function record(options) {
357
426
  stream(req, signal) {
358
427
  const hash = requestHash(req);
359
428
  const occurrence = occurrences.get(hash) ?? 0;
429
+ if (!Number.isSafeInteger(occurrence)) throw new ConfigError(`${options.cassette} has no safe occurrence number left for adapter '${adapter.id}' hash ${hash.slice(0, 12)}; an earlier call in this session claimed ${String(Number.MAX_SAFE_INTEGER)}, so this exchange cannot be numbered; record a fresh cassette instead`);
360
430
  occurrences.set(hash, occurrence + 1);
361
431
  return (async function* () {
362
432
  const events = [];
@@ -644,7 +714,11 @@ function readCassette(path) {
644
714
  * identical calls whose live completions were appended out of order
645
715
  * still replay to the callers that made them (v1.31.0 review P2); a
646
716
  * group with any unnumbered row (recorded before v1.32.0) keeps file
647
- * order.
717
+ * order. A duplicate occurrence inside a fully numbered group
718
+ * refuses the whole cassette with a typed ConfigError naming the
719
+ * adapter and hash: it means two recorder sessions wrote the file
720
+ * concurrently, and serving either order would hand a caller the
721
+ * wrong exchange (v1.32.0 review P2).
648
722
  * A call after the last occurrence is a miss: under `onMiss: 'throw'`
649
723
  * it raises a VcrMissError whose `recordedOccurrences` says the hash
650
724
  * WAS recorded but is exhausted, and under `'passthrough'` it
@@ -682,15 +756,7 @@ function replay(options) {
682
756
  const last = row.events[row.events.length - 1];
683
757
  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
758
  });
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));
759
+ const byAdapter = groupRows(rows, options.cassette);
694
760
  const live = new Map((options.adapters ?? []).map((adapter) => [adapter.id, adapter]));
695
761
  return [.../* @__PURE__ */ new Set([...byAdapter.keys(), ...live.keys()])].map((adapterId) => {
696
762
  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.34.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.34.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^22.20.0",