9.5 KiB
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<T> 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/Setfaithful 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<T> 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<T> implements Codec<T>— newsrc/codec/structured-clone.ts.encodewalks the input, lifts non-JSON-native carriers (Date,Map,Set,Uint8Array,bigint,RegExp,URL) intoTagged(tagNumber, payload)per the table indesign.md, then delegates toencodefrom@suaveplan/cbor.decodecallsdecodefrom@suaveplan/cborwith aTagHandlermap that reverses the lift. Plain objects, arrays, primitives,null,boolean, finitenumber, andstringround-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 forDate),258(Set),259(Map with explicit key/value pairs),64–72reserved typed-array tags (Uint8Arrayuses tag64),2/3(positive/negative bignum forbigint),35(PCRE regex string forRegExp),32(URI forURL). All references are indesign.md. - ADDED: Subpath export
./codec/structured-cloneinpackage.jsonmatching the existing./codec/jsonand./codec/identityshape, plus re-export from./src/codec/index.ts. - ADDED: Two new entries in
CACHE_STORAGE_CODESviadefineCodes("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) andCODEC_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.tsexportingrunCodecConformance(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 (whensupportsStructuredClone: false) theunsupported-type-on-JSONnegative case. The existingJsonCodecandIdentityCodectests gain a thin harness-invocation wrapper so all three codecs share one contract; the new codec's test file invokes it withsupportsStructuredClone: true.
@suaveplan/cbor (consumed; no changes)
- The
StructuredCloneCodecimportsencode,decode,Tagged, andtype TagHandlerdirectly from@suaveplan/cborper CLAUDE.md rule 4 (no re-exports).
Default codec unchanged
StorageCache's constructor still defaultscodectonew JsonCodec<T>(). Existing callers see no behaviour change.
Out of scope
- Cross-realm
structuredClone()parity. The HTML structured-clone algorithm coversArrayBuffertransfer,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
registerTagsurface 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-redisalready consumes theCodec<T>interface from@suaveplan/cache-storage/codec, so callers can alreadynew StructuredCloneCodec()and pass it. No code change is needed incache-redisfor that to work. - Promoting the conformance harness to
@suaveplan/testing. The harness lives insidecache-storagefor 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/— existingCodec<T>seam (codec.types.ts,json.ts,identity.ts,index.ts). New filestructured-clone.tsplugs 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_CODESregistry to extend with two new codes via the samedefineCodescall.packages/flow/cbor/src/index.ts— public root barrel;encode,decode,Tagged,type TagHandlerare 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<T> 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 viarunCodecConformance(new StructuredCloneCodec(), { name: "StructuredCloneCodec", supportsStructuredClone: true })plus direct tests forCODEC_UNSUPPORTED_TYPEandCODEC_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.tsandsrc/codec/identity.test.ts— extended with arunCodecConformance(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 inStructuredCloneCodec.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 theCodec<T>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 "passnew StructuredCloneCodec()if you wantDate/Map/Setround-trips" but does not block this change.)- No other dependents at time of writing — verified by
bunx turbo ls --filter=...@suaveplan/cache-storagereturning onlycache-redis.
Affected upstreams
@suaveplan/cbor— newly consumed viaworkspace:^. Not modified. [x] no action needed.@suaveplan/error—defineCodesextended (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.