Files
2026-05-27 23:29:53 +01:00

625 lines
37 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Capability Spec Delta: streams (enterprise-streams-redesign)
**Package**: `@suaveplan/streams`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
This delta is authored against an empty folded spec (`openspec/specs/streams/spec.md` does not exist on genesis — the prior `enterprise-streams` change archived without folding). All requirements below land as **ADDED** on archive.
## ADDED Requirements
### Requirement: BaseTransform abstract class
The package SHALL export an abstract `BaseTransform` class providing the canonical plugin contract for stream transforms. Subclasses implement `process<T>(input: T, options?: TransformOptions): Promise<T>`. Every subclass inherits a uniform hook surface, event surface, parameter introspection, and a `toTransformStream<TIn, TOut>(): TransformStream<TIn, TOut>` bridge to WHATWG Streams.
#### Scenario: Subclass implements process and is invokable
- **WHEN** a consumer defines `class JsonTransform extends BaseTransform` with `name = "json"` and a concrete `process(input)` that JSON-stringifies the input
- **THEN** `new JsonTransform().process({ a: 1 })` returns `'{"a":1}'`
#### Scenario: Hooks fire in registration order around process
- **WHEN** `t.hook("before", payloadA => payloadA)` and `t.hook("before", payloadB => payloadB)` are registered, then `t.process(...)` runs
- **THEN** the first hook receives the original payload and its return value is passed to the second hook before the wrapped operation runs
#### Scenario: Events fire without affecting return value
- **WHEN** a consumer registers `t.on("complete", payload => sideEffect(payload))` and `process` runs to completion
- **THEN** the side effect is observed and `process` still resolves with the operation's actual return value
#### Scenario: toTransformStream returns a usable WHATWG TransformStream
- **WHEN** a consumer calls `t.toTransformStream()` and pipes a `ReadableStream` through it
- **THEN** every chunk is processed by `t.process(chunk)` and the result is enqueued to the readable side
#### Scenario: dispose releases resources idempotently
- **WHEN** `t.dispose()` is called twice in succession
- **THEN** internal hooks and event listeners are cleared on the first call; the second call is a no-op and does not throw
**Verified by:** `src/base/transform/transform.test.ts::"subclass process returns transformed value"` AND `src/base/transform/transform.test.ts::"hooks fire in registration order with payload chaining"` AND `src/base/transform/transform.test.ts::"events fire without affecting return value"` AND `src/base/transform/transform.test.ts::"toTransformStream pipes through process"` AND `src/base/transform/transform.test.ts::"dispose is idempotent and clears hooks and listeners"` AND `src/base/transform/transform.spec-conformance.test.ts::"hook payload type is inferred from event name literal"`
---
### Requirement: TransformOptions parameter and Zod schema
The package SHALL accept `TransformOptions` on every `process()` call: `{ signal?: AbortSignal; clock?: Clock; logger?: LoggerLike }`. The schema is exported as `TransformOptionsSchema` from a sibling `*.schemas.ts`.
#### Scenario: Aborted signal rejects mid-process
- **WHEN** `t.process(input, { signal })` runs and `signal.abort(reason)` fires before completion
- **THEN** the returned promise rejects with `StreamError("STREAM.ABORTED")` whose `cause` is the abort reason
#### Scenario: Schema rejects unknown options at the boundary
- **WHEN** `TransformOptionsSchema.parse({ unknownKey: 1 })` is invoked
- **THEN** Zod throws with a `ZodError` listing `unknownKey` (strict object schema)
**Verified by:** `src/base/transform/transform.test.ts::"aborted signal rejects process with STREAM.ABORTED and propagates cause"` AND `src/base/transform/transform.schemas.test.ts::"TransformOptionsSchema rejects unknown keys"`
---
### Requirement: StreamManager chainable pipeline
The package SHALL export `StreamManager` with `pipe(...transforms: BaseTransform[]): StreamManager`, `execute<T>(input: T, options?: ExecuteOptions): Promise<T>`, `clear(): StreamManager`, and `getTransforms(): readonly BaseTransform[]`.
#### Scenario: Sequential execution feeds output into next transform
- **WHEN** `manager.pipe(a, b).execute(input)` runs
- **THEN** `a.process(input)` resolves to `x`, then `b.process(x)` resolves to the final value
#### Scenario: Empty pipeline returns input unchanged
- **WHEN** `new StreamManager().execute(input)` runs without any pipe
- **THEN** the result is exactly `input` (referential equality)
#### Scenario: Error in middle transform aborts downstream
- **WHEN** transform `b` throws and pipeline is `pipe(a, b, c)`
- **THEN** `c.process` is never invoked; the error from `b` propagates to the caller wrapped in `StreamError("STREAM.TRANSFORM_FAILED")`
#### Scenario: clear() empties the pipeline
- **WHEN** `manager.pipe(a).clear().getTransforms()` is called
- **THEN** the returned array has length 0
**Verified by:** `src/manager/manager/manager.test.ts::"pipe chains transforms in order"` AND `src/manager/manager/manager.test.ts::"empty pipeline returns input unchanged"` AND `src/manager/manager/manager.test.ts::"error in middle transform aborts downstream and wraps in STREAM.TRANSFORM_FAILED"` AND `src/manager/manager/manager.test.ts::"clear() empties pipeline"`
---
### Requirement: TransformFactory registry
The package SHALL export `TransformFactory` with static `create(type, ...args)` and instance `create(type, ...args)`, where `type` is a literal string keyed against a registry of bundled transforms. Unknown types raise `StreamError("STREAM.UNKNOWN_TRANSFORM_TYPE")`. The registry is open: `TransformFactory.register(type, factory)` adds new types at runtime.
#### Scenario: Built-in type resolves to correct subclass instance
- **WHEN** `TransformFactory.create("crypto", { engine, key, operation: "encrypt" })` is called
- **THEN** the returned instance is `instanceof CryptoTransform` with the supplied options
#### Scenario: Unknown type throws structured error
- **WHEN** `TransformFactory.create("nonexistent")` is called
- **THEN** the call throws `StreamError` with code `"STREAM.UNKNOWN_TRANSFORM_TYPE"` and a message naming `"nonexistent"`
#### Scenario: Custom registration is honoured
- **WHEN** a consumer calls `TransformFactory.register("upper", () => new UpperCaseTransform())` then `create("upper")`
- **THEN** the returned instance is the registered subclass
**Verified by:** `src/factory/factory/factory.test.ts::"built-in type resolves to subclass instance"` AND `src/factory/factory/factory.test.ts::"unknown type throws STREAM.UNKNOWN_TRANSFORM_TYPE"` AND `src/factory/factory/factory.test.ts::"custom registration is honoured"` AND `src/factory/factory/factory.spec-conformance.test.ts::"create() narrows return type by literal type argument"`
---
### Requirement: BaseDuplexStream primitive
The package SHALL export `BaseDuplexStream<R, W>` with constructor `(source: StreamSource<R>, sink: StreamSink<W>, options?: DuplexStreamOptions)` exposing `readable: ReadableStream<R>` and `writable: WritableStream<W>`. Supports byte-mode and object-mode via `options.objectMode`. Honours `highWaterMark`.
#### Scenario: Bidirectional flow
- **WHEN** a consumer writes a chunk to `duplex.writable` and reads from `duplex.readable` whose `pull` is wired to enqueue an echo
- **THEN** the echoed chunk is read back
#### Scenario: cancel propagates to source
- **WHEN** `duplex.readable.cancel(reason)` is called
- **THEN** the source's `cancel(reason)` is invoked exactly once with the same reason
#### Scenario: Object-mode bypasses byteLength accounting
- **WHEN** `objectMode: true` and 1000 non-byte objects are written
- **THEN** the writable accepts them subject to `highWaterMark` count, not byte size
**Verified by:** `src/primitives/duplex/duplex.test.ts::"bidirectional flow round-trips a chunk"` AND `src/primitives/duplex/duplex.test.ts::"cancel propagates reason to source.cancel"` AND `src/primitives/duplex/duplex.test.ts::"object-mode honours count-based highWaterMark"`
---
### Requirement: NetworkStreamImpl with metadata
The package SHALL export `NetworkStreamImpl` extending the duplex pattern with `id: string`, optional `protocol?: string`, `metadata: Map<string, unknown>`, `closed: boolean`, `close(): Promise<void>`, and `reset(): Promise<void>`. `close()` cleanly closes both sides; `reset()` aborts both sides with `StreamError("STREAM.NETWORK_RESET")`.
#### Scenario: Metadata is accessible after construction
- **WHEN** `createNetworkStream("peer-1", "webrtc", new Map([["userId", "u-42"]]))` is called
- **THEN** `stream.id === "peer-1"`, `stream.protocol === "webrtc"`, and `stream.metadata.get("userId") === "u-42"`
#### Scenario: close() flips closed flag and prevents further writes
- **WHEN** `await stream.close()` runs and a subsequent writer attempts to write
- **THEN** `stream.closed === true` and the write rejects
#### Scenario: reset() aborts with STREAM.NETWORK_RESET
- **WHEN** `await stream.reset()` runs while a reader is pending
- **THEN** the reader rejects with `StreamError("STREAM.NETWORK_RESET")`
**Verified by:** `src/primitives/network/network.test.ts::"metadata id and protocol accessible after construction"` AND `src/primitives/network/network.test.ts::"close() flips closed flag and rejects subsequent writes"` AND `src/primitives/network/network.test.ts::"reset() aborts pending reader with STREAM.NETWORK_RESET"`
---
### Requirement: CryptoTransform with pluggable streaming AEAD
The package SHALL export `CryptoTransform` taking `{ engine: StreamingAeadEngine, key: Uint8Array, operation: "encrypt" | "decrypt" }` and exposing the `BaseTransform` plugin surface plus `crypto:before-chunk` and `crypto:after-chunk` hooks and `crypto:start` / `crypto:chunk` / `crypto:complete` / `crypto:error` events. The transform binds an 8-byte counter and 1-byte final-flag into the AEAD AAD, so reorder, truncation, duplication, and tamper all surface as decryption failure.
#### Scenario: Encrypt-decrypt round-trip yields identity
- **WHEN** the same key is used to construct an `encrypt` and a `decrypt` transform, and a sequence of plaintext frames pipes through both via `toTransformStream()`
- **THEN** the output equals the input frame-by-frame
#### Scenario: Truncation is detected
- **WHEN** an encrypted stream's last frame is dropped before decryption
- **THEN** the `decrypt` transform rejects with `StreamError("STREAM.CRYPTO_DECRYPT_FAILED")` referencing AAD verification failure
#### Scenario: Reorder is detected
- **WHEN** two encrypted middle frames are swapped before decryption
- **THEN** the `decrypt` transform rejects with `StreamError("STREAM.CRYPTO_DECRYPT_FAILED")`
#### Scenario: Hooks observe and may modify chunks
- **WHEN** `t.hook("crypto:before-chunk", p => ({ ...p, chunk: redact(p.chunk) }))` is registered and a frame passes through
- **THEN** the redacted chunk is what gets sealed
#### Scenario: Events fire in canonical order
- **WHEN** an `encrypt` transform processes 3 frames to completion
- **THEN** events fire as `crypto:start`, `crypto:chunk` ×3, `crypto:complete` in that order
#### Scenario: dispose zeroizes key buffer
- **WHEN** `t.dispose()` is called on an encrypt transform
- **THEN** the internal copy of the key buffer is overwritten with zeros (via `@suaveplan/crypto-utils.zeroize`)
**Verified by:** `src/transforms/crypto/crypto.test.ts::"encrypt-decrypt round-trip preserves frame sequence"` AND `src/transforms/crypto/crypto.test.ts::"truncation produces STREAM.CRYPTO_DECRYPT_FAILED"` AND `src/transforms/crypto/crypto.test.ts::"reorder produces STREAM.CRYPTO_DECRYPT_FAILED"` AND `src/transforms/crypto/crypto.test.ts::"crypto:before-chunk hook can replace plaintext before sealing"` AND `src/transforms/crypto/crypto.test.ts::"events fire in canonical order start/chunk*N/complete"` AND `src/transforms/crypto/crypto.spec-conformance.test.ts::"dispose() zeroizes key buffer"`
---
### Requirement: SplitJoinTransform object-mode chunking
The package SHALL export `SplitJoinTransform` with operations `"split"` and `"join"`. Split takes `{ chunkSize, overlap }` and accepts `string`, `ArrayBuffer`, or `T[]`. Join accepts `{ chunks, metadata? }` shape and reconstructs the original. Type guards `isSplittable(value)` and `isJoinable(value)` are exported.
#### Scenario: String split with overlap reproduces every character
- **WHEN** a 100-char string is split with `chunkSize: 30, overlap: 5` and joined back with `removeOverlap: true`
- **THEN** the joined string equals the original
#### Scenario: ArrayBuffer split preserves byte-for-byte content
- **WHEN** a 1024-byte `ArrayBuffer` is split with `chunkSize: 256, overlap: 0` and joined back
- **THEN** the joined buffer equals the original byte-for-byte
#### Scenario: Type guard rejects non-splittable input
- **WHEN** `isSplittable({ foo: 1 })` is called
- **THEN** the result is `false`
**Verified by:** `src/transforms/split-join/split-join.test.ts::"string split with overlap round-trips via removeOverlap"` AND `src/transforms/split-join/split-join.test.ts::"ArrayBuffer split round-trips byte-for-byte"` AND `src/transforms/split-join/split-join.test.ts::"isSplittable rejects plain objects"`
---
### Requirement: Functional transform factories preserved
The package SHALL preserve the function-form factories from the prior `enterprise-streams` archive: `mapStream`, `filterStream`, `flatMapStream`, `batchStream`, `windowStream`, `throttleStream`, `limitStream`, `dedupStream`, `tapStream`, `encodeTextStream`, `decodeTextStream`, `compressStream`, `decompressStream`, `fromArray`, `fromBytes`, `fromAsyncGenerator`, `toArray`, `toBytes`, `forEach`, `pipeline`, `fanOut`, `merge`, `instrumentTransform`, `fromNodeReadable`, `toNodeReadable`, `fromNodeWritable`. The corresponding `BaseTransform` subclasses delegate to these factories via `toTransformStream()` — no parallel implementation.
#### Scenario: MapTransform.toTransformStream() and mapStream(fn) are observably equivalent
- **WHEN** the same chunk is piped through `mapStream(fn)` directly and through `new MapTransform({ fn }).toTransformStream()`
- **THEN** the output chunks are equal
#### Scenario: Functional factory is tree-shakeable
- **WHEN** a consumer imports only `mapStream` from `@suaveplan/streams/transforms/map/map`
- **THEN** the resulting bundle does not include `BaseTransform` or any unrelated transform
**Verified by:** `src/transforms/map/map.test.ts::"MapTransform.toTransformStream and mapStream(fn) produce equivalent output"` AND `tests/tree-shake.test.ts::"importing only mapStream excludes BaseTransform"`
---
### Requirement: ChunkBytes byte-level re-framing
The package SHALL export `chunkBytes(frameSize: number, options?: { tail?: 'emit' | 'pad' | 'drop' }): TransformStream<Uint8Array, Uint8Array>` and `ChunkBytesTransform` class. Re-emits any input byte stream as a stream of frames of exactly `frameSize` bytes; the final partial frame is handled per the `tail` policy (default: `'emit'`).
#### Scenario: All-equal-size frames concatenate to original input
- **WHEN** an input stream of total length `n × frameSize` bytes (chunked arbitrarily) is piped through `chunkBytes(frameSize)`
- **THEN** every output frame has length `frameSize` and concatenated output equals input byte-for-byte
#### Scenario: Tail policy 'emit' yields a final partial frame
- **WHEN** an input of `frameSize × 2 + 7` bytes is piped through `chunkBytes(frameSize, { tail: 'emit' })`
- **THEN** the third emitted frame has length 7 and concatenated output equals input
#### Scenario: Tail policy 'pad' zero-pads the final frame
- **WHEN** the same input is piped through `chunkBytes(frameSize, { tail: 'pad' })`
- **THEN** the third frame has length `frameSize` and bytes `frameSize - 7..frameSize - 1` are 0
#### Scenario: Tail policy 'drop' discards incomplete final frame
- **WHEN** the same input is piped through `chunkBytes(frameSize, { tail: 'drop' })`
- **THEN** only two frames are emitted
**Verified by:** `src/transforms/chunk-bytes/chunk-bytes.test.ts::"all frames are exactly frameSize bytes"` AND `src/transforms/chunk-bytes/chunk-bytes.test.ts::"tail emit yields final partial frame and round-trips"` AND `src/transforms/chunk-bytes/chunk-bytes.test.ts::"tail pad zero-pads final frame to frameSize"` AND `src/transforms/chunk-bytes/chunk-bytes.test.ts::"tail drop discards incomplete final frame"` AND `tests/property.test.ts::"chunkBytes byte-conservation under arbitrary input chunking"`
---
### Requirement: CoalesceBytes batching with latency cap
The package SHALL export `coalesceBytes(minBytes: number, options?: { maxLatencyMs?: number; clock?: Clock }): TransformStream<Uint8Array, Uint8Array>`. Accumulates incoming chunks until the accumulator reaches `minBytes` or `maxLatencyMs` has elapsed since the first buffered chunk, then emits a single coalesced frame.
#### Scenario: Below-threshold inputs are buffered and emitted on close
- **WHEN** three 100-byte chunks pipe in (total 300 bytes) with `coalesceBytes(1024)` and the input then closes
- **THEN** a single 300-byte frame is emitted before the output closes
#### Scenario: Threshold reached emits immediately
- **WHEN** chunks accumulate to ≥ `minBytes` mid-stream
- **THEN** a single coalesced frame is emitted at that point and the accumulator resets
#### Scenario: maxLatencyMs forces an early flush
- **WHEN** a single 50-byte chunk is buffered with `coalesceBytes(1024, { maxLatencyMs: 100 })` and 100 ms elapses (FakeClock advance)
- **THEN** a 50-byte frame is emitted
**Verified by:** `src/transforms/coalesce-bytes/coalesce-bytes.test.ts::"below-threshold buffers and flushes on close"` AND `src/transforms/coalesce-bytes/coalesce-bytes.test.ts::"threshold reached emits coalesced frame and resets"` AND `src/transforms/coalesce-bytes/coalesce-bytes.test.ts::"maxLatencyMs forces early flush via FakeClock"`
---
### Requirement: SlidingBytes overlapping window
The package SHALL export `slidingBytes(size: number, stride: number): TransformStream<Uint8Array, Uint8Array>`. Emits overlapping byte windows of length `size` advancing by `stride` bytes per emission.
#### Scenario: Adjacent windows overlap by size − stride bytes
- **WHEN** a 16-byte input is piped through `slidingBytes(8, 4)`
- **THEN** windows are emitted at offsets 0, 4, 8, each of length 8; consecutive windows share their last `size − stride` bytes
#### Scenario: Stride larger than size skips bytes
- **WHEN** `slidingBytes(4, 8)` runs over a 24-byte input
- **THEN** non-overlapping windows are emitted at offsets 0, 8, 16; the 4 bytes between each window are discarded
**Verified by:** `src/transforms/sliding-bytes/sliding-bytes.test.ts::"adjacent windows overlap by size minus stride"` AND `src/transforms/sliding-bytes/sliding-bytes.test.ts::"stride larger than size skips bytes between windows"`
---
### Requirement: LengthPrefix encode and decode
The package SHALL export `lengthPrefixEncode(prefixBytes: 1 | 2 | 4 | 8, options?: { byteOrder?: 'le' | 'be' })` and a matching `lengthPrefixDecode(prefixBytes, options?: { byteOrder?: 'le' | 'be'; maxFrameBytes?: number })`. Encode prepends a length prefix to each chunk. Decode reads a length prefix, accumulates that many bytes, and emits the framed payload.
#### Scenario: Encode-decode round-trip preserves frame boundaries
- **WHEN** three frames of varying sizes are piped through `lengthPrefixEncode(4) → lengthPrefixDecode(4)`
- **THEN** the three output frames equal the three input frames byte-for-byte
#### Scenario: maxFrameBytes overflow throws STREAM.FRAME_TOO_LARGE
- **WHEN** decode encounters a length prefix exceeding `maxFrameBytes`
- **THEN** the transform errors the readable side with `StreamError("STREAM.FRAME_TOO_LARGE")` containing the prefix value
#### Scenario: Truncated frame at close throws STREAM.TRUNCATED_FRAME
- **WHEN** the input closes mid-frame with fewer bytes than the declared length prefix
- **THEN** the transform errors with `StreamError("STREAM.TRUNCATED_FRAME")`
#### Scenario: Big-endian and little-endian round-trip
- **WHEN** encode/decode is run with `byteOrder: 'be'` and a frame whose length is `0x01_00`
- **THEN** the prefix bytes on the wire are `[0x00, 0x00, 0x01, 0x00]` (4-byte BE) and decode recovers the frame
**Verified by:** `src/transforms/length-prefix-encode/length-prefix-encode.test.ts::"encode-decode round-trip preserves three frames"` AND `src/transforms/length-prefix-decode/length-prefix-decode.test.ts::"maxFrameBytes overflow throws STREAM.FRAME_TOO_LARGE"` AND `src/transforms/length-prefix-decode/length-prefix-decode.test.ts::"truncated frame at close throws STREAM.TRUNCATED_FRAME"` AND `src/transforms/length-prefix-encode/length-prefix-encode.test.ts::"big-endian prefix encoding produces expected bytes"`
---
### Requirement: Partition single-pass split
The package SHALL export `partition<T>(source: ReadableStream<T>, predicate: (chunk: T) => boolean): { truthy: ReadableStream<T>; falsy: ReadableStream<T> }`. Each input chunk is routed to exactly one of the two output streams (no duplication, unlike `tee`/`fanOut`). Backpressure on either output applies to the source.
#### Scenario: Union of outputs equals input, in order per side
- **WHEN** `partition(source, n => n % 2 === 0)` is applied to `[1,2,3,4,5,6]`
- **THEN** the truthy stream yields `[2,4,6]` and the falsy stream yields `[1,3,5]`
#### Scenario: Slow consumer on one side applies backpressure to source
- **WHEN** the truthy consumer is held while the source has 1000 items
- **THEN** `source` is read at most up to the implementation's high-water-mark before pulls block
**Verified by:** `src/pipeline/partition/partition.test.ts::"union of outputs equals input with per-side ordering preserved"` AND `src/pipeline/partition/partition.test.ts::"slow consumer applies backpressure to source"` AND `tests/property.test.ts::"partition order preservation per side under arbitrary inputs"`
---
### Requirement: Route N-way single-pass split
The package SHALL export `route<T, K extends string>(source, keyFn, keys, options?: { onUnknownKey?: 'drop' | 'error' }): Record<K, ReadableStream<T>>` and a non-string-key variant `routeBy<T, K>(source, keyFn, keys, options?): Map<K, ReadableStream<T>>`.
#### Scenario: Each chunk goes to exactly one keyed branch
- **WHEN** `route(source, c => c.kind, ["a", "b"])` is applied
- **THEN** every input chunk arrives in exactly one branch matching its `kind`
#### Scenario: Unknown key with onUnknownKey 'drop' silently discards
- **WHEN** a chunk maps to a key not in `keys` and `onUnknownKey: 'drop'`
- **THEN** the chunk is consumed from source but emitted nowhere
#### Scenario: Unknown key with onUnknownKey 'error' fails the source
- **WHEN** a chunk maps to a key not in `keys` and `onUnknownKey: 'error'`
- **THEN** every output stream errors with `StreamError("STREAM.ROUTE_UNKNOWN_KEY")` containing the offending key
**Verified by:** `src/pipeline/route/route.test.ts::"each chunk routed to exactly one keyed branch"` AND `src/pipeline/route/route.test.ts::"unknown key with drop policy silently discards"` AND `src/pipeline/route/route.test.ts::"unknown key with error policy errors all branches with STREAM.ROUTE_UNKNOWN_KEY"` AND `src/pipeline/route/route.spec-conformance.test.ts::"route() return type is Record keyed by literal keys argument"`
---
### Requirement: Zip sample-aligned merge
The package SHALL export `zip<T extends readonly unknown[]>(streams: { [K in keyof T]: ReadableStream<T[K]> }, options?: { onShortInput?: 'close' | 'error' | 'pad'; padValue?: unknown }): ReadableStream<T>`. Emits one tuple per round once every input has produced a chunk.
#### Scenario: Equal-length streams zip to tuples
- **WHEN** `zip([fromArray([1,2,3]), fromArray(["a","b","c"])])` is collected
- **THEN** the result is `[[1,"a"], [2,"b"], [3,"c"]]`
#### Scenario: Short input with onShortInput 'close' ends output at first end
- **WHEN** one input stream ends after 2 chunks, the other after 5, with default `onShortInput: 'close'`
- **THEN** the zipped output emits 2 tuples and closes
#### Scenario: Short input with onShortInput 'error' rejects
- **WHEN** any input ends before others with `onShortInput: 'error'`
- **THEN** the zipped output errors with `StreamError("STREAM.ZIP_LENGTH_MISMATCH")`
**Verified by:** `src/pipeline/zip/zip.test.ts::"equal-length streams zip to tuples"` AND `src/pipeline/zip/zip.test.ts::"short input with close policy ends at first end"` AND `src/pipeline/zip/zip.test.ts::"short input with error policy throws STREAM.ZIP_LENGTH_MISMATCH"` AND `tests/property.test.ts::"zip then unzip round-trips for equal-length inputs"`
---
### Requirement: Concat sequential drain
The package SHALL export `concat<T>(streams: ReadableStream<T>[]): ReadableStream<T>`. Drains each stream to completion in order before pulling from the next.
#### Scenario: Order is preserved across boundaries
- **WHEN** `concat([fromArray([1,2,3]), fromArray([4,5,6])])` is collected
- **THEN** the result is `[1,2,3,4,5,6]`
#### Scenario: Error in one stream propagates and halts
- **WHEN** the second of three streams errors mid-drain
- **THEN** the third stream is never read; the error propagates wrapped in `StreamError("STREAM.PIPE_FAILED")`
**Verified by:** `src/pipeline/concat/concat.test.ts::"order preserved across stream boundaries"` AND `src/pipeline/concat/concat.test.ts::"error in middle stream halts and propagates STREAM.PIPE_FAILED"`
---
### Requirement: MergeOrdered k-way merge of sorted streams
The package SHALL export `mergeOrdered<T>(streams: ReadableStream<T>[], compare: (a: T, b: T) => number): ReadableStream<T>`. Performs a k-way merge of pre-sorted input streams.
#### Scenario: Three sorted streams merge to a globally sorted output
- **WHEN** three streams `[1,4,7]`, `[2,5,8]`, `[3,6,9]` are merged with ascending compare
- **THEN** the output is `[1,2,3,4,5,6,7,8,9]`
#### Scenario: Output is sorted iff inputs are sorted
- **WHEN** any input is unsorted
- **THEN** the output may not be sorted (documented limitation; not validated at runtime)
**Verified by:** `src/pipeline/merge-ordered/merge-ordered.test.ts::"three sorted streams merge to globally sorted output"` AND `tests/property.test.ts::"mergeOrdered output is sorted when all inputs are sorted"`
---
### Requirement: SafeBranch error isolation
The package SHALL export `safeBranch<TIn, TOut>(factory: (input: ReadableStream<TIn>) => ReadableStream<TOut>, options?: { bus?: EventBusLike; onError?: (err: unknown) => void }): (input: ReadableStream<TIn>) => ReadableStream<TOut>`. Wraps a sub-pipeline so that errors inside it do not propagate up; instead they emit `streams:branch-error` on the bus and/or invoke `onError`.
#### Scenario: Branch error becomes a bus event without propagation
- **WHEN** a branch factory throws on the third chunk and `bus` is provided
- **THEN** the bus receives `streams:branch-error` with the error; the outer pipeline does not error
#### Scenario: Output stream closes cleanly after branch error
- **WHEN** the branch errors and the wrapped factory's output stream is being read
- **THEN** the read completes (close) without throwing and no further chunks are emitted
**Verified by:** `src/pipeline/safe-branch/safe-branch.test.ts::"branch error emits bus event without propagating to outer pipeline"` AND `src/pipeline/safe-branch/safe-branch.test.ts::"output closes cleanly after branch error"`
---
### Requirement: ToAsyncIterable sink
The package SHALL export `toAsyncIterable<T>(stream: ReadableStream<T>, signal?: AbortSignal): AsyncIterable<T>`. Yields chunks until the stream closes or `signal` aborts.
#### Scenario: All chunks are yielded in order
- **WHEN** `toAsyncIterable(fromArray([1,2,3]))` is iterated with `for await`
- **THEN** the loop sees `1, 2, 3` in order
#### Scenario: AbortSignal stops iteration
- **WHEN** `signal.abort()` fires mid-iteration
- **THEN** the iterator's next `next()` rejects with `StreamError("STREAM.ABORTED")`
**Verified by:** `src/sinks/to-async-iterable/to-async-iterable.test.ts::"yields chunks in order"` AND `src/sinks/to-async-iterable/to-async-iterable.test.ts::"abort signal stops iteration with STREAM.ABORTED"`
---
### Requirement: Buffer utilities
The package SHALL export `getDataFromFile(filePath: string): Promise<Uint8Array>` (Node-only with runtime env detection), `bufferToUint8Array(buffer: Buffer | ArrayBuffer): Uint8Array`, and `createUint8ArraySlice(array: Uint8Array, start: number, end: number): Uint8Array`.
#### Scenario: getDataFromFile reads a Node file as Uint8Array
- **WHEN** in Node environment, `await getDataFromFile(path)` is called on an existing 4-byte file with bytes `[1,2,3,4]`
- **THEN** the result is a `Uint8Array` of length 4 with bytes `[1,2,3,4]`
#### Scenario: getDataFromFile throws in browser environment
- **WHEN** `globalThis.process` is undefined and `getDataFromFile(path)` is called
- **THEN** the call rejects with `StreamError("STREAM.FILE_BROWSER_NOT_SUPPORTED")`
#### Scenario: getDataFromFile throws on missing file
- **WHEN** `await getDataFromFile("/no/such/file")` is called in Node
- **THEN** the call rejects with `StreamError("STREAM.FILE_READ_FAILED")` whose `cause` is the underlying ENOENT error
#### Scenario: bufferToUint8Array preserves bytes from a Node Buffer
- **WHEN** `bufferToUint8Array(Buffer.from([0x10, 0x20]))` is called in Node
- **THEN** the returned `Uint8Array` has bytes `[0x10, 0x20]`
#### Scenario: createUint8ArraySlice produces an independent copy
- **WHEN** a slice is created and mutated
- **THEN** the original array is unchanged
**Verified by:** `src/utils/buffer/get-data-from-file/get-data-from-file.test.ts::"reads a Node file as Uint8Array"` AND `src/utils/buffer/get-data-from-file/get-data-from-file.test.ts::"throws STREAM.FILE_BROWSER_NOT_SUPPORTED when process is undefined"` AND `src/utils/buffer/get-data-from-file/get-data-from-file.test.ts::"throws STREAM.FILE_READ_FAILED with ENOENT cause for missing path"` AND `src/utils/buffer/buffer-to-uint8/buffer-to-uint8.test.ts::"preserves bytes from a Node Buffer"` AND `src/utils/buffer/uint8-slice/uint8-slice.test.ts::"slice mutation does not affect original"`
---
### Requirement: Stream utilities
The package SHALL export `streamToUint8Array(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): Promise<Uint8Array>`, `uint8ArrayToStream(data: Uint8Array, options?: { chunkSize?: number }): ReadableStream<Uint8Array>`, `createDataStream<T>(data: T, transformer: (data: T) => Uint8Array | Promise<Uint8Array>, options?: { chunkSize?: number }): Promise<ReadableStream<Uint8Array>>`, `processDataStream<T>(stream: ReadableStream<Uint8Array>, processor: (data: Uint8Array) => T | Promise<T>): Promise<T>`, and `validateStreamIntegrity(stream: ReadableStream<Uint8Array>, validate: (chunk: Uint8Array) => boolean): Promise<boolean>`.
#### Scenario: Round-trip identity
- **WHEN** `streamToUint8Array(uint8ArrayToStream(input))` is awaited
- **THEN** the result equals `input` byte-for-byte
#### Scenario: validateStreamIntegrity returns false on first invalid chunk
- **WHEN** any chunk fails the predicate
- **THEN** the call resolves to `false` without consuming further chunks
**Verified by:** `src/utils/stream/round-trip/round-trip.test.ts::"streamToUint8Array of uint8ArrayToStream is identity"` AND `src/utils/stream/validate-integrity/validate-integrity.test.ts::"returns false on first invalid chunk and short-circuits"`
---
### Requirement: Instrumented transform with OTel
The package SHALL export `instrumentTransform<TIn, TOut>(transform: TransformStream<TIn, TOut>, name: string, options?: InstrumentOptions): TransformStream<TIn, TOut>`. Wraps the input transform so each chunk produces a span `stream.transform.<name>`, increments counter `stream.chunks_total`, and records histogram `stream.transform_duration_ms`. Attributes include `stream.stage = name` plus user-supplied `options.attributes`.
#### Scenario: Each chunk emits a span and counter increment
- **WHEN** an in-memory `MeterProvider` is registered and 5 chunks pass through an instrumented map transform named `"upper"`
- **THEN** `stream.chunks_total` is incremented 5 times with attribute `stream.stage = "upper"`; 5 spans named `stream.transform.upper` are emitted
#### Scenario: Duration histogram records non-negative values
- **WHEN** chunks pass through an instrumented transform that sleeps 10 ms via `FakeClock`
- **THEN** every recorded `stream.transform_duration_ms` value is ≥ 0 (FakeClock returns 0; real clock returns positive)
**Verified by:** `src/instrument/transform/transform.test.ts::"each chunk emits a span and increments stream.chunks_total"` AND `src/instrument/transform/transform.spec-conformance.test.ts::"in-memory MeterProvider observes counter and histogram emissions"`
---
### Requirement: Node interop adapters
The package SHALL export `fromNodeReadable(readable: NodeReadable): ReadableStream<Uint8Array>`, `toNodeReadable(stream: ReadableStream<Uint8Array>): NodeReadable`, and `fromNodeWritable(writable: NodeWritable): WritableStream<Uint8Array>` from the `@suaveplan/streams/node/adapters/adapters` subpath.
#### Scenario: Node Readable round-trips through WHATWG
- **WHEN** a Node `Readable` emitting `[Buffer.from("a"), Buffer.from("b")]` is converted via `fromNodeReadable`, drained, and re-emitted via `toNodeReadable`
- **THEN** the final Node Readable yields the bytes of `"ab"`
#### Scenario: Node Writable receives all chunks
- **WHEN** a WHATWG `WritableStream` returned by `fromNodeWritable` is fed three chunks
- **THEN** the underlying Node Writable observes three `write` calls in order
**Verified by:** `src/node/adapters/adapters.test.ts::"Node Readable round-trips through fromNodeReadable and toNodeReadable"` AND `src/node/adapters/adapters.test.ts::"fromNodeWritable forwards chunks to underlying Writable"`
---
### Requirement: Structured error class with stable code registry
The package SHALL export `StreamError extends SuaveplanError<StreamErrorCode>` and a stable code registry registered via `defineCodes`. Codes:
- `STREAM.ABORTED`, `STREAM.BACKPRESSURE_EXCEEDED`, `STREAM.CHUNK_INVALID`, `STREAM.PIPE_FAILED`, `STREAM.TRANSFORM_FAILED`, `STREAM.SOURCE_EXHAUSTED`, `STREAM.READ_FAILED`, `STREAM.WRITE_FAILED`, `STREAM.CLOSED`
- `STREAM.FRAME_TOO_LARGE`, `STREAM.TRUNCATED_FRAME`, `STREAM.ROUTE_UNKNOWN_KEY`, `STREAM.ZIP_LENGTH_MISMATCH`, `STREAM.BRANCH_FAILED`
- `STREAM.CRYPTO_DECRYPT_FAILED`, `STREAM.CRYPTO_INVALID_FRAME`
- `STREAM.DUPLEX_CLOSED`, `STREAM.NETWORK_RESET`
- `STREAM.FILE_READ_FAILED`, `STREAM.FILE_BROWSER_NOT_SUPPORTED`
- `STREAM.UNKNOWN_TRANSFORM_TYPE`
#### Scenario: Every error code is thrown by at least one production path
- **WHEN** the code registry is enumerated and cross-checked against grep of `throw new StreamError`
- **THEN** every code appears in at least one production-path throw site (verified by `tests/error-coverage.test.ts`)
#### Scenario: Subclass narrows code union
- **WHEN** a consumer narrows `err.code === "STREAM.ABORTED"` with TypeScript
- **THEN** the narrowed branch type is the literal `"STREAM.ABORTED"`
**Verified by:** `tests/error-coverage.test.ts::"every StreamErrorCode appears in at least one production throw site"` AND `src/errors/error/error.spec-conformance.test.ts::"narrowing err.code preserves literal type"`
---
### Requirement: Subpath exports per feature module
The `package.json` `exports` map SHALL include one entry per feature module so consumers can tree-shake. The barrel `"."` is preserved for ergonomic top-level imports.
#### Scenario: Every feature directory under src/ has a corresponding subpath export
- **WHEN** `tests/dep-graph.test.ts` enumerates `src/**/index.ts` and matches against `package.json` `exports`
- **THEN** every feature index has a matching subpath export
#### Scenario: Side-effect-free imports
- **WHEN** the entry point `src/index.ts` is loaded
- **THEN** no side effects (OTel registration, global mutation) occur (verified via `tests/tree-shake.test.ts`)
**Verified by:** `tests/dep-graph.test.ts::"every feature index has a matching subpath export in package.json"` AND `tests/tree-shake.test.ts::"entry-point load produces no observable side effects"`
---
### Requirement: 100% coverage threshold
The `vitest.config.ts` SHALL set `thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 }` with `provider: 'istanbul'`. The pre-publish gate `bunx turbo test:coverage --filter=@suaveplan/streams` SHALL hard-fail below 100% on any of the four metrics.
#### Scenario: Coverage configuration is set
- **WHEN** the vitest config is parsed
- **THEN** all four thresholds equal 100 and provider is `'istanbul'`
**Verified by:** `tests/dep-graph.test.ts::"vitest.config.ts coverage thresholds are 100/100/100/100 with istanbul provider"`
---
### Requirement: AbortSignal propagation end-to-end
The package SHALL accept an `AbortSignal` on every async public entry point: `process`, `execute`, `toArray`, `toBytes`, `forEach`, `pipeline.collect`, `pipeline.drain`, `pipeline.forEach`, `streamToUint8Array`, `validateStreamIntegrity`, `toAsyncIterable`. On abort, in-flight operations cancel underlying readers/writers and reject pending promises with `StreamError("STREAM.ABORTED")`.
#### Scenario: Aborting collect cancels source
- **WHEN** `pipeline(fromArray([1,2,3,4,5]).pipeThrough(throttle({ everyMs: 100 }))).collect(signal)` runs and `signal.abort()` fires after 50 ms (FakeClock)
- **THEN** the promise rejects with `STREAM.ABORTED` and the source has its `cancel()` invoked
**Verified by:** `tests/integration.test.ts::"abort during pipeline.collect cancels source and rejects with STREAM.ABORTED"` AND `tests/integration.test.ts::"abort during streamToUint8Array cancels reader"`