import type { StorableValue } from "./interface.ts"; import { DECONSTRUCT, isStorableInstance, RECONSTRUCT, type ReconstructionContext, type StorableInstance, } from "./storable-protocol.ts"; import { SpecialPrimitiveValue } from "./special-primitive-value.ts"; import { NATIVE_TAGS, tagFromNativeValue, TAGS } from "./type-tags.ts"; import { FrozenMap, FrozenSet } from "./frozen-builtins.ts"; // --------------------------------------------------------------------------- // Utility: native-instance type guard // --------------------------------------------------------------------------- /** * Returns `true` if the value is a native JS object type that the storable * system knows how to wrap (Error, Map, Set, Date, Uint8Array). These are * the "wild-west" instances that get converted into `StorableNativeWrapper` * subclasses or `StorableInstance` types by the conversion layer. * * Arrays, plain objects, objects with `toJSON()`, and system-defined special * primitives (EpochNsec, EpochDays, ContentId) are recognized by * `tagFromNativeValue()` but are NOT convertible native instances -- they * have their own handling paths in the conversion layer. */ export function isConvertibleNativeInstance(value: object): boolean { switch (tagFromNativeValue(value)) { case NATIVE_TAGS.Error: case NATIVE_TAGS.Map: case NATIVE_TAGS.Set: case NATIVE_TAGS.Date: case NATIVE_TAGS.Uint8Array: case NATIVE_TAGS.RegExp: return true; default: return false; } } // --------------------------------------------------------------------------- // Utility: safe property copy // --------------------------------------------------------------------------- /** Keys that must never be copied to prevent prototype pollution. */ export const UNSAFE_KEYS: FrozenSet = new FrozenSet([ "__proto__", "constructor", ]); /** * Copy own enumerable properties from `source` to `target`, skipping * prototype-sensitive keys (`__proto__`, `constructor`). When `noOverride` * is `true`, keys already present on `target` are also skipped. */ function copyOwnSafeProperties( source: object, target: Record, noOverride = false, ): void { for (const key of Object.keys(source)) { if (UNSAFE_KEYS.has(key)) continue; if (noOverride && key in target) continue; target[key] = (source as Record)[key]; } } /** * Create a shallow copy of an Error, preserving constructor, name, message, * stack, cause, and custom enumerable properties. Used by `toNativeValue()` * when the freeze state of the wrapped Error doesn't match the requested state. */ function copyError(error: Error): Error { const copy = new (error.constructor as ErrorConstructor)(error.message); if (copy.name !== error.name) copy.name = error.name; if (error.stack !== undefined) copy.stack = error.stack; if (error.cause !== undefined) copy.cause = error.cause; copyOwnSafeProperties( error, copy as unknown as Record, true, ); return copy; } // --------------------------------------------------------------------------- // Utility: Error class lookup // --------------------------------------------------------------------------- /** Map from Error subclass name to its constructor. */ const ERROR_CLASS_BY_TYPE: ReadonlyMap = new Map([ ["TypeError", TypeError], ["RangeError", RangeError], ["SyntaxError", SyntaxError], ["ReferenceError", ReferenceError], ["URIError", URIError], ["EvalError", EvalError], ]); /** * Return the `Error` constructor for the given type string (e.g. `"TypeError"`). * Falls back to the base `Error` constructor for unknown types. */ function errorClassFromType(type: string): ErrorConstructor { return ERROR_CLASS_BY_TYPE.get(type) ?? Error; } // --------------------------------------------------------------------------- // Abstract base class for native-object wrappers // --------------------------------------------------------------------------- /** * Abstract base class for `StorableInstance` wrappers that bridge native JS * objects (Error, Map, Set, Uint8Array) into the `StorableValue` layer. * Provides a common `toNativeValue()` method used by both the shallow and * deep unwrap functions, replacing their `instanceof` cascades with a single * `instanceof StorableNativeWrapper` check. */ export abstract class StorableNativeWrapper implements StorableInstance { abstract readonly typeTag: string; abstract [DECONSTRUCT](): StorableValue; /** The wrapped native value, used by `toNativeValue` for freeze-state checks. */ protected abstract get wrappedValue(): T; /** Convert the wrapped value to frozen form (only called on state mismatch). */ protected abstract toNativeFrozen(): T; /** Convert the wrapped value to thawed form (only called on state mismatch). */ protected abstract toNativeThawed(): T; /** Return the underlying native value, optionally frozen. */ toNativeValue(frozen: boolean): T { const value = this.wrappedValue; if (frozen === Object.isFrozen(value)) return value; return frozen ? this.toNativeFrozen() : this.toNativeThawed(); } } // --------------------------------------------------------------------------- // StorableError // --------------------------------------------------------------------------- /** * Wrapper for `Error` instances in the storable type system. Bridges native * `Error` (JS wild west) into the strongly-typed `StorableValue` layer by * implementing `StorableInstance`. The serialization layer handles * `StorableError` via the generic `StorableInstanceHandler` path. * See Section 1.4.1 of the formal spec. */ export class StorableError extends StorableNativeWrapper { /** The type tag used in the wire format (`TAGS.Error`). */ readonly typeTag = TAGS.Error; constructor( /** The wrapped native `Error`. */ readonly error: Error, ) { super(); } /** * Deconstruct into essential state for serialization. Returns type, name, * message, stack, cause, and custom enumerable properties. Does NOT recurse * into nested values -- the serialization system handles that. * * `type` is the constructor name (e.g. "TypeError") used for reconstruction. * `name` is the `.name` property -- emitted as `null` when it equals `type` * (the common case) to avoid redundancy. * * **Invariant**: By the time this method runs, `this.error.cause` and any * custom enumerable properties are already `StorableValue`. The conversion * layer (`convertErrorInternals()` in `rich-storable-value.ts`) ensures * this by recursively converting Error internals before wrapping in * `StorableError`. The `as StorableValue` casts below are therefore safe. */ [DECONSTRUCT](): StorableValue { const type = this.error.constructor.name; const name = this.error.name; const state: Record = { type, name: name === type ? null : name, message: this.error.message, }; if (this.error.stack !== undefined) { state.stack = this.error.stack; } if (this.error.cause !== undefined) { state.cause = this.error.cause as StorableValue; } copyOwnSafeProperties( this.error, state as Record, true, ); return state as StorableValue; } protected get wrappedValue(): Error { return this.error; } protected toNativeFrozen(): Error { return Object.freeze(copyError(this.error)); } protected toNativeThawed(): Error { return copyError(this.error); } /** * Reconstruct a `StorableError` from its essential state. Nested values * in `state` have already been reconstructed by the serialization system. * Returns a `StorableError` wrapping the reconstructed `Error`; callers * who need the native `Error` use `nativeValueFromStorableValue()`. */ static [RECONSTRUCT]( state: StorableValue, _context: ReconstructionContext, ): StorableError { const s = state as Record; const type = (s.type as string) ?? (s.name as string) ?? "Error"; // null name means "same as type" (the common case optimization). const name = (s.name as string | null) ?? type; const message = (s.message as string) ?? ""; const ErrorClass = errorClassFromType(type); const error = new ErrorClass(message); // Set name explicitly (covers custom names like "MyError", and the case // where type and name differ). if (error.name !== name) { error.name = name; } if (s.stack !== undefined) { error.stack = s.stack as string; } if (s.cause !== undefined) { error.cause = s.cause; } // Copy custom properties from state onto the error. const skip = new Set(["type", "name", "message", "stack", "cause"]); for (const key of Object.keys(s)) { if (!skip.has(key) && !UNSAFE_KEYS.has(key)) { (error as unknown as Record)[key] = s[key]; } } return new StorableError(error); } } // --------------------------------------------------------------------------- // Stub native wrappers: Map, Set, Date, Uint8Array // --------------------------------------------------------------------------- /** * Wrapper for `Map` instances. Stub -- `[DECONSTRUCT]` and `[RECONSTRUCT]` * throw until Map support is fully implemented. Extra properties beyond the * wrapped collection are not supported on non-Error wrappers. */ export class StorableMap extends StorableNativeWrapper> { readonly typeTag = TAGS.Map; constructor(readonly map: Map) { super(); } [DECONSTRUCT](): StorableValue { throw new Error("StorableMap: not yet implemented"); } protected get wrappedValue(): Map { return this.map; } protected toNativeFrozen(): FrozenMap { return new FrozenMap(this.map); } protected toNativeThawed(): Map { return new Map(this.map); } static [RECONSTRUCT]( _state: StorableValue, _context: ReconstructionContext, ): StorableMap { throw new Error("StorableMap: not yet implemented"); } } /** * Wrapper for `Set` instances. Stub -- `[DECONSTRUCT]` and `[RECONSTRUCT]` * throw until Set support is fully implemented. Extra properties beyond the * wrapped collection are not supported on non-Error wrappers. */ export class StorableSet extends StorableNativeWrapper> { readonly typeTag = TAGS.Set; constructor(readonly set: Set) { super(); } [DECONSTRUCT](): StorableValue { throw new Error("StorableSet: not yet implemented"); } protected get wrappedValue(): Set { return this.set; } protected toNativeFrozen(): FrozenSet { return new FrozenSet(this.set); } protected toNativeThawed(): Set { return new Set(this.set); } static [RECONSTRUCT]( _state: StorableValue, _context: ReconstructionContext, ): StorableSet { throw new Error("StorableSet: not yet implemented"); } } /** * Wrapper for `RegExp` instances in the storable type system. Bridges native * `RegExp` (JS wild west) into the strongly-typed `StorableValue` layer by * implementing `StorableInstance`. The essential state is * `{ source, flags, flavor }`. * See Section 1.4.1 of the formal spec. */ export class StorableRegExp extends StorableNativeWrapper { /** The type tag used in the wire format (`TAGS.RegExp`). */ readonly typeTag = TAGS.RegExp; constructor( /** The wrapped native `RegExp`. */ readonly regex: RegExp, /** Regex flavor/dialect identifier (e.g. `"es2025"`). */ readonly flavor: string = "es2025", ) { super(); } /** * Deconstruct into essential state for serialization. Returns * `{ source, flags, flavor }` -- the values needed to reconstruct the * RegExp. Extra enumerable properties on the RegExp cause rejection. */ [DECONSTRUCT](): StorableValue { rejectExtraRegExpProperties(this.regex); return { source: this.regex.source, flags: this.regex.flags, flavor: this.flavor, } as StorableValue; } protected get wrappedValue(): RegExp { return this.regex; } /** * Return a frozen copy of the RegExp. A frozen RegExp has an immutable * `lastIndex`, so stateful methods (`exec()`, `test()`) won't work * correctly -- but that matches the "death before confusion" principle. */ protected toNativeFrozen(): RegExp { return Object.freeze(new RegExp(this.regex)); } protected toNativeThawed(): RegExp { return new RegExp(this.regex); } /** * Reconstruct a `StorableRegExp` from its essential state * (`{ source, flags, flavor }`). */ static [RECONSTRUCT]( state: StorableValue, _context: ReconstructionContext, ): StorableRegExp { const s = state as Record; const source = (s.source as string) ?? ""; const flags = (s.flags as string) ?? ""; const flavor = (s.flavor as string) ?? "es2025"; return new StorableRegExp(new RegExp(source, flags), flavor); } } /** * Reject RegExp instances with extra enumerable properties. The built-in * `lastIndex` property is not enumerable, so `Object.keys()` won't see it. * Any enumerable own property is therefore user-added and causes rejection. */ function rejectExtraRegExpProperties(regex: RegExp): void { if (Object.keys(regex).length > 0) { throw new Error( "Cannot store RegExp with extra enumerable properties", ); } } /** * Temporal type representing nanoseconds from the POSIX Epoch (1970-01-01T00:00:00Z). * Wraps a `bigint` value. Used for high-precision timestamps. Direct member of * `StorableDatum` (not a `StorableInstance`). * See Section 1.4.6 of the formal spec. */ export class StorableEpochNsec extends SpecialPrimitiveValue { constructor( /** Nanoseconds from POSIX Epoch. Negative values represent pre-epoch timestamps. */ readonly value: bigint, ) { super(); Object.freeze(this); } } /** * Temporal type representing days from the POSIX Epoch (1970-01-01). * Wraps a `bigint` value. Used for date-only (no time) values. Direct member of * `StorableDatum` (not a `StorableInstance`). * See Section 1.4.7 of the formal spec. */ export class StorableEpochDays extends SpecialPrimitiveValue { constructor( /** Days from POSIX Epoch. Negative values represent pre-epoch dates. */ readonly value: bigint, ) { super(); Object.freeze(this); } } /** * Wrapper for `Uint8Array` instances. Stub -- `[DECONSTRUCT]` and * `[RECONSTRUCT]` throw until Uint8Array support is fully implemented. * Extra properties beyond the wrapped value are not supported on non-Error * wrappers. */ export class StorableUint8Array extends StorableNativeWrapper { readonly typeTag = TAGS.Bytes; constructor(readonly bytes: Uint8Array) { super(); } [DECONSTRUCT](): StorableValue { throw new Error("StorableUint8Array: not yet implemented"); } protected get wrappedValue(): Uint8Array { return this.bytes; } /** * Returns a `Blob` (immutable by nature). `Uint8Array` cannot be frozen * per the JS spec, so the base class freeze-state check always delegates * here when `frozen=true`. */ protected toNativeFrozen(): Blob { return new Blob([this.bytes as BlobPart]); } protected toNativeThawed(): Uint8Array { return this.bytes; } static [RECONSTRUCT]( _state: StorableValue, _context: ReconstructionContext, ): StorableUint8Array { throw new Error("StorableUint8Array: not yet implemented"); } } // --------------------------------------------------------------------------- // Unwrapping: StorableValue -> native JS types // --------------------------------------------------------------------------- /** * Shallow unwrap: convert a `StorableValue` to a native JS value, with * freeze-state adjustment for types that support it. * * Behavior by value category: * - **StorableNativeWrapper** (Error, Map, Set, Uint8Array): delegates to * `toNativeValue(frozen)`, which adjusts freeze state. * - **Arrays and plain objects**: freeze state is adjusted to match `frozen` * (shallow copy if needed). * - **SpecialPrimitiveValue** (EpochNsec, EpochDays, ContentId): pass through * as-is (always frozen by construction). * - **Non-native StorableInstance** (Cell, Stream, UnknownStorable, etc.): * pass through as-is (freeze state is an internal concern of the wrapper). * - **Primitives** (null, undefined, boolean, number, string, bigint): * inherently immutable, pass through unchanged. */ export function nativeValueFromStorableValue( value: StorableValue, frozen = true, ): unknown { if (value instanceof StorableNativeWrapper) { return value.toNativeValue(frozen); } // Special primitives (StorableEpochNsec, StorableEpochDays) are simple // value wrappers -- pass through as-is. if (value instanceof SpecialPrimitiveValue) { return value; } // Primitives (null, undefined, boolean, number, string, bigint) are // inherently immutable -- no freeze adjustment needed. if (value === null || value === undefined || typeof value !== "object") { return value; } // Non-native StorableInstance values (Cell, Stream, UnknownStorable, etc.) // pass through unchanged -- spreading would strip their prototype/methods, // and their freeze state is an internal concern of the wrapper. if (isStorableInstance(value)) return value; // For arrays and plain objects: ensure the freeze state matches `frozen`. const isFrozen = Object.isFrozen(value); if (frozen === isFrozen) return value; // already matches if (frozen) { // Value is unfrozen but caller wants frozen -> freeze a shallow copy. if (Array.isArray(value)) { const copy = new Array(value.length); for (let i = 0; i < value.length; i++) { if (i in value) copy[i] = value[i]; } return Object.freeze(copy); } return Object.freeze({ ...value }); } // Value is frozen but caller wants unfrozen -> shallow copy. if (Array.isArray(value)) { const copy = new Array(value.length); for (let i = 0; i < value.length; i++) { if (i in value) copy[i] = value[i]; } return copy; } return { ...(value as Record) }; } /** * Deep unwrap: recursively walk a `StorableValue` tree, unwrapping any * `StorableNativeWrapper` values to their underlying native types via * `toNativeValue()`. Non-native `StorableInstance` values (Cell, Stream, * UnknownStorable, etc.) pass through as-is. * * The freeze-state contract: the output's freeze state ALWAYS matches `frozen`. * Arrays and objects are copied and frozen/unfrozen accordingly. For * `StorableError`, the inner Error's `cause` and custom properties are also * recursively unwrapped (since they may contain `StorableInstance` wrappers). * * When `frozen` is true (the default), collections are returned as FrozenMap / * FrozenSet and plain objects/arrays are frozen. When false, mutable copies are * returned. */ export function deepNativeValueFromStorableValue( value: StorableValue, frozen = true, ): unknown { // StorableError: deep-unwrap the inner Error's internals (cause, custom // properties) since they may contain StorableInstance wrappers. if (value instanceof StorableError) { return deepUnwrapError(value.error, frozen); } // Other native wrappers (Map, Set, Uint8Array) -> native types. if (value instanceof StorableNativeWrapper) { return value.toNativeValue(frozen); } // Special primitives (StorableEpochNsec, StorableEpochDays) are simple // value wrappers -- pass through as-is. if (value instanceof SpecialPrimitiveValue) { return value; } // Other StorableInstance (Cell, Stream, UnknownStorable, etc.) -- pass through. if (isStorableInstance(value)) return value; // Storable primitives (null, undefined, boolean, number, string, bigint) // pass through. Note: `symbol` and `function` are NOT storable and cannot // reach here because the `StorableValue` type excludes them. if (value === null || value === undefined || typeof value !== "object") { return value; } // Arrays -- recursively unwrap elements, then freeze if requested. if (Array.isArray(value)) { const result: unknown[] = []; for (let i = 0; i < value.length; i++) { if (!(i in value)) { // Preserve sparse holes. result.length = i + 1; } else { result[i] = deepNativeValueFromStorableValue( value[i] as StorableValue, frozen, ); } } if (frozen) Object.freeze(result); return result; } // Objects -- recursively unwrap values, then freeze if requested. // Skip prototype-sensitive keys to prevent prototype pollution. const result: Record = {}; for (const [key, val] of Object.entries(value)) { if (!UNSAFE_KEYS.has(key)) { result[key] = deepNativeValueFromStorableValue( val as StorableValue, frozen, ); } } if (frozen) Object.freeze(result); return result; } /** * Deep-unwrap an Error's internals: recursively unwrap `cause` and custom * enumerable properties that may contain `StorableInstance` wrappers. Creates * a copy of the Error to avoid mutating the stored value. Freezes the result * when `frozen` is true. */ function deepUnwrapError(error: Error, frozen: boolean): Error { const copy = new (error.constructor as ErrorConstructor)(error.message); if (copy.name !== error.name) copy.name = error.name; if (error.stack !== undefined) copy.stack = error.stack; // Recursively unwrap cause. if (error.cause !== undefined) { copy.cause = deepNativeValueFromStorableValue( error.cause as StorableValue, frozen, ); } // Recursively unwrap custom enumerable properties. const SKIP = new Set(["name", "message", "stack", "cause"]); for (const key of Object.keys(error)) { if (SKIP.has(key) || UNSAFE_KEYS.has(key)) continue; (copy as unknown as Record)[key] = deepNativeValueFromStorableValue( (error as unknown as Record)[key] as StorableValue, frozen, ); } if (frozen) Object.freeze(copy); return copy; }