Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/standard-schema-codec.md
Original file line number Diff line number Diff line change
@@ -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).
4 changes: 2 additions & 2 deletions .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/adapters/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
20 changes: 10 additions & 10 deletions apps/docs/content/concepts/choosing-codec.mdx
Original file line number Diff line number Diff line change
@@ -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<S>` (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<S>` (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).
2 changes: 1 addition & 1 deletion apps/docs/content/concepts/entry-points.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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` | `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` |
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/concepts/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
7 changes: 4 additions & 3 deletions apps/docs/content/concepts/three-seams.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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
Expand Down
20 changes: 10 additions & 10 deletions apps/docs/content/recipes/index.mdx
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion apps/docs/content/recipes/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default defineMeta({
"options",
"wrapping-stores",
"crosstab",
"zod",
"standard-schema",
"react-native",
"node-fs",
],
Expand Down
69 changes: 69 additions & 0 deletions apps/docs/content/recipes/standard-schema.mdx
Original file line number Diff line number Diff line change
@@ -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<typeof prefs>;

const storage = createStandardSchemaStorage<Prefs>(() => 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>()!, 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).
35 changes: 0 additions & 35 deletions apps/docs/content/recipes/zod.mdx

This file was deleted.

8 changes: 6 additions & 2 deletions apps/docs/content/reference/api/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@ One TypeDoc module per package entry. [Reference](/reference)
<Card title="Seroval" href="/reference/api/adapters-codecs-seroval" arrow>
`@stainless-code/persist/codecs/seroval`
</Card>
<Card title="Zod" href="/reference/api/adapters-codecs-zod" arrow>
`@stainless-code/persist/codecs/zod`
<Card
title="Standard Schema"
href="/reference/api/adapters-codecs-standard-schema"
arrow
>
`@stainless-code/persist/codecs/standard-schema`
</Card>
</CardGroup>

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/reference/api/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading