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

9.5 KiB
Raw Permalink Blame History

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/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<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> — 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<T>(). 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<T> 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<T> 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<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 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<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 "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.