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