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

7.2 KiB
Raw Permalink Blame History

Enterprise hardening — @suaveplan/packer resilience cluster

Package: @suaveplan/packer Status: PROPOSED Type: feature Design: (inline below — small scope) Spec delta: ./specs/packer/spec.md

Why

Cluster #1 (enterprise-packer-integrity, archived 2026-05-19) landed BLAKE3 content-addressable digests, the bundle-level Merkle root, mandatory integrity verification on unpack, full OpenTelemetry instrumentation, and i18n-keyed error messages. The enterprise/packer.md audit (L43, L46–L47) calls out three remaining resilience gaps that block the ENTERPRISE label:

  1. No retry primitives on IO-backed sources (ReadableStream.read() calls throw straight up the stack on transient errors).
  2. No idempotency layer — repeat packs over identical inputs re-run the full gzip + AEAD + ed25519 pipeline every time.
  3. The whole-file pack / unpack paths do not accept AbortSignal; only the chunked path honours cancellation today.
  4. Date.now() and crypto.getRandomValues() are called at production code paths in src/, violating the audit's "no Math.random() / Date.now() at module scope; injectable clock and RNG" line.

This is cluster #2 of a 3-cluster enterprise hardening of @suaveplan/packer (integrity → resilience → extensibility). Cluster #3 is deliberately out of scope here — see "Out of scope" below.

What changes

  • ADDED: src/retry/retryable-read.ts — retryRead(reader, opts) helper that wraps ReadableStream reads in @suaveplan/retry's withRetry, honours AbortSignal, and returns the same { value, done } shape. Defaults: createExponentialBackoff({ initial: 25, base: 2, max: 1000, jitter: "equal" })
    • maxAttempts: 4.
  • ADDED: RetryPolicy option bundle on PackerOptions, PackChunkedOptions, and UnpackChunkedOptions (retryConfig?, signal?). Threaded through to normalizeSource and the abstract collect path so every reader.read() in production code is retry-aware.
  • ADDED: src/idempotency/idempotency.ts — PackIdempotencyGuard that computes a content-derived key (blake3(payload) || compressionLevel || encryptionAlgo || signed-bit) and short-circuits to a cached PackedData when the same input is packed again. Backed by an injectable IdempotencyStoreAdapter (default MemoryIdempotencyStore).
  • ADDED: PackerOptions.idempotency?: { store, ttlMs?, enabled? }. Packer.pack consults the guard before any work; a hit returns the cached bundle bytes. Misses populate the store after the pack completes.
  • ADDED: signal?: AbortSignal on UnpackOptions. Unpacker.unpack now checks signal.aborted at every yield point (before integrity verify, before signature verify, before AEAD decrypt, before gunzip, before checksum verify, before the final emit).
  • ADDED: PackOptions interface ({ filename?, signal? }) and an additional Packer.pack(data, opts: PackOptions) overload. The original Packer.pack(data, filename?) overload remains for back-compat.
  • ADDED: signal?: AbortSignal on PackChunkedOptions — already honoured on the unpacker; now enforced on the chunked packer between every emitted chunk and the source pull loop.
  • ADDED: Clock and Rng injection on PackerOptions, PackChunkedOptions, UnpackChunkedOptions. Defaults wire to real platform implementations (() => Date.now() for Clock, a wrapper around crypto.getRandomValues for Rng). Every Date.now() / crypto.getRandomValues() / performance.now() call in production src/ is replaced with a call through the injected Clock / Rng.
  • MODIFIED: AbstractPacker.buildMetadata reads timestamp from the resolved Clock instead of Date.now().
  • MODIFIED: packer/packer.ts reads nonce bytes via the resolved Rng instead of calling crypto.getRandomValues directly.
  • MODIFIED: every nowMs() helper across packer/, unpacker/, chunked/packer/, chunked/unpacker/ now reads through the resolved monotonicClock (defaults to performance.now() for backward parity).

Out of scope

  • @suaveplan/schema-registry integration → cluster #3.
  • @suaveplan/feature-flags for format-version rollouts → cluster #3.
  • Plugin / extension interface → cluster #3.
  • fast-check property tests for round-trip invariants → cluster #3.
  • mitata benchmarks → cluster #3.

Source mapping

This change derives from ../suaveplan/enterprise/packer.md.

Requirement Source lines Notes
PACKER-22 (@suaveplan/retry on IO) L47 Verbatim — "retry primitives for IO-backed sources".
PACKER-23 (idempotency cache) L47 Verbatim — "Idempotency — repeat packs over the same inputs are a no-op".
PACKER-24 (whole-file AbortSignal) L46 Verbatim — "Backpressure & cancellation — AbortSignal everywhere async".
PACKER-25 (injected Clock + Rng) L43 Verbatim — "no Math.random() / Date.now() at module scope; injectable clock and RNG".

Requirements with no source line

None — every requirement in this cluster maps directly to an audit bullet.

Verification approach

  • src/retry/retryable-read.test.ts + .spec-conformance.test.ts — pins retryRead against an injectable failing reader, asserts the retry policy is consulted, asserts AbortSignal cancels mid-backoff.
  • src/idempotency/idempotency.test.ts + .spec-conformance.test.ts — pins identical-input dedup, cache-miss population, custom-store injection.
  • src/packer/packer.spec-conformance.test.ts (extended) — pins pack abort, idempotency hit, Clock-driven timestamp, Rng-driven nonce.
  • src/unpacker/unpacker.spec-conformance.test.ts (extended) — pins pre-flight abort, mid-pipeline abort.
  • src/chunked/packer/packer.spec-conformance.test.ts (extended) — pins signal-driven mid-stream abort.

Migration impact

Public-surface additions only. No existing signature changes shape — the new Packer.pack(data, opts: PackOptions) overload sits beside the existing Packer.pack(data, filename?). The Clock / Rng / idempotency / retryConfig / signal options are all optional with platform defaults. Two consumer packages (@suaveplan/drive-plugin-chunks, @suaveplan/drive-webrtc) are audited and the sweep is part of the gates; no existing call site changes shape.

Impact

Affected dependents

Run bunx turbo ls --filter=...@suaveplan/packer to enumerate.

  • @suaveplan/drive-plugin-chunks — [x] tests run / [x] docs reviewed / [ ] no action needed
  • @suaveplan/drive-webrtc — [x] tests run / [x] docs reviewed / [ ] no action needed

If tests run is unchecked for any dependent, the change MUST NOT be folded into the canonical spec.

Migration

No behavioral change for callers that did not pass the new options. Existing calls keep their previous semantics:

  • No signal passed: pack / unpack behaviour is identical to cluster #1.
  • No idempotency passed: no cache layer is consulted.
  • No retryConfig passed: a single read is attempted (no retries) for parity with cluster #1 — the default policy is maxAttempts: 1 so the retry layer is a no-op unless opted in.
  • No clock / rng passed: defaults call Date.now() / crypto.getRandomValues exactly as before, so wire output is identical.