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
4 changes: 2 additions & 2 deletions .changeset/standard-schema-codec.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
"@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.
Add `./codecs/standard-schema` — sync `~standard` codec, `PersistStorage` wraps (`withStandardSchema` / `withStandardSchemaAsync`), and JSON factories. Types vendored; no runtime peer. `createStorage` rethrows `PersistDecodeRethrowError` from decode (wrong-lane / programmer errors — not clearCorrupt).

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).
Remove `./codecs/zod`. Migrate to `createStandardSchemaStorage(getStorage, schema)` (Zod ≥3.24 / v4 via `~standard`). Encode writes schema `value` (defaults/transforms). Yup / async `~standard.validate` → async lane (async hydrate — gate UI).
2 changes: 1 addition & 1 deletion apps/docs/content/concepts/choosing-codec.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ localStorage stays JSON by default. Reach for seroval when Set/Map/Date must rou
| `identityCodec` | ✅ (structured clone) | `StorageValue<S>` (object) | ❌ | structured-clone only (IDB) | core |
| custom `StorageCodec` | yours | yours | yours | any | custom `encode`/`decode` |

`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).
`standardSchemaCodec` is sync [`~standard`](https://standardschema.dev/) over JSON. Prefer `withStandardSchema` / `withStandardSchemaAsync` over any `PersistStorage` when you need non-JSON compose or async validate — [recipe](/recipes/standard-schema). Composition: [The three seams](/concepts/three-seams).
2 changes: 1 addition & 1 deletion apps/docs/content/concepts/three-seams.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const prefs = z.object({ theme: z.enum(["light", "dark"]) });

jsonCodec(); // core default — plain JSON
serovalCodec(); // Set / Map / Date / cycles, inert JSON-shaped output
standardSchemaCodec(prefs); // schema-gated (~standard) — invalid state never writes; corrupt reads discard
standardSchemaCodec(prefs); // schema-gated (~standard) — invalid writes throw; corrupt reads discard via createStorage
// or wrap after createStorage: withStandardSchema(storage, prefs) / withStandardSchemaAsync (Yup / async ~standard)
identityCodec(); // structured-clone backends only — zero serialization
// custom — any pair of pure functions:
Expand Down
8 changes: 4 additions & 4 deletions apps/docs/content/recipes/standard-schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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.
Invalid prefs must not survive a refresh. Pass any sync `~standard` schema — Persist validates on write and on read; `clearCorruptOnFailure` attempts to remove the key.

```ts
import { z } from "zod";
Expand Down Expand Up @@ -55,15 +55,15 @@ 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.
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:
Sync-lane implementers (fact-checked):

- [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`).
[Yup](https://github.com/jquense/yup) implements `~standard` as always-async — use the async lane (or wrap `validateSync` for sync hydrate).

Encode writes the schema `value` (defaults/transforms). Date/Map/Set without a schema → [`./codecs/seroval`](/concepts/choosing-codec).
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. Standard Schema also offers `PersistStorage` wraps (`withStandardSchema` / `withStandardSchemaAsync`) for sync vs async `~standard` lanes — JSON factories are sugar over those wraps.
One API. Sync backends (localStorage) settle hydration before first paint; async backends (IndexedDB) ride the same `getItem` Promise branch (`instanceof Promise`, not thenable duck-typing a stored value with `then` is never a pending read). Gate UI on `useHydrated` for async backends. Standard Schema: `withStandardSchema` / `withStandardSchemaAsync` for sync vs async `~standard`; JSON factories are sugar. `PersistDecodeRethrowError` from `decode` is rethrown by `createStorage` (not clearCorrupt).

## Beyond Query-persister parity

Expand Down
17 changes: 17 additions & 0 deletions src/adapters/codecs/standard-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,23 @@ describe("standardSchemaCodec direct seam", () => {
const stored = await storage.getItem("direct-standard-schema");
expect(stored?.state.count).toBe(7);
});

it("async schema + clearCorruptOnFailure throws and does not clear the key", () => {
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 = createStorage<{ count: number }>(
() => memory,
standardSchemaCodec(schema),
{ clearCorruptOnFailure: true },
)!;
const asyncMessage =
"[@stainless-code/persist] Async Standard Schema validation is not supported — use withStandardSchemaAsync.";

expect(() => storage.getItem("keep")).toThrow(asyncMessage);
expect(memory.getItem("keep")).not.toBeNull();
});
});

describe("standard-schema dependency isolation", () => {
Expand Down
18 changes: 12 additions & 6 deletions src/adapters/codecs/standard-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import type {
StorageCodec,
StorageValue,
} from "../../core/persist-core";
import { createStorage, jsonCodec } from "../../core/persist-core";
import {
createStorage,
jsonCodec,
PersistDecodeRethrowError,
} from "../../core/persist-core";

/**
* Minimal vendored Standard Schema v1 types.
Expand Down Expand Up @@ -64,7 +68,9 @@ const ASYNC_VALIDATE_SYNC_LANE_MESSAGE =

function isAsyncValidateSyncLaneError(error: unknown): boolean {
return (
error instanceof Error && error.message === ASYNC_VALIDATE_SYNC_LANE_MESSAGE
error instanceof PersistDecodeRethrowError ||
(error instanceof Error &&
error.message === ASYNC_VALIDATE_SYNC_LANE_MESSAGE)
);
}

Expand All @@ -76,7 +82,8 @@ function validateSync<Output>(
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);
// PersistDecodeRethrowError — createStorage must not clearCorrupt this.
throw new PersistDecodeRethrowError(ASYNC_VALIDATE_SYNC_LANE_MESSAGE);
}
if (result.issues) {
throw new Error(
Expand All @@ -102,9 +109,8 @@ async function validateAsync<Output>(
/**
* 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).
* corrupt path (`null` / `clearCorruptOnFailure`). Async validate throws
* {@link PersistDecodeRethrowError} (not clearCorrupt under `createStorage`).
* Envelope `version` / `timestamp` / `buster` are not schema-checked.
*/
export function standardSchemaCodec<Output>(
Expand Down
18 changes: 18 additions & 0 deletions src/core/persist-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
createStorage,
identityCodec,
jsonCodec,
PersistDecodeRethrowError,
persistSource,
} from "./persist-core";
import type {
Expand Down Expand Up @@ -92,6 +93,23 @@ describe("createJSONStorage codec seam", () => {
expect(memory.getItem("corrupt")).toBeNull();
});

it("PersistDecodeRethrowError from decode is rethrown and does not clearCorrupt", () => {
memory.setItem("keep", JSON.stringify({ state: { count: 1 }, version: 0 }));
const storage = createStorage<{ count: number }>(
() => memory,
{
encode: jsonCodec<{ count: number }>().encode,
decode: () => {
throw new PersistDecodeRethrowError("wrong lane");
},
},
{ clearCorruptOnFailure: true },
)!;

expect(() => storage.getItem("keep")).toThrow("wrong lane");
expect(memory.getItem("keep")).not.toBeNull();
});

it("createStorage returns undefined when getStorage throws", () => {
expect(
createStorage<{ count: number }>(() => {
Expand Down
17 changes: 14 additions & 3 deletions src/core/persist-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ export interface StorageCodec<S, TRaw = string> {
decode: (raw: TRaw) => StorageValue<S>;
}

/**
* Programmer / wrong-lane errors from `StorageCodec.decode` that must not be
* treated as corrupt payload. {@link createStorage} rethrows these instead of
* returning `null` or running `clearCorruptOnFailure`.
*/
export class PersistDecodeRethrowError extends Error {
override readonly name = "PersistDecodeRethrowError";
}

/** Standard `JSON.parse` reviver / `JSON.stringify` replacer pass-throughs. */
export interface JsonStorageOptions {
reviver?: (key: string, value: unknown) => unknown;
Expand All @@ -83,8 +92,8 @@ export interface JsonStorageOptions {
export interface CreateStorageOptions {
/**
* Remove the storage key when `codec.decode` throws (corrupt-payload
* self-heal). When off, a corrupt payload hydrates to nothing and stays in
* storage.
* self-heal). {@link PersistDecodeRethrowError} is never treated as corrupt.
* When off, a corrupt payload hydrates to nothing and stays in storage.
* @default false
*/
clearCorruptOnFailure?: boolean;
Expand Down Expand Up @@ -592,7 +601,9 @@ export function createStorage<S, TRaw = string>(
if (raw === null) return null;
try {
return codec.decode(raw);
} catch {
} catch (error) {
// Wrong-lane / programmer decode errors must surface — not clearCorrupt.
if (error instanceof PersistDecodeRethrowError) throw error;
if (options?.clearCorruptOnFailure) {
// Best-effort cleanup; an async backend's rejected removeItem must not
// become an unhandled rejection outside every containment path.
Expand Down