Files
suaveplan/openspec/changes/enterprise-streams-redesign/specs/streams/spec.md
2026-05-27 23:29:53 +01:00

37 KiB
Raw Blame History

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"