9.8 KiB
Capability Spec Delta: packer (enterprise-packer-resilience)
Package: @suaveplan/packer
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
ADDED Requirements
Requirement: PACKER-22 — @suaveplan/retry on IO-backed source reads
The package SHALL route every IO-backed source pull (every reader.read()
call against a ReadableStream consumed by normalizeSource or the
abstract collect helper) through @suaveplan/retry's withRetry. The
retry policy MUST be configurable per call via a retryConfig?: RetryConfig
option on PackerOptions, PackChunkedOptions, and
UnpackChunkedOptions. When retryConfig is absent the package MUST
default to a single-attempt policy (maxAttempts: 1) so wire behaviour
matches cluster #1. When retryConfig is supplied the retry loop MUST
honour the caller's AbortSignal: cancellation MUST abort the retry loop
immediately (no further attempts, no further sleeps) and surface as a
RetryAbortedError.
Scenario: failing reader is retried up to maxAttempts
- WHEN
normalizeSourceconsumes aReadableStreamwhoseread()rejects the first two times and resolves on the third, withretryConfig: { maxAttempts: 3, delay: createConstantBackoff({ delay: 0 }) } - THEN the iterable yields the eventually-read bytes and the consumer observes three total read attempts
Scenario: AbortSignal cancels the retry loop mid-backoff
- WHEN the retry loop is sleeping between attempts and the caller's
AbortSignalfires - THEN the retry rejects with
RetryAbortedErrorand no furtherreader.read()is invoked
Scenario: default policy is a single attempt
- WHEN
retryConfigis not supplied and aread()rejects - THEN the rejection surfaces unchanged and the iterable terminates with the original error (no retries)
Verified by: src/retry/retryable-read.test.ts::"retries up to maxAttempts on a failing reader" AND src/retry/retryable-read.test.ts::"cancels the retry loop when AbortSignal fires mid-backoff" AND src/retry/retryable-read.spec-conformance.test.ts::"default policy is a single attempt with no retries" AND src/retry/retryable-read.spec-conformance.test.ts::"retryRead surfaces RetryAbortedError when signal fires before first read"
Requirement: PACKER-23 — Idempotent repeat-pack short-circuit
The package SHALL expose an injectable idempotency cache on
PackerOptions.idempotency ({ store, ttlMs?, enabled? }). When enabled,
Packer.pack(data, opts?) MUST compute a content-derived dedup key from
blake3(data) plus the packer's pipeline parameters
(compressionLevel, encryption.algorithm, signing enabled bit), look
the key up in the supplied IdempotencyStoreAdapter, and on hit return
the previously-built PackedData without re-running the gzip / AEAD /
sign / integrity pipeline. On miss the pipeline MUST run as before and
the resulting PackedData MUST be written back to the store under the
same key with the configured TTL (default 86_400_000 ms / 24 h).
Idempotency MUST be opt-in: callers that do not pass idempotency see
the legacy non-cached behaviour.
Scenario: identical pack inputs short-circuit on the second call
- WHEN
Packer.pack(data)is called twice with identicaldata, identical options, and aMemoryIdempotencyStoreinjected throughidempotency.store - THEN the second call returns a
PackedDatawhosedatabytes andmetadata.integrityare byte-equal to the first call's result, and the underlying pipeline (gzip / AEAD / sign) ran exactly once
Scenario: cache miss populates the store
- WHEN the first call's dedup key is absent from the store
- THEN the store's
set(key, record)is invoked exactly once after the pack completes, with the resultingPackedDatacarried onrecord.response
Scenario: idempotency disabled by default
- WHEN
Packer.pack(data)is called with noidempotencyoption - THEN no store interaction occurs and the pipeline runs end-to-end on every call
Verified by: src/idempotency/idempotency.test.ts::"second pack with identical input short-circuits to cached PackedData" AND src/idempotency/idempotency.test.ts::"cache miss populates the store with the resulting PackedData" AND src/idempotency/idempotency.spec-conformance.test.ts::"idempotency is disabled by default" AND src/idempotency/idempotency.spec-conformance.test.ts::"custom IdempotencyStoreAdapter is consulted exactly once per pack"
MODIFIED Requirements
Requirement: PACKER-24 — Whole-file AbortSignal propagation
The package SHALL accept an AbortSignal on every async public
entrypoint and MUST check the signal at every yield point. Unpacker.unpack
accepts UnpackOptions.signal? and MUST check it before integrity
verify, before signature verify, before AEAD decrypt, before gunzip, and
before the final emit. Packer.pack accepts an additional overload
pack(data, opts: PackOptions) where PackOptions.signal? is checked
before compression, before encryption, before signing, before integrity
compute, and before the final return. packChunks accepts
PackChunkedOptions.signal? and MUST check it on every loop iteration
before yielding the next packed chunk. Aborted operations MUST throw
PackerError(PACKER.ABORTED) with the i18n catalog message
packer.error.ABORTED.<entrypoint>. The original
Packer.pack(data, filename?) overload MUST remain a valid call shape
for back-compat.
Scenario: Unpacker.unpack aborts before integrity verify
- WHEN
Unpacker.unpack(packed, { signal })is called with a signal that is already aborted - THEN the call rejects with
PackerError(PACKER.ABORTED)and no integrity recomputation runs
Scenario: Unpacker.unpack aborts between pipeline steps
- WHEN the signal fires after integrity verify but before AEAD decrypt
- THEN the call rejects with
PackerError(PACKER.ABORTED)and no decrypted plaintext is materialised
Scenario: Packer.pack(data, opts) honours opts.signal
- WHEN
Packer.pack(data, { signal })is called with a signal that is already aborted - THEN the call rejects with
PackerError(PACKER.ABORTED)before any compression / encryption / signing / integrity work runs
Scenario: packChunks aborts mid-stream
- WHEN
packChunks(source, { ..., signal })is iterated and the signal fires after the second emitted chunk - THEN the iterator rejects with
PackerError(PACKER.ABORTED)before yielding any further chunks
Scenario: legacy filename overload remains valid
- WHEN
Packer.pack(data, "logo.png")is called - THEN the call succeeds,
metadata.mimeTypeis"image/png", and no signal-related behaviour kicks in
Verified by: src/unpacker/unpacker.spec-conformance.test.ts::"rejects with PACKER.ABORTED when signal is pre-aborted" AND src/unpacker/unpacker.spec-conformance.test.ts::"rejects with PACKER.ABORTED when signal fires between pipeline steps" AND src/packer/packer.spec-conformance.test.ts::"rejects with PACKER.ABORTED when opts.signal is pre-aborted" AND src/chunked/packer/packer.spec-conformance.test.ts::"aborts mid-stream when signal fires after the second chunk" AND src/packer/packer.spec-conformance.test.ts::"legacy pack(data, filename) overload still surfaces mimeType"
Requirement: PACKER-25 — Injected Clock and Rng everywhere
The package SHALL accept clock?: Clock and rng?: Rng (types from
@suaveplan/types) on every public option bundle (PackerOptions,
PackChunkedOptions, UnpackChunkedOptions). Every production
read of wall-clock time MUST go through the resolved Clock (no direct
Date.now()); every production source of randomness MUST go through
the resolved Rng (no direct Math.random() /
crypto.getRandomValues); every elapsed-time measurement MUST go
through an injected monotonicClock?: MonotonicClock (defaults to a
wrapper around performance.now()). The defaults MUST produce
byte-identical wire output to the cluster #1 behaviour. Determinism:
two Packer.pack calls with identical inputs, identical options, and a
seeded Rng MUST produce byte-identical PackedData.data and
identical metadata.integrity values.
Scenario: timestamp comes from the injected Clock
- WHEN
Packer.pack(data)is called with a clock returning a fixed value1_700_000_000_000 - THEN
result.metadata.timestamp === 1_700_000_000_000
Scenario: AEAD nonce comes from the injected Rng
- WHEN
Packer.pack(data, opts?)is called withencryptionset and anRngwhosenextBytes(n)returns a fixed byte pattern - THEN
result.metadata.nonceequals the byte pattern truncated to the nonce length
Scenario: same inputs + same seed produce byte-identical output
- WHEN two
Packer.pack(data)calls run with the same seededRngand the same fixedClock - THEN their
dataandmetadata.integrity.entryDigestare byte-equal
Scenario: no production file calls Date.now / Math.random / crypto.getRandomValues directly
- WHEN a recursive grep against
src/**/*.ts(excluding*.test.ts,*.types.ts,*.schemas.ts) is run forDate.now\|Math.random\|crypto.getRandomValues - THEN zero matches exist outside the default-Clock / default-Rng
factories in
src/runtime/runtime.ts
Verified by: src/packer/packer.spec-conformance.test.ts::"timestamp is sourced from the injected Clock" AND src/packer/packer.spec-conformance.test.ts::"AEAD nonce is sourced from the injected Rng" AND src/packer/packer.spec-conformance.test.ts::"two packs with identical inputs and seeded Rng produce byte-identical output" AND src/runtime/runtime.spec-conformance.test.ts::"no production file calls Date.now / Math.random / crypto.getRandomValues directly"