From fb3a1261892f7c17ebe98a9a6e93c573c0eff1f9 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Wed, 22 Jul 2026 22:24:54 +0300 Subject: [PATCH 1/2] fix: rethrow wrong-lane decode errors past clearCorrupt Add PersistDecodeRethrowError so createStorage does not treat Standard Schema sync-lane/async-schema mistakes as corrupt payload deletion. --- .changeset/decode-rethrow-wrong-lane.md | 5 +++++ apps/docs/content/concepts/three-seams.mdx | 2 +- apps/docs/content/recipes/standard-schema.mdx | 2 +- src/adapters/codecs/standard-schema.test.ts | 17 +++++++++++++++++ src/adapters/codecs/standard-schema.ts | 18 ++++++++++++------ src/core/persist-core.test.ts | 18 ++++++++++++++++++ src/core/persist-core.ts | 17 ++++++++++++++--- 7 files changed, 68 insertions(+), 11 deletions(-) create mode 100644 .changeset/decode-rethrow-wrong-lane.md diff --git a/.changeset/decode-rethrow-wrong-lane.md b/.changeset/decode-rethrow-wrong-lane.md new file mode 100644 index 0000000..e26c9a7 --- /dev/null +++ b/.changeset/decode-rethrow-wrong-lane.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/persist": patch +--- + +`createStorage` rethrows `PersistDecodeRethrowError` from codec decode (not corrupt / clearCorrupt). Standard Schema wrong-lane async validate uses it so `standardSchemaCodec` + `clearCorruptOnFailure` no longer deletes valid keys. diff --git a/apps/docs/content/concepts/three-seams.mdx b/apps/docs/content/concepts/three-seams.mdx index eb00b5d..9e032f8 100644 --- a/apps/docs/content/concepts/three-seams.mdx +++ b/apps/docs/content/concepts/three-seams.mdx @@ -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: diff --git a/apps/docs/content/recipes/standard-schema.mdx b/apps/docs/content/recipes/standard-schema.mdx index 91431fc..a68124c 100644 --- a/apps/docs/content/recipes/standard-schema.mdx +++ b/apps/docs/content/recipes/standard-schema.mdx @@ -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"; diff --git a/src/adapters/codecs/standard-schema.test.ts b/src/adapters/codecs/standard-schema.test.ts index f098678..61561a2 100644 --- a/src/adapters/codecs/standard-schema.test.ts +++ b/src/adapters/codecs/standard-schema.test.ts @@ -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", () => { diff --git a/src/adapters/codecs/standard-schema.ts b/src/adapters/codecs/standard-schema.ts index 742506f..68ca491 100644 --- a/src/adapters/codecs/standard-schema.ts +++ b/src/adapters/codecs/standard-schema.ts @@ -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. @@ -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) ); } @@ -76,7 +82,8 @@ function validateSync( 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( @@ -102,9 +109,8 @@ async function validateAsync( /** * 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( diff --git a/src/core/persist-core.test.ts b/src/core/persist-core.test.ts index c780198..ab87d36 100644 --- a/src/core/persist-core.test.ts +++ b/src/core/persist-core.test.ts @@ -11,6 +11,7 @@ import { createStorage, identityCodec, jsonCodec, + PersistDecodeRethrowError, persistSource, } from "./persist-core"; import type { @@ -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 }>(() => { diff --git a/src/core/persist-core.ts b/src/core/persist-core.ts index c1a74bd..48f2c68 100644 --- a/src/core/persist-core.ts +++ b/src/core/persist-core.ts @@ -74,6 +74,15 @@ export interface StorageCodec { decode: (raw: TRaw) => StorageValue; } +/** + * 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; @@ -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; @@ -592,7 +601,9 @@ export function createStorage( 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. From bc75ead2622dd44476246e752e8103231436b441 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Wed, 22 Jul 2026 22:26:31 +0300 Subject: [PATCH 2/2] docs: consolidate Standard Schema changeset; slim recipe prose One minor changeset for the feature + wrong-lane clearCorrupt fix after early merge; tighten public-docs wording. --- .changeset/decode-rethrow-wrong-lane.md | 5 ----- .changeset/standard-schema-codec.md | 4 ++-- apps/docs/content/concepts/choosing-codec.mdx | 2 +- apps/docs/content/recipes/standard-schema.mdx | 6 +++--- docs/architecture.md | 2 +- 5 files changed, 7 insertions(+), 12 deletions(-) delete mode 100644 .changeset/decode-rethrow-wrong-lane.md diff --git a/.changeset/decode-rethrow-wrong-lane.md b/.changeset/decode-rethrow-wrong-lane.md deleted file mode 100644 index e26c9a7..0000000 --- a/.changeset/decode-rethrow-wrong-lane.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@stainless-code/persist": patch ---- - -`createStorage` rethrows `PersistDecodeRethrowError` from codec decode (not corrupt / clearCorrupt). Standard Schema wrong-lane async validate uses it so `standardSchemaCodec` + `clearCorruptOnFailure` no longer deletes valid keys. diff --git a/.changeset/standard-schema-codec.md b/.changeset/standard-schema-codec.md index 88bd5a3..7d3a9a1 100644 --- a/.changeset/standard-schema-codec.md +++ b/.changeset/standard-schema-codec.md @@ -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). diff --git a/apps/docs/content/concepts/choosing-codec.mdx b/apps/docs/content/concepts/choosing-codec.mdx index 8e159cf..6c714b4 100644 --- a/apps/docs/content/concepts/choosing-codec.mdx +++ b/apps/docs/content/concepts/choosing-codec.mdx @@ -15,4 +15,4 @@ localStorage stays JSON by default. Reach for seroval when Set/Map/Date must rou | `identityCodec` | ✅ (structured clone) | `StorageValue` (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). diff --git a/apps/docs/content/recipes/standard-schema.mdx b/apps/docs/content/recipes/standard-schema.mdx index a68124c..fe4ca48 100644 --- a/apps/docs/content/recipes/standard-schema.mdx +++ b/apps/docs/content/recipes/standard-schema.mdx @@ -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). diff --git a/docs/architecture.md b/docs/architecture.md index 55672ba..02f8370 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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