From 0b83492c22b18cafed4a2230c57617064be7d0f5 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Wed, 22 Jul 2026 21:39:48 +0300 Subject: [PATCH 1/2] feat(codecs): Standard Schema wraps and drop zod codec Add ./codecs/standard-schema with sync/async PersistStorage wraps and JSON factories; remove ./codecs/zod in favor of ~standard. --- .changeset/standard-schema-codec.md | 7 + .size-limit.json | 4 +- apps/docs/content/adapters/index.mdx | 2 +- apps/docs/content/concepts/choosing-codec.mdx | 20 +- apps/docs/content/concepts/entry-points.mdx | 2 +- apps/docs/content/concepts/index.mdx | 2 +- apps/docs/content/concepts/three-seams.mdx | 7 +- apps/docs/content/recipes/index.mdx | 20 +- apps/docs/content/recipes/meta.ts | 2 +- apps/docs/content/recipes/standard-schema.mdx | 69 +++ apps/docs/content/recipes/zod.mdx | 35 -- apps/docs/content/reference/api/index.mdx | 8 +- apps/docs/content/reference/api/meta.ts | 2 +- apps/docs/pages/_home/Batteries.astro | 6 +- apps/docs/pages/_home/Seams.astro | 2 +- apps/docs/pages/_home/UseCases.astro | 4 +- docs/architecture.md | 8 +- docs/plans/upstream-tanstack-pitch.md | 4 +- package.json | 10 +- src/adapters/codecs/standard-schema.test.ts | 525 ++++++++++++++++++ src/adapters/codecs/standard-schema.ts | 265 +++++++++ src/adapters/codecs/zod.test.ts | 102 ---- src/adapters/codecs/zod.ts | 56 -- tsdown.config.ts | 3 +- typedoc.json | 2 +- 25 files changed, 920 insertions(+), 247 deletions(-) create mode 100644 .changeset/standard-schema-codec.md create mode 100644 apps/docs/content/recipes/standard-schema.mdx delete mode 100644 apps/docs/content/recipes/zod.mdx create mode 100644 src/adapters/codecs/standard-schema.test.ts create mode 100644 src/adapters/codecs/standard-schema.ts delete mode 100644 src/adapters/codecs/zod.test.ts delete mode 100644 src/adapters/codecs/zod.ts diff --git a/.changeset/standard-schema-codec.md b/.changeset/standard-schema-codec.md new file mode 100644 index 0000000..88bd5a3 --- /dev/null +++ b/.changeset/standard-schema-codec.md @@ -0,0 +1,7 @@ +--- +"@stainless-code/persist": minor +--- + +Add `./codecs/standard-schema` — sync `~standard` codec plus `PersistStorage` wraps (`withStandardSchema` / `withStandardSchemaAsync`) and JSON factories (`createStandardSchemaStorage` / `createStandardSchemaStorageAsync`). Types vendored; no runtime peer. + +Remove `./codecs/zod`. Migrate to `createStandardSchemaStorage(getStorage, schema)` (Zod ≥3.24 / v4 via `~standard`). Encode now writes defaults/transforms (replaces side-effect `parse`). Yup / Promise-returning `~standard.validate` → async wrap or factory (always-async hydrate — gate UI). diff --git a/.size-limit.json b/.size-limit.json index 0d8d9c2..92fa29b 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -30,8 +30,8 @@ "gzip": true }, { - "name": "zod", - "path": "dist/codecs/zod.mjs", + "name": "standard-schema", + "path": "dist/codecs/standard-schema.mjs", "limit": "2 KB", "gzip": true }, diff --git a/apps/docs/content/adapters/index.mdx b/apps/docs/content/adapters/index.mdx index 9b24102..e512ae2 100644 --- a/apps/docs/content/adapters/index.mdx +++ b/apps/docs/content/adapters/index.mdx @@ -10,7 +10,7 @@ Persist ships adapters as opt-in subpaths — import only what you use. Each sea | Seam | What it is | Where to look | | -------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | **Backends** | `StateStorage` implementations (IDB, RN, encrypted, compressed, Node fs, …) | [Choosing a storage](/concepts/choosing-storage) · [Entry points](/concepts/entry-points) | -| **Codecs** | `StorageCodec` encode/decode (JSON, seroval, zod, identity) | [Choosing a codec](/concepts/choosing-codec) | +| **Codecs** | `StorageCodec` encode/decode (JSON, seroval, Standard Schema, identity) | [Choosing a codec](/concepts/choosing-codec) | | **Sources** | Thin wrappers that adapt a store library to `PersistableSource` | [Wrapping stores](/recipes/wrapping-stores) | | **Frameworks** | Hydration gates (`useHydrated`, `hydratedRune`, …) | [Writing a framework adapter](/adapters/framework-adapter) | | **Transport** | Cross-tab bridges (BroadcastChannel) | [Cross-tab](/recipes/crosstab) | diff --git a/apps/docs/content/concepts/choosing-codec.mdx b/apps/docs/content/concepts/choosing-codec.mdx index 6370bb6..8e159cf 100644 --- a/apps/docs/content/concepts/choosing-codec.mdx +++ b/apps/docs/content/concepts/choosing-codec.mdx @@ -1,18 +1,18 @@ --- title: Choosing a codec -description: Pick jsonCodec, serovalCodec, zodCodec, or identityCodec for your Persist wire format. +description: Pick a Persist wire format — Set/Map/Date, schema validation, or structured-clone backends. search: tags: ["concept", "codec"] --- -Pick by whether you need Set/Map/Date round-trips, schema-gated persistence, or a structured-clone backend. `identityCodec` only with structured-clone backends (IDB) — never string-wire. +localStorage stays JSON by default. Reach for seroval when Set/Map/Date must round-trip; Standard Schema when invalid prefs must never write; `identityCodec` only with structured-clone backends (IDB) — never string-wire. -| Codec | Set/Map/Date | Wire type | Schema validation | For backend | Subpath | -| --------------------- | ------------------------------------------ | -------------------------- | ----------------- | --------------------------- | ------------------------ | -| `jsonCodec` | ❌ | string | ❌ | string-wire | core | -| `serovalCodec` | ✅ | string (JSON-shaped) | ❌ | string-wire | `./codecs/seroval` | -| `zodCodec` | ❌ (JSON wire; only via schema transforms) | string | ✅ | string-wire | `./codecs/zod` | -| `identityCodec` | ✅ (structured clone) | `StorageValue` (object) | ❌ | structured-clone only (IDB) | core | -| custom `StorageCodec` | yours | yours | yours | any | custom `encode`/`decode` | +| Codec | Set/Map/Date | Wire type | Schema validation | For backend | Subpath | +| --------------------- | ------------------------------------------ | -------------------------- | ----------------- | --------------------------- | -------------------------- | +| `jsonCodec` | ❌ | string | ❌ | string-wire | core | +| `serovalCodec` | ✅ | string (JSON-shaped) | ❌ | string-wire | `./codecs/seroval` | +| `standardSchemaCodec` | ❌ (JSON wire; only via schema transforms) | string | ✅ | string-wire | `./codecs/standard-schema` | +| `identityCodec` | ✅ (structured clone) | `StorageValue` (object) | ❌ | structured-clone only (IDB) | core | +| custom `StorageCodec` | yours | yours | yours | any | custom `encode`/`decode` | -See [The three seams](/concepts/three-seams) for composition examples. +`standardSchemaCodec` accepts any sync [`~standard`](https://standardschema.dev/) schema and persists its `value`. Schema gating also composes via `withStandardSchema` / `withStandardSchemaAsync` over any `PersistStorage` (sync vs async lanes) — library matrix and Yup notes in the [Standard Schema recipe](/recipes/standard-schema). Composition: [The three seams](/concepts/three-seams). diff --git a/apps/docs/content/concepts/entry-points.mdx b/apps/docs/content/concepts/entry-points.mdx index 39195fc..92cfa1a 100644 --- a/apps/docs/content/concepts/entry-points.mdx +++ b/apps/docs/content/concepts/entry-points.mdx @@ -11,7 +11,7 @@ One subpath = one optional peer. No barrel — importing a subpath is the depend | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | | `@stainless-code/persist` | `persistSource`, `PersistApi`, `createStorage`, `createJSONStorage`, `createSessionStorage`, `createMigrationChain`, `jsonCodec`, `identityCodec`, `createPersistRegistry`, `toHydrationSignal`, `alwaysHydratedSignal` | none | | `@stainless-code/persist/codecs/seroval` | `serovalCodec`, `createSerovalStorage` | `seroval` | -| `@stainless-code/persist/codecs/zod` | `zodCodec`, `createZodStorage` | `zod` | +| `@stainless-code/persist/codecs/standard-schema` | `withStandardSchema`, `withStandardSchemaAsync`, `standardSchemaCodec`, `createStandardSchemaStorage`, `createStandardSchemaStorageAsync` | none | | `@stainless-code/persist/backends/idb` | `idbStateStorage`, `createIdbStorage` (structured-clone mode) | `idb-keyval` | | `@stainless-code/persist/backends/async-storage` | `asyncStorageStateStorage`, `createAsyncStorage` | `@react-native-async-storage/async-storage` | | `@stainless-code/persist/backends/mmkv` | `mmkvStateStorage`, `createMmkvStorage` | `react-native-mmkv` | diff --git a/apps/docs/content/concepts/index.mdx b/apps/docs/content/concepts/index.mdx index 1f9cc6e..44fe664 100644 --- a/apps/docs/content/concepts/index.mdx +++ b/apps/docs/content/concepts/index.mdx @@ -11,7 +11,7 @@ Three seams — backend, codec, source — so swapping storage, serialization, o | ------------------------------------------------ | ------------------------------------------------ | | [The three seams](/concepts/three-seams) | Backend × codec × source | | [Choosing a storage](/concepts/choosing-storage) | Sync vs async, cross-tab, structured-clone | -| [Choosing a codec](/concepts/choosing-codec) | JSON, seroval, zod, identity | +| [Choosing a codec](/concepts/choosing-codec) | JSON, seroval, Standard Schema, identity | | [Lifecycle](/concepts/lifecycle) | Hydrate, write, destroy, errors | | [Entry points](/concepts/entry-points) | Subpaths and optional peers | | [Comparison](/concepts/comparison) | vs zustand-persist, redux-persist, pinia-persist | diff --git a/apps/docs/content/concepts/three-seams.mdx b/apps/docs/content/concepts/three-seams.mdx index 3b6ee98..eb00b5d 100644 --- a/apps/docs/content/concepts/three-seams.mdx +++ b/apps/docs/content/concepts/three-seams.mdx @@ -30,15 +30,16 @@ import { createStorage, } from "@stainless-code/persist"; import { serovalCodec } from "@stainless-code/persist/codecs/seroval"; -import { zodCodec } from "@stainless-code/persist/codecs/zod"; +import { standardSchemaCodec } from "@stainless-code/persist/codecs/standard-schema"; import { idbStateStorage } from "@stainless-code/persist/backends/idb"; import { z } from "zod"; -const PrefsSchema = z.object({ theme: z.enum(["light", "dark"]) }); +const prefs = z.object({ theme: z.enum(["light", "dark"]) }); jsonCodec(); // core default — plain JSON serovalCodec(); // Set / Map / Date / cycles, inert JSON-shaped output -zodCodec(PrefsSchema); // schema-gated persistence — invalid state never writes; corrupt reads discard +standardSchemaCodec(prefs); // schema-gated (~standard) — invalid state never writes; corrupt reads discard +// or wrap after createStorage: withStandardSchema(storage, prefs) / withStandardSchemaAsync (Yup / async ~standard) identityCodec(); // structured-clone backends only — zero serialization // custom — any pair of pure functions: const superjsonCodec = { encode: superjson.stringify, decode: superjson.parse }; // class instances via registerCustom diff --git a/apps/docs/content/recipes/index.mdx b/apps/docs/content/recipes/index.mdx index f1929d0..b722d8f 100644 --- a/apps/docs/content/recipes/index.mdx +++ b/apps/docs/content/recipes/index.mdx @@ -1,20 +1,20 @@ --- title: Recipes -description: Copy-paste Persist patterns — encryption, options, wrapping stores, cross-tab, zod, React Native, and Node fs. +description: Copy-paste Persist patterns — composition, options, stores, cross-tab, Standard Schema, React Native, Node fs. search: tags: ["recipe"] --- Composable patterns over the three seams. Start with [Composition](/recipes/composition) for encrypt/compress stacks; use the pages below for options and store adapters. -| Recipe | Topic | -| ------------------------------------------- | ------------------------------------------------------------------------------------------ | -| [Composition](/recipes/composition) | Encrypt, compress, legacy IDB string payloads, namespaced stores | -| [Options](/recipes/options) | Clear-all, partialize, merge, retryWrite, throttleMs, maxAge, buster, migrate, skipPersist | -| [Wrapping stores](/recipes/wrapping-stores) | zustand, jotai, valtio, mobx, pinia, redux, custom `PersistableSource` | -| [Cross-tab](/recipes/crosstab) | localStorage sync and BroadcastChannel over IndexedDB | -| [Zod](/recipes/zod) | Schema-gated persist with `createZodStorage` | -| [React Native](/recipes/react-native) | AsyncStorage, MMKV, Expo Secure Store | -| [Node fs](/recipes/node-fs) | Server / SSR / CLI file-backed storage | +| Recipe | Topic | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| [Composition](/recipes/composition) | Encrypt, compress, legacy IDB string payloads, namespaced stores | +| [Options](/recipes/options) | Clear-all, partialize, merge, retryWrite, throttleMs, maxAge, buster, migrate, skipPersist | +| [Wrapping stores](/recipes/wrapping-stores) | zustand, jotai, valtio, mobx, pinia, redux, custom `PersistableSource` | +| [Cross-tab](/recipes/crosstab) | localStorage sync and BroadcastChannel over IndexedDB | +| [Standard Schema](/recipes/standard-schema) | Schema-gated persist with any `~standard` library | +| [React Native](/recipes/react-native) | AsyncStorage, MMKV, Expo Secure Store | +| [Node fs](/recipes/node-fs) | Server / SSR / CLI file-backed storage | Caveats that matter per backend: async backends (IDB, AsyncStorage, Secure Store) can't settle hydration before first paint → gate UI on a framework adapter; `sessionStorage` is per-tab (crossTab is meaningless); `identityCodec` never with string-only backends. diff --git a/apps/docs/content/recipes/meta.ts b/apps/docs/content/recipes/meta.ts index 36686d3..63978f9 100644 --- a/apps/docs/content/recipes/meta.ts +++ b/apps/docs/content/recipes/meta.ts @@ -8,7 +8,7 @@ export default defineMeta({ "options", "wrapping-stores", "crosstab", - "zod", + "standard-schema", "react-native", "node-fs", ], diff --git a/apps/docs/content/recipes/standard-schema.mdx b/apps/docs/content/recipes/standard-schema.mdx new file mode 100644 index 0000000..91431fc --- /dev/null +++ b/apps/docs/content/recipes/standard-schema.mdx @@ -0,0 +1,69 @@ +--- +title: Standard Schema–validated storage +description: Schema-gate PersistStorage with any ~standard library — sync/async wraps or JSON factories. +search: + tags: ["recipe", "standard-schema", "codec"] +--- + +Invalid prefs must not survive a refresh. Pass any sync `~standard` schema — Persist validates on write and on read; `clearCorruptOnFailure` deletes the key. + +```ts +import { z } from "zod"; +// or: import * as v from "valibot"; // any ~standard schema works +import { createStandardSchemaStorage } from "@stainless-code/persist/codecs/standard-schema"; +import { persistStore } from "@stainless-code/persist/sources/zustand"; + +const prefs = z.object({ + theme: z.enum(["light", "dark"]), + density: z.enum(["comfortable", "compact"]).default("comfortable"), +}); +type Prefs = z.infer; + +const storage = createStandardSchemaStorage(() => localStorage, prefs, { + clearCorruptOnFailure: true, +})!; + +persistStore(usePrefs, { + name: "app:prefs:v1", + storage, + onError: (error, { phase }) => { + // phase "write" = schema rejected outbound state + report({ error, phase }); + }, +}); +``` + +`createStandardSchemaStorage` is JSON sugar over `withStandardSchema`. Wraps are the primitive — compose after any `PersistStorage` (don't stack wrap + `standardSchemaCodec`; that double-validates): + +```ts +import { createIdbStorage } from "@stainless-code/persist/backends/idb"; +import { withStandardSchema } from "@stainless-code/persist/codecs/standard-schema"; + +const storage = withStandardSchema(createIdbStorage()!, prefs, { + clearCorruptOnFailure: true, +}); +``` + +### Async lane (Yup / Promise-returning validate) + +`~standard.validate` may return a `Promise` (Yup, async refine). Codecs stay sync — use the async wrap / factory: + +```ts +import { createStandardSchemaStorageAsync } from "@stainless-code/persist/codecs/standard-schema"; + +const storage = createStandardSchemaStorageAsync(() => localStorage, yupSchema)!; +// or: withStandardSchemaAsync(anyPersistStorage, yupSchema) +``` + +That lane forces async hydrate even over localStorage — gate UI like IDB (`useHydrated`). Prefer the sync wrap when validate is sync. + +Anything with sync `~standard.validate` works on the sync lane. Fact-checked implementers: + +- [ArkType](https://github.com/arktypeio/arktype), [Valibot](https://github.com/fabian-hiller/valibot), [Zod](https://github.com/colinhacks/zod) (v3.24+), [Raptor Validator](https://raptorjs.com/docs/validation) (v0.9.0+) — on the [official list](https://standardschema.dev/) +- [Typia](https://typia.io/docs/validators/validate/) — `createValidate` / `createValidateEquals` (v9.2+) +- [Effect Schema](https://effect.website/docs/schema/standard-schema/) — wrap with `Schema.standardSchemaV1` +- [TypeMap](https://github.com/sinclairzx81/typemap) — `Compile(...)` for TypeBox (and others); TypeBox schematics alone do **not** implement `~standard` + +[Yup](https://github.com/jquense/yup) implements the spec, but its `~standard.validate` returns a `Promise` — use the async lane above (or a sync Standard Schema wrapper around `validateSync`). + +Encode writes the schema `value` (defaults/transforms). Date/Map/Set without a schema → [`./codecs/seroval`](/concepts/choosing-codec). diff --git a/apps/docs/content/recipes/zod.mdx b/apps/docs/content/recipes/zod.mdx deleted file mode 100644 index 6ba6dad..0000000 --- a/apps/docs/content/recipes/zod.mdx +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: Zod-validated storage -description: Schema-gate writes and discard corrupt reads with createZodStorage. -search: - tags: ["recipe", "zod", "codec"] ---- - -Invalid state never persists — `encode` validates before serialize. A decode validation failure returns null (store keeps current/initial); with `clearCorruptOnFailure` the key is removed. - -```ts -import { z } from "zod"; -import { createZodStorage } from "@stainless-code/persist/codecs/zod"; -import { persistStore } from "@stainless-code/persist/sources/zustand"; - -const Prefs = z.object({ - theme: z.enum(["light", "dark"]), - density: z.enum(["comfortable", "compact"]).default("comfortable"), -}); -type Prefs = z.infer; - -const storage = createZodStorage(() => localStorage, Prefs, { - clearCorruptOnFailure: true, -})!; - -persistStore(usePrefs, { - name: "app:prefs:v1", - storage, - onError: (error, { phase }) => { - // phase "write" = schema rejected outbound state - report({ error, phase }); - }, -}); -``` - -Need Date/Map/Set without a schema? Prefer [`./codecs/seroval`](/concepts/choosing-codec). Compose zod over a custom backend with `createStorage(backend, zodCodec(schema))`. diff --git a/apps/docs/content/reference/api/index.mdx b/apps/docs/content/reference/api/index.mdx index b8f41e6..0093e67 100644 --- a/apps/docs/content/reference/api/index.mdx +++ b/apps/docs/content/reference/api/index.mdx @@ -26,8 +26,12 @@ One TypeDoc module per package entry. [Reference](/reference) `@stainless-code/persist/codecs/seroval` - - `@stainless-code/persist/codecs/zod` + + `@stainless-code/persist/codecs/standard-schema` diff --git a/apps/docs/content/reference/api/meta.ts b/apps/docs/content/reference/api/meta.ts index 6e2755e..86789dc 100644 --- a/apps/docs/content/reference/api/meta.ts +++ b/apps/docs/content/reference/api/meta.ts @@ -6,7 +6,7 @@ export default defineMeta({ pages: [ "core", "adapters-codecs-seroval", - "adapters-codecs-zod", + "adapters-codecs-standard-schema", "adapters-backends-idb", "adapters-backends-async-storage", "adapters-backends-mmkv", diff --git a/apps/docs/pages/_home/Batteries.astro b/apps/docs/pages/_home/Batteries.astro index 5bff9df..ce343e0 100644 --- a/apps/docs/pages/_home/Batteries.astro +++ b/apps/docs/pages/_home/Batteries.astro @@ -82,10 +82,10 @@ const codeChip = arrow href={contentHref("/concepts/choosing-codec")} icon="braces" - title="Validate or rich-serialize" + title="Schema-gate or keep rich types" > - ./codecs/zod for schema checks;{" "} - ./codecs/seroval for Date/Map/Set. + ./codecs/standard-schema blocks invalid + prefs; ./codecs/seroval keeps Date/Map/Set. =5.7 runes) | | `@stainless-code/persist/frameworks/svelte-store` | `adapters/frameworks/svelte-store` | `svelte` (>=3 store) | -No barrel — importing a subpath is the dependency opt-in. Each subpath entry owns its optional peer dep when the adapter needs one — the no-peer entries are the core, encrypted, compressed, node-fs, and crosstab subpaths — and the peer stays external in the build (`tsdown.config.ts` `neverBundle`) so consumers tree-shake cleanly. +No barrel — importing a subpath is the dependency opt-in. Each subpath entry owns its optional peer dep when the adapter needs one — the no-peer entries are the core, standard-schema, encrypted, compressed, node-fs, and crosstab subpaths — and the peer stays external in the build (`tsdown.config.ts` `neverBundle`) so consumers tree-shake cleanly. ## Folder layout @@ -54,7 +54,7 @@ No barrel — importing a subpath is the dependency opt-in. Each subpath entry o - **`core/`** — the zero-dep engine (`persist-core.ts`, `hydration.ts`) plus `index.ts` (the `.` entry that re-exports both). Nothing in `core/` imports an adapter. - **`adapters//`** — opt-in entries that own an optional peer and import only from `core/`: - - `codecs/` — `StorageCodec` adapters (seroval, zod) + - `codecs/` — `StorageCodec` adapters (seroval, Standard Schema) + Standard Schema `PersistStorage` wraps - `backends/` — `StateStorage` adapters + wrappers (idb, async-storage, mmkv, secure-store, encrypted, compressed, node-fs) - `transport/` — `CrossTabEventTarget` adapters (crosstab — BroadcastChannel bridge) - `sources/` — `PersistableSource` adapters (tanstack-store, zustand, jotai, valtio, mobx, pinia, redux). Shape-named, not library-named — same persistable shape → same name → same merge semantics; the subpath carries the library. Alias when importing two same-shape adapters into one module. @@ -68,7 +68,7 @@ A per-entry self-check test pins the invariant: every adapter's relative imports ## Sync vs async -One API. Sync backends (localStorage) settle hydration before first paint; async backends (IndexedDB) ride the same `getItem` Promise branch — `getItem` returning a native `Promise` switches the read path to async (deliberately `instanceof Promise`, not thenable duck-typing, so a stored value carrying a `then` property is never mistaken for a pending read). Gate UI on `useHydrated` for async backends. +One API. Sync backends (localStorage) settle hydration before first paint; async backends (IndexedDB) ride the same `getItem` Promise branch — `getItem` returning a native `Promise` switches the read path to async (deliberately `instanceof Promise`, not thenable duck-typing, so a stored value carrying a `then` property is never mistaken for a pending read). Gate UI on `useHydrated` for async backends. Standard Schema also offers `PersistStorage` wraps (`withStandardSchema` / `withStandardSchemaAsync`) for sync vs async `~standard` lanes — JSON factories are sugar over those wraps. ## Beyond Query-persister parity diff --git a/docs/plans/upstream-tanstack-pitch.md b/docs/plans/upstream-tanstack-pitch.md index cbef44f..622a7e1 100644 --- a/docs/plans/upstream-tanstack-pitch.md +++ b/docs/plans/upstream-tanstack-pitch.md @@ -6,11 +6,11 @@ Hi — sharing [`@stainless-code/persist`](https://github.com/stainless-code/persist), a hydration-aware persistence middleware for any reactive store. Published under stainless-code, extracted, test-covered. It could serve as a reference or a contribution toward TanStack Persist; happy to fold any of it upstream. Quick tour. -**1. What it is.** A middleware over a _structural_ `PersistableSource` (`getState`/`setState`/`subscribe`) — not a store-specific wrapper. `persistSource(source, options)` persists anything reactive; `persistStore`/`persistAtom` are thin adapters for `@tanstack/store`. Unlike a passive `StoragePersister` (dump/load a blob), the middleware owns the lifecycle — hydrate-on-create, subscribe-writes gated until hydrated, trailing throttle, teardown — so the store stays a plain store and persistence is a sidekick. Three seams (`StateStorage` × `StorageCodec` × `PersistableSource`) make every backend × codec cell a one-line composition; the surface is exercised across codecs (seroval, zod), backends (IndexedDB, AsyncStorage, MMKV, SecureStore, encrypted, compressed, node-fs), a BroadcastChannel cross-tab transport, store sources (TanStack Store, zustand, jotai, valtio, mobx), and framework hydration adapters (React/Solid/Vue/Svelte (+ Svelte-store)/Angular/Preact) — so the shape is proven to scale, not sketched. ([README § Extensibility guide](https://github.com/stainless-code/persist#extensibility-guide)) +**1. What it is.** A middleware over a _structural_ `PersistableSource` (`getState`/`setState`/`subscribe`) — not a store-specific wrapper. `persistSource(source, options)` persists anything reactive; `persistStore`/`persistAtom` are thin adapters for `@tanstack/store`. Unlike a passive `StoragePersister` (dump/load a blob), the middleware owns the lifecycle — hydrate-on-create, subscribe-writes gated until hydrated, trailing throttle, teardown — so the store stays a plain store and persistence is a sidekick. Three seams (`StateStorage` × `StorageCodec` × `PersistableSource`) make every backend × codec cell a one-line composition; the surface is exercised across codecs (seroval, Standard Schema), backends (IndexedDB, AsyncStorage, MMKV, SecureStore, encrypted, compressed, node-fs), a BroadcastChannel cross-tab transport, store sources (TanStack Store, zustand, jotai, valtio, mobx), and framework hydration adapters (React/Solid/Vue/Svelte (+ Svelte-store)/Angular/Preact) — so the shape is proven to scale, not sketched. ([README § Extensibility guide](https://github.com/stainless-code/persist#extensibility-guide)) **2. Sync vs async — one API.** No split into two APIs. `getItem` returning a native `Promise` switches the read path to async; sync backends stay sync and hydration settles before first paint (no flash, no `Suspense`). Async backends ride the same `getItem` Promise branch; UI gates on a hydration signal. `instanceof Promise` (not thenable duck-typing) so a stored value with a `then` field is never mistaken for a pending read. ([architecture § Sync vs async](https://github.com/stainless-code/persist/blob/main/docs/architecture.md#sync-vs-async)) -**3. Where it lands vs `@tanstack/query-persist-client`.** Fact-checked against query's source. **Parity** — query has these too: `buster`, `maxAge`, `throttleTime` (`throttleMs`), `retry` (`retryWrite` shrink-or-give-up), and a hydration gate (`useIsRestoring` / `PersistQueryClientProvider`, React in-core). **Beyond** — a store-agnostic source (query is cache-bound), a codec seam independent of the backend (query couples `serialize`/`deserialize` to the persister factory), versioned `migrate` (query has only `buster`, no `version`), cross-tab sync, schema validation (a `zod` codec), and framework hydration adapters beyond React (Solid/Vue/Svelte/Angular/Preact — query's non-React adapters live in sidecar `@tanstack/query-*-` packages, not query-persist-client core). One deliberate divergence: `maxAge` is opt-in, not default-on — prefs shouldn't silently expire. ([README § Comparison](https://github.com/stainless-code/persist#comparison-with-other-persist-libraries)) +**3. Where it lands vs `@tanstack/query-persist-client`.** Fact-checked against query's source. **Parity** — query has these too: `buster`, `maxAge`, `throttleTime` (`throttleMs`), `retry` (`retryWrite` shrink-or-give-up), and a hydration gate (`useIsRestoring` / `PersistQueryClientProvider`, React in-core). **Beyond** — a store-agnostic source (query is cache-bound), a codec seam independent of the backend (query couples `serialize`/`deserialize` to the persister factory), versioned `migrate` (query has only `buster`, no `version`), cross-tab sync, schema validation (`./codecs/standard-schema`), and framework hydration adapters beyond React (Solid/Vue/Svelte/Angular/Preact — query's non-React adapters live in sidecar `@tanstack/query-*-` packages, not query-persist-client core). One deliberate divergence: `maxAge` is opt-in, not default-on — prefs shouldn't silently expire. ([README § Comparison](https://github.com/stainless-code/persist#comparison-with-other-persist-libraries)) **4. How it could merge.** `StateStorage` + `StorageCodec` drop in; `persistSource` is the store-agnostic core a `persistStore`/`persistQuery`/`persistForm` family can share; `HydrationSignal` is the framework-agnostic subscribe target — React (`useSyncExternalStore`), Solid (`from`), Vue (`shallowRef` + `onScopeDispose`), Svelte (runes `createSubscriber`, plus a `readable` store variant for Svelte 4), Angular (`signal` + `effect`), and Preact (`useSyncExternalStore` via preact/compat) mount it. Contributable: the core + the TanStack adapter + the framework hydration adapters. No barrel — one subpath per optional peer, categorized to mirror the source (`./codecs/…`, `./backends/…`, `./transport/…`, `./sources/…`, `./frameworks/…`) — so the dep opt-in is the import. diff --git a/package.json b/package.json index 1795ec5..1fd5124 100644 --- a/package.json +++ b/package.json @@ -68,9 +68,9 @@ "types": "./dist/codecs/seroval.d.mts", "import": "./dist/codecs/seroval.mjs" }, - "./codecs/zod": { - "types": "./dist/codecs/zod.d.mts", - "import": "./dist/codecs/zod.mjs" + "./codecs/standard-schema": { + "types": "./dist/codecs/standard-schema.d.mts", + "import": "./dist/codecs/standard-schema.mjs" }, "./backends/idb": { "types": "./dist/backends/idb.d.mts", @@ -282,7 +282,6 @@ "svelte": ">=3.0.0", "valtio": ">=1.0.0", "vue": ">=3.3.0", - "zod": ">=3.20.0", "zustand": ">=4.0.0" }, "peerDependenciesMeta": { @@ -319,9 +318,6 @@ "alpinejs": { "optional": true }, - "zod": { - "optional": true - }, "@react-native-async-storage/async-storage": { "optional": true }, diff --git a/src/adapters/codecs/standard-schema.test.ts b/src/adapters/codecs/standard-schema.test.ts new file mode 100644 index 0000000..5d5657e --- /dev/null +++ b/src/adapters/codecs/standard-schema.test.ts @@ -0,0 +1,525 @@ +import { beforeEach, describe, expect, it } from "bun:test"; + +import { z } from "zod"; + +import { + createStorage, + jsonCodec, + persistSource, +} from "../../core/persist-core"; +import type { PersistStorage, StorageValue } from "../../core/persist-core"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; +import { MemoryStorage } from "../../testing/memory-storage"; +import { createMockSource } from "../../testing/mock-source"; +import { waitForHydration } from "../../testing/wait-for-hydration"; +import type { StandardSchemaV1 } from "./standard-schema"; +import { + createStandardSchemaStorage, + createStandardSchemaStorageAsync, + standardSchemaCodec, + withStandardSchema, + withStandardSchemaAsync, +} from "./standard-schema"; + +function fakeSchema(opts: { + validate: ( + input: unknown, + ) => + | StandardSchemaV1.Result + | Promise>; +}): StandardSchemaV1 { + return { + "~standard": { + version: 1, + vendor: "test", + validate: opts.validate, + }, + }; +} + +describe("standardSchemaCodec", () => { + let memory: MemoryStorage; + + beforeEach(() => { + memory = new MemoryStorage(); + }); + + it("round-trips typed state through a sync StandardSchemaV1", async () => { + const schema = fakeSchema<{ count: number }>({ + validate: (input) => { + const value = input as { count: number }; + if (typeof value?.count !== "number") { + return { issues: [{ message: "count must be a number" }] }; + } + return { value }; + }, + }); + const storage = createStandardSchemaStorage<{ count: number }>( + () => memory, + schema, + )!; + + await storage.setItem("test", { + state: { count: 42 }, + version: 1, + }); + + const stored = await storage.getItem("test"); + expect(stored?.state.count).toBe(42); + expect(stored?.version).toBe(1); + }); + + it("decode of an invalid payload returns null", async () => { + memory.setItem("bad", JSON.stringify({ state: { count: "not-a-number" } })); + + const schema = fakeSchema<{ count: number }>({ + validate: (input) => { + const value = input as { count: number }; + if (typeof value?.count !== "number") { + return { issues: [{ message: "count must be a number" }] }; + } + return { value }; + }, + }); + const storage = createStandardSchemaStorage<{ count: number }>( + () => memory, + schema, + )!; + expect(await storage.getItem("bad")).toBeNull(); + }); + + it("clearCorruptOnFailure removes the key on an invalid payload", async () => { + memory.setItem("corrupt", JSON.stringify({ state: { count: "bad" } })); + + const schema = fakeSchema<{ count: number }>({ + validate: (input) => { + const value = input as { count: number }; + if (typeof value?.count !== "number") { + return { issues: [{ message: "count must be a number" }] }; + } + return { value }; + }, + }); + const storage = createStandardSchemaStorage<{ count: number }>( + () => memory, + schema, + { clearCorruptOnFailure: true }, + )!; + + expect(await storage.getItem("corrupt")).toBeNull(); + expect(memory.getItem("corrupt")).toBeNull(); + }); + + it("encode of an invalid state throws and abandons the write", async () => { + const schema = fakeSchema<{ count: number }>({ + validate: (input) => { + const value = input as { count: number }; + if (typeof value?.count !== "number") { + return { issues: [{ message: "count must be a number" }] }; + } + return { value }; + }, + }); + const errors: Array<{ phase: string }> = []; + const source = createMockSource({ count: 0 }); + const persist = persistSource(source, { + name: "invalid-write", + storage: createStandardSchemaStorage<{ count: number }>( + () => memory, + schema, + )!, + onError: (_error, context) => errors.push({ phase: context.phase }), + }); + + await waitForHydration(persist.hasHydrated); + + source.setState( + () => ({ count: "not-a-number" }) as unknown as { count: number }, + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(memory.getItem("invalid-write")).toBeNull(); + expect(errors.some((entry) => entry.phase === "write")).toBe(true); + + persist.destroy(); + }); + + it("async validate throws Persist message on encode and decode", async () => { + const schema = fakeSchema<{ count: number }>({ + validate: async (input) => ({ value: input as { count: number } }), + }); + const codec = standardSchemaCodec(schema); + const asyncMessage = + "[@stainless-code/persist] Async Standard Schema validation is not supported — use withStandardSchemaAsync."; + + expect(() => codec.encode({ state: { count: 1 }, version: 0 })).toThrow( + asyncMessage, + ); + + expect(() => + codec.decode(JSON.stringify({ state: { count: 1 }, version: 0 })), + ).toThrow(asyncMessage); + }); + + it("rejecting async validate does not surface unhandledRejection", async () => { + const schema = fakeSchema<{ count: number }>({ + validate: () => Promise.reject(new Error("schema boom")), + }); + const codec = standardSchemaCodec(schema); + const asyncMessage = + "[@stainless-code/persist] Async Standard Schema validation is not supported — use withStandardSchemaAsync."; + + expect(() => codec.encode({ state: { count: 1 }, version: 0 })).toThrow( + asyncMessage, + ); + + // Flush microtasks — a missing .catch would fire unhandledRejection. + await Promise.resolve(); + }); + + it("encode persists transformed output from validate", async () => { + const schema = fakeSchema<{ count: number; label: string }>({ + validate: (input) => { + const value = input as { count: number; label?: string }; + if (typeof value?.count !== "number") { + return { issues: [{ message: "count must be a number" }] }; + } + return { + value: { count: value.count, label: value.label ?? "default" }, + }; + }, + }); + const storage = createStandardSchemaStorage<{ + count: number; + label: string; + }>(() => memory, schema)!; + + await storage.setItem("transformed", { + state: { count: 3 } as { count: number; label: string }, + version: 0, + }); + + const raw = memory.getItem("transformed"); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!) as { + state: { count: number; label: string }; + }; + expect(parsed.state).toEqual({ count: 3, label: "default" }); + + const stored = await storage.getItem("transformed"); + expect(stored?.state).toEqual({ count: 3, label: "default" }); + }); + + it("works with a real zod schema via ~standard", async () => { + const schema = z.object({ count: z.number() }); + expect(schema["~standard"]?.version).toBe(1); + + const storage = createStandardSchemaStorage<{ count: number }>( + () => memory, + schema, + )!; + + await storage.setItem("zod-interop", { + state: { count: 9 }, + version: 0, + }); + const stored = await storage.getItem("zod-interop"); + expect(stored?.state.count).toBe(9); + }); +}); + +describe("withStandardSchema", () => { + function memoryPersistStorage(): PersistStorage & { + store: Map>; + } { + const store = new Map>(); + return { + store, + getItem(name) { + return store.get(name) ?? null; + }, + setItem(name, value) { + store.set(name, value); + }, + removeItem(name) { + store.delete(name); + }, + raw: store, + }; + } + + function countSchema() { + return fakeSchema<{ count: number }>({ + validate: (input) => { + const value = input as { count: number }; + if (typeof value?.count !== "number") { + return { issues: [{ message: "count must be a number" }] }; + } + return { value }; + }, + }); + } + + it("round-trips typed state over a hand-rolled PersistStorage", () => { + const inner = memoryPersistStorage<{ count: number }>(); + const storage = withStandardSchema(inner, countSchema()); + + storage.setItem("test", { state: { count: 42 }, version: 1 }); + const stored = storage.getItem("test"); + expect(stored).toEqual({ state: { count: 42 }, version: 1 }); + expect(inner.store.get("test")?.state.count).toBe(42); + }); + + it("invalid get returns null and clearCorruptOnFailure removes the key", () => { + const inner = memoryPersistStorage<{ count: number }>(); + inner.store.set("corrupt", { + state: { count: "bad" } as unknown as { count: number }, + version: 0, + }); + + const storage = withStandardSchema(inner, countSchema(), { + clearCorruptOnFailure: true, + }); + + expect(storage.getItem("corrupt")).toBeNull(); + expect(inner.store.has("corrupt")).toBe(false); + }); + + it("Promise-returning inner getItem still validates sync schema", async () => { + const store = new Map>(); + store.set("async-backend", { + state: { count: 7 }, + version: 0, + }); + store.set("async-bad", { + state: { count: "nope" } as unknown as { count: number }, + version: 0, + }); + + const inner: PersistStorage<{ count: number }> = { + getItem(name) { + return Promise.resolve(store.get(name) ?? null); + }, + setItem(name, value) { + store.set(name, value); + }, + removeItem(name) { + store.delete(name); + }, + }; + + const storage = withStandardSchema(inner, countSchema(), { + clearCorruptOnFailure: true, + }); + + await expect(storage.getItem("async-backend")).resolves.toEqual({ + state: { count: 7 }, + version: 0, + }); + await expect(storage.getItem("async-bad")).resolves.toBeNull(); + expect(store.has("async-bad")).toBe(false); + }); + + it("async schema throws Persist async message", () => { + const inner = memoryPersistStorage<{ count: number }>(); + const schema = fakeSchema<{ count: number }>({ + validate: async (input) => ({ value: input as { count: number } }), + }); + const storage = withStandardSchema(inner, schema); + const asyncMessage = + "[@stainless-code/persist] Async Standard Schema validation is not supported — use withStandardSchemaAsync."; + + expect(() => + storage.setItem("async-schema", { state: { count: 1 }, version: 0 }), + ).toThrow(asyncMessage); + }); + + it("async schema on get throws and does not clearCorrupt", () => { + const inner = memoryPersistStorage<{ count: number }>(); + inner.store.set("keep", { state: { count: 1 }, version: 0 }); + const schema = fakeSchema<{ count: number }>({ + validate: async (input) => ({ value: input as { count: number } }), + }); + const storage = withStandardSchema(inner, schema, { + clearCorruptOnFailure: true, + }); + const asyncMessage = + "[@stainless-code/persist] Async Standard Schema validation is not supported — use withStandardSchemaAsync."; + + expect(() => storage.getItem("keep")).toThrow(asyncMessage); + expect(inner.store.has("keep")).toBe(true); + }); + + it("composes over createStorage + jsonCodec (non-codec-coupled path)", async () => { + const memory = new MemoryStorage(); + const inner = createStorage<{ count: number }>( + () => memory, + jsonCodec<{ count: number }>(), + )!; + const storage = withStandardSchema(inner, countSchema()); + + await storage.setItem("compose", { state: { count: 11 }, version: 0 }); + const stored = await storage.getItem("compose"); + expect(stored).toEqual({ state: { count: 11 }, version: 0 }); + }); +}); + +describe("withStandardSchemaAsync", () => { + function memoryPersistStorage(): PersistStorage & { + store: Map>; + } { + const store = new Map>(); + return { + store, + getItem(name) { + return store.get(name) ?? null; + }, + setItem(name, value) { + store.set(name, value); + }, + removeItem(name) { + store.delete(name); + }, + raw: store, + }; + } + + function asyncCountSchema() { + return fakeSchema<{ count: number }>({ + validate: async (input) => { + const value = input as { count: number }; + if (typeof value?.count !== "number") { + return { issues: [{ message: "count must be a number" }] }; + } + return { value }; + }, + }); + } + + function syncCountSchema() { + return fakeSchema<{ count: number }>({ + validate: (input) => { + const value = input as { count: number }; + if (typeof value?.count !== "number") { + return { issues: [{ message: "count must be a number" }] }; + } + return { value }; + }, + }); + } + + it("round-trips typed state via async schema over hand-rolled PersistStorage", async () => { + const inner = memoryPersistStorage<{ count: number }>(); + const storage = withStandardSchemaAsync(inner, asyncCountSchema()); + + await storage.setItem("test", { state: { count: 42 }, version: 1 }); + const stored = await storage.getItem("test"); + expect(stored).toEqual({ state: { count: 42 }, version: 1 }); + expect(inner.store.get("test")?.state.count).toBe(42); + expect(storage.raw).toBe(inner.raw); + }); + + it("sync schema works through withStandardSchemaAsync", async () => { + const inner = memoryPersistStorage<{ count: number }>(); + const storage = withStandardSchemaAsync(inner, syncCountSchema()); + + await storage.setItem("sync", { state: { count: 5 }, version: 0 }); + await expect(storage.getItem("sync")).resolves.toEqual({ + state: { count: 5 }, + version: 0, + }); + }); + + it("invalid get returns null and clearCorruptOnFailure removes the key", async () => { + const inner = memoryPersistStorage<{ count: number }>(); + inner.store.set("corrupt", { + state: { count: "bad" } as unknown as { count: number }, + version: 0, + }); + + const storage = withStandardSchemaAsync(inner, asyncCountSchema(), { + clearCorruptOnFailure: true, + }); + + await expect(storage.getItem("corrupt")).resolves.toBeNull(); + expect(inner.store.has("corrupt")).toBe(false); + }); + + it("rejecting validate does not leave unhandledRejection", async () => { + const inner = memoryPersistStorage<{ count: number }>(); + const schema = fakeSchema<{ count: number }>({ + validate: () => Promise.reject(new Error("schema boom")), + }); + const storage = withStandardSchemaAsync(inner, schema); + + await expect( + storage.setItem("boom", { state: { count: 1 }, version: 0 }), + ).rejects.toThrow("schema boom"); + + inner.store.set("boom-get", { state: { count: 1 }, version: 0 }); + // Rejecting get validate is contained — returns null, no unhandledRejection. + await expect(storage.getItem("boom-get")).resolves.toBeNull(); + await Promise.resolve(); + }); +}); + +describe("createStandardSchemaStorageAsync", () => { + it("round-trips via fake async schema over MemoryStorage", async () => { + const memory = new MemoryStorage(); + const schema = fakeSchema<{ count: number }>({ + validate: async (input) => { + const value = input as { count: number }; + if (typeof value?.count !== "number") { + return { issues: [{ message: "count must be a number" }] }; + } + return { value }; + }, + }); + const storage = createStandardSchemaStorageAsync<{ count: number }>( + () => memory, + schema, + )!; + + await storage.setItem("async-sugar", { + state: { count: 8 }, + version: 0, + }); + const stored = await storage.getItem("async-sugar"); + expect(stored?.state.count).toBe(8); + }); +}); + +describe("standardSchemaCodec direct seam", () => { + let memory: MemoryStorage; + + beforeEach(() => { + memory = new MemoryStorage(); + }); + + it("standardSchemaCodec plugs into createStorage directly", async () => { + const schema = fakeSchema<{ count: number }>({ + validate: (input) => { + const value = input as { count: number }; + if (typeof value?.count !== "number") { + return { issues: [{ message: "count must be a number" }] }; + } + return { value }; + }, + }); + const storage = createStorage<{ count: number }>( + () => memory, + standardSchemaCodec(schema), + )!; + + await storage.setItem("direct-standard-schema", { + state: { count: 7 }, + version: 0, + }); + const stored = await storage.getItem("direct-standard-schema"); + expect(stored?.state.count).toBe(7); + }); +}); + +describe("standard-schema dependency isolation", () => { + itImportsOnlyFromCore(new URL("./standard-schema.ts", import.meta.url)); +}); diff --git a/src/adapters/codecs/standard-schema.ts b/src/adapters/codecs/standard-schema.ts new file mode 100644 index 0000000..45563bf --- /dev/null +++ b/src/adapters/codecs/standard-schema.ts @@ -0,0 +1,265 @@ +// Standard Schema–gated codec + sync/async PersistStorage wraps — zero +// runtime deps; types vendored from https://standardschema.dev +// (StandardSchemaV1). +import type { + CreateStorageOptions, + PersistStorage, + StateStorage, + StorageCodec, + StorageValue, +} from "../../core/persist-core"; +import { createStorage, jsonCodec } from "../../core/persist-core"; + +/** + * Minimal vendored Standard Schema v1 types. + * Vendoring keeps schema integration type-only and avoids a runtime dependency. + * + * @see https://standardschema.dev + */ +export interface StandardSchemaV1 { + readonly "~standard": StandardSchemaV1.Props; +} + +export declare namespace StandardSchemaV1 { + export interface Props { + readonly version: 1; + readonly vendor: string; + readonly validate: ( + value: unknown, + ) => Result | Promise>; + readonly types?: Types | undefined; + } + export type Result = SuccessResult | FailureResult; + export interface SuccessResult { + readonly value: Output; + readonly issues?: undefined; + } + export interface FailureResult { + readonly issues: ReadonlyArray; + } + export interface Issue { + readonly message: string; + readonly path?: ReadonlyArray | undefined; + } + export interface PathSegment { + readonly key: PropertyKey; + } + export interface Types { + readonly input: Input; + readonly output: Output; + } + export type InferInput = NonNullable< + Schema["~standard"]["types"] + >["input"]; + export type InferOutput = NonNullable< + Schema["~standard"]["types"] + >["output"]; +} + +/** Same options as `createStorage` (`clearCorruptOnFailure`). */ +export type StandardSchemaStorageOptions = CreateStorageOptions; + +const ASYNC_VALIDATE_SYNC_LANE_MESSAGE = + "[@stainless-code/persist] Async Standard Schema validation is not supported — use withStandardSchemaAsync."; + +function isAsyncValidateSyncLaneError(error: unknown): boolean { + return ( + error instanceof Error && error.message === ASYNC_VALIDATE_SYNC_LANE_MESSAGE + ); +} + +function validateSync( + schema: StandardSchemaV1, + input: unknown, +): Output { + const result = schema["~standard"].validate(input); + if (result instanceof Promise) { + // Contain abandoned rejections — same pattern as persist-core write paths. + void result.catch(() => {}); + throw new Error(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: StandardSchemaV1, + input: unknown, +): Promise { + 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 — + * under `createStorage` + `clearCorruptOnFailure` that throw is treated as + * corrupt (prefer `withStandardSchema`, which rethrows wrong-lane errors). + * Envelope `version` / `timestamp` / `buster` are not schema-checked. + */ +export function standardSchemaCodec( + schema: StandardSchemaV1, +): StorageCodec { + return { + encode: (value) => { + const state = validateSync(schema, value.state); + return JSON.stringify({ ...value, state }); + }, + decode: (raw) => { + const envelope = JSON.parse(raw) as StorageValue; + 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`. + */ +export function withStandardSchema( + storage: PersistStorage, + schema: StandardSchemaV1, + options?: StandardSchemaStorageOptions, +): PersistStorage { + const gate = ( + name: string, + value: StorageValue | null, + ): StorageValue | null => { + if (value === null) return null; + try { + return { ...value, state: validateSync(schema, value.state) }; + } catch (error) { + // Wrong lane (async ~standard on sync wrap) is a programmer error — never + // treat as corrupt / clearCorrupt (would delete valid keys). + if (isAsyncValidateSyncLaneError(error)) throw error; + if (options?.clearCorruptOnFailure) { + // Best-effort cleanup; async removeItem must not leak unhandled rejection. + try { + const removal = storage.removeItem(name); + if (removal instanceof Promise) void removal.catch(() => {}); + } catch { + // sync removeItem throw — corrupt already neutralized by returning null + } + } + 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` for sync-only schemas. + */ +export function withStandardSchemaAsync( + storage: PersistStorage, + schema: StandardSchemaV1, + options?: StandardSchemaStorageOptions, +): PersistStorage { + 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 { + // best-effort cleanup — corrupt already neutralized by returning null + } + } + 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 }, + * ); + * ``` + */ +export function createStandardSchemaStorage( + getStorage: () => StateStorage, + schema: StandardSchemaV1, + options?: StandardSchemaStorageOptions, +): PersistStorage | undefined { + 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. + * + * @example + * ```ts + * // Async ~standard (Yup / async refine) — or a sync schema like zod. + * const storage = createStandardSchemaStorageAsync<{ count: number }>( + * () => localStorage, + * schema, + * { clearCorruptOnFailure: true }, + * ); + * ``` + */ +export function createStandardSchemaStorageAsync( + getStorage: () => StateStorage, + schema: StandardSchemaV1, + options?: StandardSchemaStorageOptions, +): PersistStorage | undefined { + const inner = createStorage(getStorage, jsonCodec(), options); + if (!inner) return; + return withStandardSchemaAsync(inner, schema, options); +} diff --git a/src/adapters/codecs/zod.test.ts b/src/adapters/codecs/zod.test.ts deleted file mode 100644 index 69bb6a9..0000000 --- a/src/adapters/codecs/zod.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { beforeEach, describe, expect, it } from "bun:test"; - -import { z } from "zod"; - -import { createStorage, persistSource } from "../../core/persist-core"; -import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; -import { MemoryStorage } from "../../testing/memory-storage"; -import { createMockSource } from "../../testing/mock-source"; -import { waitForHydration } from "../../testing/wait-for-hydration"; -import { createZodStorage, zodCodec } from "./zod"; - -describe("zodCodec", () => { - let memory: MemoryStorage; - - beforeEach(() => { - memory = new MemoryStorage(); - }); - - it("round-trips typed state through a zod schema", async () => { - const schema = z.object({ count: z.number() }); - const storage = createZodStorage<{ count: number }>(() => memory, schema)!; - - await storage.setItem("test", { - state: { count: 42 }, - version: 1, - }); - - const stored = await storage.getItem("test"); - expect(stored?.state.count).toBe(42); - expect(stored?.version).toBe(1); - }); - - it("decode of an invalid payload returns null", async () => { - memory.setItem("bad", JSON.stringify({ state: { count: "not-a-number" } })); - - const schema = z.object({ count: z.number() }); - const storage = createZodStorage<{ count: number }>(() => memory, schema)!; - expect(await storage.getItem("bad")).toBeNull(); - }); - - it("clearCorruptOnFailure removes the key on an invalid payload", async () => { - memory.setItem("corrupt", JSON.stringify({ state: { count: "bad" } })); - - const schema = z.object({ count: z.number() }); - const storage = createZodStorage<{ count: number }>(() => memory, schema, { - clearCorruptOnFailure: true, - })!; - - expect(await storage.getItem("corrupt")).toBeNull(); - expect(memory.getItem("corrupt")).toBeNull(); - }); - - it("encode of an invalid state throws and abandons the write", async () => { - const schema = z.object({ count: z.number() }); - const errors: Array<{ phase: string }> = []; - const source = createMockSource({ count: 0 }); - const persist = persistSource(source, { - name: "invalid-write", - storage: createZodStorage<{ count: number }>(() => memory, schema)!, - onError: (_error, context) => errors.push({ phase: context.phase }), - }); - - await waitForHydration(persist.hasHydrated); - - source.setState( - () => ({ count: "not-a-number" }) as unknown as { count: number }, - ); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(memory.getItem("invalid-write")).toBeNull(); - expect(errors.some((entry) => entry.phase === "write")).toBe(true); - - persist.destroy(); - }); -}); - -describe("zodCodec direct seam", () => { - let memory: MemoryStorage; - - beforeEach(() => { - memory = new MemoryStorage(); - }); - - it("zodCodec plugs into createStorage directly (frozen-API symmetry with serovalCodec)", async () => { - const schema = z.object({ count: z.number() }); - const storage = createStorage<{ count: number }>( - () => memory, - zodCodec(schema), - )!; - - await storage.setItem("direct-zod", { - state: { count: 7 }, - version: 0, - }); - const stored = await storage.getItem("direct-zod"); - expect(stored?.state.count).toBe(7); - }); -}); - -describe("zod dependency isolation", () => { - itImportsOnlyFromCore(new URL("./zod.ts", import.meta.url)); -}); diff --git a/src/adapters/codecs/zod.ts b/src/adapters/codecs/zod.ts deleted file mode 100644 index aa679f6..0000000 --- a/src/adapters/codecs/zod.ts +++ /dev/null @@ -1,56 +0,0 @@ -// zod-validated codec — peer `zod` >=3.20.0. -import { ZodType } from "zod"; - -import type { - CreateStorageOptions, - PersistStorage, - StateStorage, - StorageCodec, - StorageValue, -} from "../../core/persist-core"; -import { createStorage } from "../../core/persist-core"; - -/** Same options as `createStorage` (`clearCorruptOnFailure`). */ -export type ZodStorageOptions = CreateStorageOptions; - -/** - * zod-validated codec — `encode` validates the state before serializing the - * envelope (invalid state never persists; throws surface via `onError` phase - * "write"), `decode` parses + validates the stored state (a validation - * failure throws → persist-core's corrupt-payload path returns null, or with - * `clearCorruptOnFailure` removes the key). Validates `state` only; version / - * timestamp / buster are the envelope's concern, not the schema's. - */ -export function zodCodec(schema: ZodType): StorageCodec { - return { - encode: (value) => { - schema.parse(value.state); - return JSON.stringify(value); - }, - decode: (raw) => { - const envelope = JSON.parse(raw) as StorageValue; - return { ...envelope, state: schema.parse(envelope.state) }; - }, - }; -} - -/** - * Build a zod-validated `PersistStorage`. Any string-keyed `StateStorage` works - * (localStorage, sessionStorage, custom). - * - * @example - * ```ts - * import { z } from "zod"; - * const Prefs = z.object({ theme: z.enum(["light", "dark"]) }); - * const storage = createZodStorage>(() => localStorage, Prefs, { - * clearCorruptOnFailure: true, - * }); - * ``` - */ -export function createZodStorage( - getStorage: () => StateStorage, - schema: ZodType, - options?: ZodStorageOptions, -): PersistStorage | undefined { - return createStorage(getStorage, zodCodec(schema), options); -} diff --git a/tsdown.config.ts b/tsdown.config.ts index 317980b..2210d85 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ entry: { "core/index": "src/core/index.ts", "codecs/seroval": "src/adapters/codecs/seroval.ts", - "codecs/zod": "src/adapters/codecs/zod.ts", + "codecs/standard-schema": "src/adapters/codecs/standard-schema.ts", "backends/idb": "src/adapters/backends/idb.ts", "backends/async-storage": "src/adapters/backends/async-storage.ts", "backends/mmkv": "src/adapters/backends/mmkv.ts", @@ -47,7 +47,6 @@ export default defineConfig({ "idb-keyval", "@tanstack/store", "react", - "zod", "solid-js", "svelte", "@angular/core", diff --git a/typedoc.json b/typedoc.json index 012dc4b..1fdee8f 100644 --- a/typedoc.json +++ b/typedoc.json @@ -4,7 +4,7 @@ "entryPoints": [ "src/core/index.ts", "src/adapters/codecs/seroval.ts", - "src/adapters/codecs/zod.ts", + "src/adapters/codecs/standard-schema.ts", "src/adapters/backends/idb.ts", "src/adapters/backends/async-storage.ts", "src/adapters/backends/mmkv.ts", From 7d57e0814a25ebc53c45dc0569270a6809c633e3 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Wed, 22 Jul 2026 21:44:24 +0300 Subject: [PATCH 2/2] harden: standard-schema wrap tests and hover JSDoc Cover factory wrong-lane/clearCorrupt paths and document async-hydrate gating on the async lane exports. --- apps/docs/content/concepts/entry-points.mdx | 2 +- src/adapters/codecs/standard-schema.test.ts | 73 +++++++++++++++++++++ src/adapters/codecs/standard-schema.ts | 35 ++++++++-- 3 files changed, 104 insertions(+), 6 deletions(-) diff --git a/apps/docs/content/concepts/entry-points.mdx b/apps/docs/content/concepts/entry-points.mdx index 92cfa1a..685ba4c 100644 --- a/apps/docs/content/concepts/entry-points.mdx +++ b/apps/docs/content/concepts/entry-points.mdx @@ -11,7 +11,7 @@ One subpath = one optional peer. No barrel — importing a subpath is the depend | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | | `@stainless-code/persist` | `persistSource`, `PersistApi`, `createStorage`, `createJSONStorage`, `createSessionStorage`, `createMigrationChain`, `jsonCodec`, `identityCodec`, `createPersistRegistry`, `toHydrationSignal`, `alwaysHydratedSignal` | none | | `@stainless-code/persist/codecs/seroval` | `serovalCodec`, `createSerovalStorage` | `seroval` | -| `@stainless-code/persist/codecs/standard-schema` | `withStandardSchema`, `withStandardSchemaAsync`, `standardSchemaCodec`, `createStandardSchemaStorage`, `createStandardSchemaStorageAsync` | none | +| `@stainless-code/persist/codecs/standard-schema` | `StandardSchemaV1`, `withStandardSchema`, `withStandardSchemaAsync`, `standardSchemaCodec`, `createStandardSchemaStorage`, `createStandardSchemaStorageAsync` | none | | `@stainless-code/persist/backends/idb` | `idbStateStorage`, `createIdbStorage` (structured-clone mode) | `idb-keyval` | | `@stainless-code/persist/backends/async-storage` | `asyncStorageStateStorage`, `createAsyncStorage` | `@react-native-async-storage/async-storage` | | `@stainless-code/persist/backends/mmkv` | `mmkvStateStorage`, `createMmkvStorage` | `react-native-mmkv` | diff --git a/src/adapters/codecs/standard-schema.test.ts b/src/adapters/codecs/standard-schema.test.ts index 5d5657e..f098678 100644 --- a/src/adapters/codecs/standard-schema.test.ts +++ b/src/adapters/codecs/standard-schema.test.ts @@ -350,6 +350,33 @@ describe("withStandardSchema", () => { expect(inner.store.has("keep")).toBe(true); }); + it("Promise getItem + async schema throws and does not clearCorrupt", async () => { + const store = new Map>(); + store.set("keep", { state: { count: 1 }, version: 0 }); + const inner: PersistStorage<{ count: number }> = { + getItem(name) { + return Promise.resolve(store.get(name) ?? null); + }, + setItem(name, value) { + store.set(name, value); + }, + removeItem(name) { + store.delete(name); + }, + }; + const schema = fakeSchema<{ count: number }>({ + validate: async (input) => ({ value: input as { count: number } }), + }); + const storage = withStandardSchema(inner, schema, { + clearCorruptOnFailure: true, + }); + const asyncMessage = + "[@stainless-code/persist] Async Standard Schema validation is not supported — use withStandardSchemaAsync."; + + await expect(storage.getItem("keep")).rejects.toThrow(asyncMessage); + expect(store.has("keep")).toBe(true); + }); + it("composes over createStorage + jsonCodec (non-codec-coupled path)", async () => { const memory = new MemoryStorage(); const inner = createStorage<{ count: number }>( @@ -463,6 +490,27 @@ describe("withStandardSchemaAsync", () => { }); }); +describe("createStandardSchemaStorage", () => { + it("wrong-lane async schema on get throws and does not clearCorrupt", async () => { + const memory = new MemoryStorage(); + memory.setItem("keep", JSON.stringify({ state: { count: 1 }, version: 0 })); + const schema = fakeSchema<{ count: number }>({ + validate: async (input) => ({ value: input as { count: number } }), + }); + const storage = createStandardSchemaStorage<{ count: number }>( + () => memory, + schema, + { clearCorruptOnFailure: true }, + )!; + const asyncMessage = + "[@stainless-code/persist] Async Standard Schema validation is not supported — use withStandardSchemaAsync."; + + // Sync JSON backend → sync throw (not a rejected Promise). + expect(() => storage.getItem("keep")).toThrow(asyncMessage); + expect(memory.getItem("keep")).not.toBeNull(); + }); +}); + describe("createStandardSchemaStorageAsync", () => { it("round-trips via fake async schema over MemoryStorage", async () => { const memory = new MemoryStorage(); @@ -487,6 +535,31 @@ describe("createStandardSchemaStorageAsync", () => { const stored = await storage.getItem("async-sugar"); expect(stored?.state.count).toBe(8); }); + + it("invalid get returns null and clearCorruptOnFailure removes the key", async () => { + const memory = new MemoryStorage(); + memory.setItem( + "corrupt", + JSON.stringify({ state: { count: "bad" }, version: 0 }), + ); + const schema = fakeSchema<{ count: number }>({ + validate: async (input) => { + const value = input as { count: number }; + if (typeof value?.count !== "number") { + return { issues: [{ message: "count must be a number" }] }; + } + return { value }; + }, + }); + const storage = createStandardSchemaStorageAsync<{ count: number }>( + () => memory, + schema, + { clearCorruptOnFailure: true }, + )!; + + await expect(storage.getItem("corrupt")).resolves.toBeNull(); + expect(memory.getItem("corrupt")).toBeNull(); + }); }); describe("standardSchemaCodec direct seam", () => { diff --git a/src/adapters/codecs/standard-schema.ts b/src/adapters/codecs/standard-schema.ts index 45563bf..742506f 100644 --- a/src/adapters/codecs/standard-schema.ts +++ b/src/adapters/codecs/standard-schema.ts @@ -126,6 +126,16 @@ export function standardSchemaCodec( * 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, { + * clearCorruptOnFailure: true, + * }); + * ``` */ export function withStandardSchema( storage: PersistStorage, @@ -178,7 +188,19 @@ export function withStandardSchema( /** * Async `~standard` wrap over an existing `PersistStorage`. Awaits validate * (sync schemas OK). Prefer this for Yup / async refine; use - * `withStandardSchema` for sync-only schemas. + * `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(() => localStorage)!, + * yupSchema, + * ); + * ``` */ export function withStandardSchemaAsync( storage: PersistStorage, @@ -242,14 +264,17 @@ export function createStandardSchemaStorage( /** * JSON sugar over `withStandardSchemaAsync` for async `~standard` schemas - * (Yup, async refine). Sync schemas also work through this lane. + * (Yup, async refine). Sync schemas also work through this lane. Forces async + * hydrate even over `localStorage` — gate UI with `useHydrated`. * * @example * ```ts - * // Async ~standard (Yup / async refine) — or a sync schema like zod. - * const storage = createStandardSchemaStorageAsync<{ count: number }>( + * import { createStandardSchemaStorageAsync } from "@stainless-code/persist/codecs/standard-schema"; + * + * // Pass any async ~standard schema (Yup, async refine, …). + * const storage = createStandardSchemaStorageAsync( * () => localStorage, - * schema, + * yupSchema, * { clearCorruptOnFailure: true }, * ); * ```