# Add StructuredCloneCodec (CBOR-backed) to @suaveplan/cache-storage **Package**: `@suaveplan/cache-storage` (universal) **Status**: PROPOSED **Type**: additive — new optional codec, JSON default unchanged **Picks up deferred work from**: `openspec/archive/2026-05-16-add-cache-storage-redis-backends/` - **Design**: ./design.md - **Spec delta**: ./specs/cache-storage/spec.md ## Why The archived change `add-cache-storage-redis-backends` shipped `StorageCache` with two codecs (`JsonCodec`, `IdentityCodec`) and explicitly deferred the third under its `## Out of scope` heading: > **`StructuredCloneCodec` / CBOR codec.** Deferred. JSON + Identity cover the common case; CBOR-backed structured-clone (`Date`/`Map`/`Set` faithful round-trip) ships in a follow-up alongside the cbor catalog entry. No placeholder code lands in this change. That follow-up is now unblocked: `@suaveplan/cbor` ships on genesis with a complete RFC 8949 encoder/decoder, `Tagged` value, and registry-extensible tag handlers. Callers that cache rich JS values (`Date`, `Map`, `Set`, `Uint8Array`, `bigint`, nested combinations) currently either lose information through `JsonCodec` (`Date` → string; `Map`/`Set` → `{}` or `[]`; `Uint8Array` → object-with-numeric-keys; `bigint` → `JSON.stringify` throw) or are forced to author their own pre/post serializers. Neither is acceptable for an enterprise cache layer that already exposes `Codec` as the documented swap-point. This change adds an opt-in `StructuredCloneCodec` that round-trips the structured-clone-ish subset over `@suaveplan/cbor`, leaving `JsonCodec` as the back-compat default. ## What changes ### `@suaveplan/cache-storage` (additive only) - ADDED: `StructuredCloneCodec implements Codec` — new `src/codec/structured-clone.ts`. `encode` walks the input, lifts non-JSON-native carriers (`Date`, `Map`, `Set`, `Uint8Array`, `bigint`, `RegExp`, `URL`) into `Tagged(tagNumber, payload)` per the table in `design.md`, then delegates to `encode` from `@suaveplan/cbor`. `decode` calls `decode` from `@suaveplan/cbor` with a `TagHandler` map that reverses the lift. Plain objects, arrays, primitives, `null`, `boolean`, finite `number`, and `string` round-trip natively through CBOR with zero transformation. - ADDED: Tag numbers chosen from IANA-registered CBOR tags so the wire format is interop-friendly: `0` (RFC 3339 date string — used for `Date`), `258` (Set), `259` (Map with explicit key/value pairs), `64`–`72` reserved typed-array tags (`Uint8Array` uses tag `64`), `2`/`3` (positive/negative bignum for `bigint`), `35` (PCRE regex string for `RegExp`), `32` (URI for `URL`). All references are in `design.md`. - ADDED: Subpath export `./codec/structured-clone` in `package.json` matching the existing `./codec/json` and `./codec/identity` shape, plus re-export from `./src/codec/index.ts`. - ADDED: Two new entries in `CACHE_STORAGE_CODES` via `defineCodes("CACHE_STORAGE", [...])`: `CODEC_UNSUPPORTED_TYPE` (raised on encode of a value the codec cannot lift — `Function`, `Symbol`, `WeakMap`, `WeakSet`, DOM nodes, `Promise`, class instance without a registered tag handler) and `CODEC_UNKNOWN_TAG` (raised on decode of a CBOR tag the codec does not recognise AND for which no caller-supplied fallback handler exists). - ADDED: Conformance harness `src/codec/conformance.ts` exporting `runCodecConformance(codec, { name, supportsStructuredClone })` — round-trip cases covering: primitives (null, boolean, number, string), nested plain objects, arrays, `Date`, `Map`, `Set`, `Uint8Array`, `bigint` (positive + negative), `RegExp`, `URL`, deeply nested combinations, empty collections, and (when `supportsStructuredClone: false`) the `unsupported-type-on-JSON` negative case. The existing `JsonCodec` and `IdentityCodec` tests gain a thin harness-invocation wrapper so all three codecs share one contract; the new codec's test file invokes it with `supportsStructuredClone: true`. ### `@suaveplan/cbor` (consumed; no changes) - The `StructuredCloneCodec` imports `encode`, `decode`, `Tagged`, and `type TagHandler` directly from `@suaveplan/cbor` per CLAUDE.md rule 4 (no re-exports). ### Default codec unchanged - `StorageCache`'s constructor still defaults `codec` to `new JsonCodec()`. Existing callers see no behaviour change. ## Out of scope - **Cross-realm `structuredClone()` parity.** The HTML structured-clone algorithm covers `ArrayBuffer` transfer, `ImageBitmap`, `Blob`, `File`, etc. This codec covers only the JS-language subset relevant to caching. Browser-only types stay out of a universal-tier package. - **Custom user tag registration.** The codec exposes the handler map internally; a public `registerTag` surface is a follow-up if real callers ask for it. Documented as such — not silently missing. - **Wiring the new codec into `@suaveplan/cache-redis`.** Out of scope here; `cache-redis` already consumes the `Codec` interface from `@suaveplan/cache-storage/codec`, so callers can already `new StructuredCloneCodec()` and pass it. No code change is needed in `cache-redis` for that to work. - **Promoting the conformance harness to `@suaveplan/testing`.** The harness lives inside `cache-storage` for now; if other packages adopt the codec interface it gets lifted. ## Source mapping - `openspec/archive/2026-05-16-add-cache-storage-redis-backends/proposal.md` — the deferral text quoted above. - `packages/flow/cache-storage/src/codec/` — existing `Codec` seam (`codec.types.ts`, `json.ts`, `identity.ts`, `index.ts`). New file `structured-clone.ts` plugs into the same barrel and exports. - `packages/flow/cache-storage/src/storage-cache/storage-cache.ts:13-14,54,93,168` — codec seam (constructor parameter, encode/decode call sites). No changes required: the new codec is interface-compatible. - `packages/flow/cache-storage/src/errors/errors.ts` — `CACHE_STORAGE_CODES` registry to extend with two new codes via the same `defineCodes` call. - `packages/flow/cbor/src/index.ts` — public root barrel; `encode`, `decode`, `Tagged`, `type TagHandler` are the exports consumed. ### Requirements with no source line | Requirement | Justification | |---|---| | Tag numbers chosen from the IANA CBOR tag registry (0, 2, 3, 32, 35, 64, 258, 259) | Standardised wire format is the only sane choice for a cache that may be read by a non-JS consumer (Redis blob, file dump). Inventing private tag numbers would lock the format to this codec forever. | | Conformance harness lives in `cache-storage`, not `@suaveplan/testing` | `@suaveplan/testing` carries zero `@suaveplan/*` runtime deps (rule 8). The harness must import the `Codec` interface to type its parameter; lifting it would invert the dep graph. Promotion is a separate change if a second consumer appears. | | Unknown-tag default: throw `CODEC_UNKNOWN_TAG` (not silent pass-through) | A cache that silently turns an unknown tag into a `Tagged` wrapper changes the runtime type the caller observes versus what they cached. Failing loudly preserves the codec's round-trip contract and matches the `JsonCodec` "decode failure = throw" precedent. | | `RegExp` and `URL` lifted (not just structured-clone-spec types) | These are the two most common JS-host carriers that round-trip cleanly through one IANA tag each and that callers reliably cache. Adding them costs ~10 lines and avoids "almost structured-clone" surprise. | ## Verification approach - `src/codec/structured-clone.test.ts` — every `**Verified by:**` line in the spec delta, invoked via `runCodecConformance(new StructuredCloneCodec(), { name: "StructuredCloneCodec", supportsStructuredClone: true })` plus direct tests for `CODEC_UNSUPPORTED_TYPE` and `CODEC_UNKNOWN_TAG`. - `src/codec/conformance.test.ts` — harness self-test: passing a deliberately broken codec causes the harness assertions to fail, proving the harness has teeth (no tautological green). - `src/codec/json.test.ts` and `src/codec/identity.test.ts` — extended with a `runCodecConformance(codec, { supportsStructuredClone: false })` call to prove the shared contract holds for the existing codecs at the JSON subset. - `src/errors/errors.test.ts` — extended to assert the two new codes (`CODEC_UNSUPPORTED_TYPE`, `CODEC_UNKNOWN_TAG`) are reachable from real call sites in `StructuredCloneCodec`. - `bunx turbo test:coverage --filter=@suaveplan/cache-storage` — 100/100/100/100 retained. - `bunx turbo lint typecheck test --filter=@suaveplan/cache-storage` — green. ## Impact ### Affected dependents Run `bunx turbo ls --filter=...@suaveplan/cache-storage` to enumerate. - `@suaveplan/cache-redis` — [ ] tests run / [x] docs reviewed / [x] no action needed (reason: consumes the `Codec` interface from `@suaveplan/cache-storage/codec`; the new codec is interface-compatible and opt-in. No source change required; a follow-up README note can mention "pass `new StructuredCloneCodec()` if you want `Date`/`Map`/`Set` round-trips" but does not block this change.) - No other dependents at time of writing — verified by `bunx turbo ls --filter=...@suaveplan/cache-storage` returning only `cache-redis`. ### Affected upstreams - `@suaveplan/cbor` — newly consumed via `workspace:^`. Not modified. [x] no action needed. - `@suaveplan/error` — `defineCodes` extended (additive); existing codes unchanged. [x] no action needed. - `@suaveplan/cache`, `@suaveplan/storage`, `@suaveplan/events`, `@suaveplan/types` — not touched. ## Migration This is purely additive: no error renames, no default flips, no removed exports. Callers opt in by constructing `StorageCache({ storage, codec: new StructuredCloneCodec() })`. Existing callers using the default `JsonCodec` see byte-identical behaviour.