UNPKG

@stainless-code/persist

Version:

Hydration-aware persistence for any reactive store — zero-dep persistSource core; codecs, backends, cross-tab transport, source + framework hydration adapters ship as opt-in recipes

190 lines (189 loc) • 6.11 kB
import { c as jsonCodec, o as createStorage, t as PersistDecodeRethrowError } from "../persist-core-B5VikEft.mjs"; //#region src/adapters/codecs/standard-schema.ts const ASYNC_VALIDATE_SYNC_LANE_MESSAGE = "[@stainless-code/persist] Async Standard Schema validation is not supported — use withStandardSchemaAsync."; function isAsyncValidateSyncLaneError(error) { return error instanceof PersistDecodeRethrowError || error instanceof Error && error.message === ASYNC_VALIDATE_SYNC_LANE_MESSAGE; } function validateSync(schema, input) { const result = schema["~standard"].validate(input); if (result instanceof Promise) { result.catch(() => {}); throw new PersistDecodeRethrowError(ASYNC_VALIDATE_SYNC_LANE_MESSAGE); } if (result.issues) throw new Error(result.issues.map((i) => i.message).join("; ") || "Validation failed"); return result.value; } async function validateAsync(schema, input) { const result = await schema["~standard"].validate(input); if (result.issues) throw new Error(result.issues.map((i) => i.message).join("; ") || "Validation failed"); return result.value; } /** * Sync `~standard` codec for `state` only. Encode persists schema `value` * (defaults/transforms); throws → `onError` `"write"`. Decode failures → * corrupt path (`null` / `clearCorruptOnFailure`). Async validate throws * {@link PersistDecodeRethrowError} (not clearCorrupt under `createStorage`). * Envelope `version` / `timestamp` / `buster` are not schema-checked. */ function standardSchemaCodec(schema) { return { encode: (value) => { const state = validateSync(schema, value.state); return JSON.stringify({ ...value, state }); }, decode: (raw) => { const envelope = JSON.parse(raw); return { ...envelope, state: validateSync(schema, envelope.state) }; } }; } /** * Sync `~standard` wrap over an existing `PersistStorage` (typed envelope * already decoded). Promise-aware for backend `getItem` only — async schemas * throw toward `withStandardSchemaAsync`. * * @example * ```ts * import { createIdbStorage } from "@stainless-code/persist/backends/idb"; * import { withStandardSchema } from "@stainless-code/persist/codecs/standard-schema"; * * const storage = withStandardSchema(createIdbStorage<Prefs>()!, prefs, { * clearCorruptOnFailure: true, * }); * ``` */ function withStandardSchema(storage, schema, options) { const gate = (name, value) => { if (value === null) return null; try { return { ...value, state: validateSync(schema, value.state) }; } catch (error) { if (isAsyncValidateSyncLaneError(error)) throw error; if (options?.clearCorruptOnFailure) try { const removal = storage.removeItem(name); if (removal instanceof Promise) removal.catch(() => {}); } catch {} return null; } }; return { getItem(name) { const value = storage.getItem(name) ?? null; if (value instanceof Promise) return value.then((resolved) => gate(name, resolved ?? null)); return gate(name, value); }, setItem(name, value) { const state = validateSync(schema, value.state); return storage.setItem(name, { ...value, state }); }, removeItem(name) { return storage.removeItem(name); }, raw: storage.raw }; } /** * Async `~standard` wrap over an existing `PersistStorage`. Awaits validate * (sync schemas OK). Prefer this for Yup / async refine; use * `withStandardSchema` when validate is sync. Forces async hydrate even over * `localStorage` — gate UI with `useHydrated` (same as IndexedDB). * * @example * ```ts * import { createJSONStorage } from "@stainless-code/persist"; * import { withStandardSchemaAsync } from "@stainless-code/persist/codecs/standard-schema"; * * const storage = withStandardSchemaAsync( * createJSONStorage<Prefs>(() => localStorage)!, * yupSchema, * ); * ``` */ function withStandardSchemaAsync(storage, schema, options) { return { async getItem(name) { const value = await storage.getItem(name) ?? null; if (value === null) return null; try { const state = await validateAsync(schema, value.state); return { ...value, state }; } catch { if (options?.clearCorruptOnFailure) try { await storage.removeItem(name); } catch {} return null; } }, async setItem(name, value) { const state = await validateAsync(schema, value.state); await storage.setItem(name, { ...value, state }); }, async removeItem(name) { await storage.removeItem(name); }, raw: storage.raw }; } /** * Build a Standard Schema–gated `PersistStorage` over any string-keyed * `StateStorage`. JSON sugar over `withStandardSchema` — pass a schema that * implements sync `~standard`. * * @example * ```ts * import { z } from "zod"; * const prefs = z.object({ theme: z.enum(["light", "dark"]) }); * const storage = createStandardSchemaStorage<{ theme: "light" | "dark" }>( * () => localStorage, * prefs, * { clearCorruptOnFailure: true }, * ); * ``` */ function createStandardSchemaStorage(getStorage, schema, options) { const inner = createStorage(getStorage, jsonCodec(), options); if (!inner) return; return withStandardSchema(inner, schema, options); } /** * JSON sugar over `withStandardSchemaAsync` for async `~standard` schemas * (Yup, async refine). Sync schemas also work through this lane. Forces async * hydrate even over `localStorage` — gate UI with `useHydrated`. * * @example * ```ts * import { createStandardSchemaStorageAsync } from "@stainless-code/persist/codecs/standard-schema"; * * // Pass any async ~standard schema (Yup, async refine, …). * const storage = createStandardSchemaStorageAsync<Prefs>( * () => localStorage, * yupSchema, * { clearCorruptOnFailure: true }, * ); * ``` */ function createStandardSchemaStorageAsync(getStorage, schema, options) { const inner = createStorage(getStorage, jsonCodec(), options); if (!inner) return; return withStandardSchemaAsync(inner, schema, options); } //#endregion export { createStandardSchemaStorage, createStandardSchemaStorageAsync, standardSchemaCodec, withStandardSchema, withStandardSchemaAsync };