Initial Commit
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user