# Design — StructuredCloneCodec for @suaveplan/cache-storage > Non-normative. Normative requirements live in `./specs/cache-storage/spec.md`. ## Goal Add a third `Codec` implementation to `@suaveplan/cache-storage` that round-trips the structured-clone-ish subset of JS values — `Date`, `Map`, `Set`, `Uint8Array`, `bigint`, `RegExp`, `URL`, and arbitrarily nested combinations — over `@suaveplan/cbor`. The default `JsonCodec` remains untouched; callers opt in. ## Codec interface (unchanged) ```ts // existing — packages/flow/cache-storage/src/codec/codec.types.ts export interface Codec { encode(value: T): Uint8Array; decode(bytes: Uint8Array): T; } ``` The new codec implements this interface verbatim. No constructor surface beyond the bare class is required for the default case; a future `{ tags?: ReadonlyMap }` option is sketched below but is out of scope for this change. ## Tag scheme CBOR tags from the IANA "Concise Binary Object Representation (CBOR) Tags" registry. Choosing standard tags keeps the on-wire format interop-friendly (a non-JS reader can decode the blob with any RFC 8949 implementation plus the standard tag set). | JS type | CBOR tag | Payload shape | IANA reference | |---------------|----------|----------------------------------------------------------------|---------------------------------| | `Date` | `0` | RFC 3339 string (`date.toISOString()`) | RFC 8949 §3.4.1 | | `bigint > 0` | `2` | byte string, big-endian magnitude | RFC 8949 §3.4.3 (positive bignum)| | `bigint < 0` | `3` | byte string, big-endian magnitude of `-1 - n` | RFC 8949 §3.4.3 (negative bignum)| | `URL` | `32` | string (the `URL.href`) | RFC 8949 §3.4.5.3 (URI) | | `RegExp` | `35` | string (`regex.source`); flags lost — see "Trade-offs" below | RFC 8949 §3.4.5.4 (PCRE regex) | | `Uint8Array` | `64` | byte string | RFC 8746 §3.3 | | `Set` | `258` | array of items (recursively encoded) | https://github.com/input-output-hk/cbor-sets-spec | | `Map` | `259` | array of `[key, value]` pairs (recursively encoded) | https://github.com/shanewholloway/js-cbor-codec/blob/master/docs/CBOR-259-spec--explicit-maps.md | Plain objects, arrays, `null`, `boolean`, finite `number`, and `string` round-trip natively through CBOR — no transformation, no tag. > Note on the Map choice: RFC 8949 §3.1 major type 5 already carries maps natively, but CBOR maps key on the encoded *bytes* of the key, which means a JS `Map` with object keys would either lose distinct identity (every `{}` encodes to the same bytes) or throw. Tag `259` was introduced by the JS ecosystem specifically to disambiguate "this came from a JS Map and the keys are values, not strings" — picking it preserves round-trip without breaking the major-type-5 invariants for plain-object encoding. ## Encode pipeline ```text input value │ ▼ walk(value) ── recursive lift │ plain JS value ─► returned as-is │ carrier type ─► returned as new Tagged(tag, walk(payload)) │ unsupported type ─► throw CACHE_STORAGE.CODEC_UNSUPPORTED_TYPE │ ▼ encode(walked, { deterministic: false }) ── @suaveplan/cbor │ ▼ Uint8Array ``` Detection order is intentional — most specific first — so that subclasses do not slip through. `Array.isArray` is checked before "plain object"; `instanceof Date`, `instanceof Map`, etc. come before the duck-typed fallthrough. ## Decode pipeline ```text input bytes │ ▼ decode(bytes, { tagHandling: "registry", tags: TAG_REGISTRY }) ── @suaveplan/cbor │ ▼ each Tagged hit TagHandler[tag](payload) ── reverse-lift │ known tag ─► returns the reconstructed JS carrier │ unknown tag ─► throw CACHE_STORAGE.CODEC_UNKNOWN_TAG │ ▼ value ``` Unknown tags fail loud rather than passing through as `Tagged` objects. Silent pass-through would change the runtime shape the caller observes versus what they wrote, breaking the `decode(encode(x)) deepEquals x` contract every other codec honours. ### TagHandler shape (from `@suaveplan/cbor`) ```ts type TagHandler = (payload: DecodedValue) => unknown; ``` The codec builds a frozen `Map` once at module load. The registry is `internal` for this change; a public `register(tag, handler)` surface is sketched in the "Future work" section below. ## Error handling Two new entries extend `CACHE_STORAGE_CODES`: | Code | Raised by | Caller-actionable response | |-------------------------------|---------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------| | `CODEC_UNSUPPORTED_TYPE` | `StructuredCloneCodec.encode` when the walk hits `Function`, `Symbol`, `WeakMap`, `WeakSet`, `Promise`, or any class instance without a registered tag | Switch to `JsonCodec` (for primitives only) or pre-serialise the value yourself | | `CODEC_UNKNOWN_TAG` | `StructuredCloneCodec.decode` when CBOR yields a `Tagged(n, …)` for `n` not in the registry | Upgrade the codec version, or pass a custom tag map (future-work surface) | Existing `CODEC_ERROR` is reused for malformed CBOR bytes (catch-all for `CborError` thrown by `@suaveplan/cbor/decoder`) so the boundary remains a single "decode failed" code for back-compat — the new `CODEC_UNKNOWN_TAG` covers only the well-formed-but-unrecognised case. ## Fallback strategy when CBOR decoder hits an unknown tag The codec configures `@suaveplan/cbor`'s decoder with a `TagRegistry` containing handlers for the eight tags above. For any other tag number, the registry returns a `Tagged` placeholder; the codec then inspects the decoded tree post-hoc — any leftover `Tagged` instance triggers `CACHE_STORAGE.CODEC_UNKNOWN_TAG` carrying the offending tag number in the error context. Walking the tree once for the post-check is O(n) over the decoded value count and runs at most once per `decode` call. Why not configure the cbor decoder to throw immediately on unknown tags? Because `@suaveplan/cbor`'s tag-handling mode is a single decoder-wide flag; flipping it to "throw" would also kill any caller who later wants to mix this codec with a custom fallback handler. Inspecting post-hoc keeps the policy local to this codec. ## File layout ```text packages/flow/cache-storage/src/codec/ codec.types.ts # unchanged identity.ts # unchanged identity.test.ts # extended with conformance harness call identity.md # unchanged json.ts # unchanged json.test.ts # extended with conformance harness call json.md # unchanged structured-clone.ts # NEW — codec implementation (~180 LoC) structured-clone.test.ts # NEW — conformance + negative cases structured-clone.md # NEW — sibling docs conformance.ts # NEW — runCodecConformance harness conformance.test.ts # NEW — harness self-test conformance.md # NEW — sibling docs index.ts # extended — explicit named re-export added ``` The 300-line soft cap is comfortably met. If the lift table grows (custom-tag registration), `structured-clone.tags.ts` extracts cleanly. ## Trade-offs - **`RegExp.flags` is dropped on round-trip.** RFC 8949's PCRE-regex tag carries only the pattern string; flags are not part of the registry payload spec. Documented in the sibling `.md`. Callers needing flags can wrap the regex in `{ source, flags }` and cache that. - **`number === NaN` / `±Infinity` survive CBOR but not JSON.** With `StructuredCloneCodec` these round-trip cleanly (CBOR has float specials); `JsonCodec` already turns them into `null`. Worth noting in docs but not a defect of this codec. - **No transferable objects.** `ArrayBuffer.transfer`, `MessagePort`, `ImageBitmap` are out of scope — they belong to browser-only structured clone, not a universal-tier cache codec. - **CBOR map key constraint.** A JS `Map` with object keys round-trips because we use tag `259` (array of `[k, v]` pairs), where each `k` is recursively encoded as a full CBOR item. The major-type-5 "duplicate keys collide" hazard does not apply. ## Future work (explicitly deferred) - Public `StructuredCloneCodec` constructor option `{ tags?: ReadonlyMap }` so callers can register their own carriers (e.g. `Decimal`, `Big`, domain value objects). - Promotion of `runCodecConformance` to `@suaveplan/testing` if a second consumer adopts the `Codec` interface. - Streaming variant `StructuredCloneStreamCodec` for >10 MiB blobs once a real consumer asks. Current synchronous design is the right default.