diff --git a/.changeset/intent-skills-rename.md b/.changeset/intent-skills-rename.md new file mode 100644 index 0000000..39cd8de --- /dev/null +++ b/.changeset/intent-skills-rename.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/persist": patch +--- + +Ship Intent skills for the full public surface — core `persist`, `persist-*` (sources/codecs/backends/transport), and `-persist`. Re-run `npx @tanstack/intent@latest install` if agent config still points at `skills/tanstack-store`. diff --git a/.github/workflows/check-skills.yml b/.github/workflows/check-skills.yml index 324751c..ccece94 100644 --- a/.github/workflows/check-skills.yml +++ b/.github/workflows/check-skills.yml @@ -1,10 +1,4 @@ -# check-skills.yml — Validates intent skills on PRs. After a release or manual -# run, opens or updates one review PR when existing skills, artifact coverage, -# or workspace package coverage need review. -# -# Triggers: pull requests touching skills/artifacts, new release published, or -# manual workflow_dispatch. -# +# Validate skills on PR; post-release/manual: open Intent stale-coverage review PR. # intent-workflow-version: 3 name: Check Skills @@ -14,8 +8,6 @@ on: paths: - "skills/**" - "**/skills/**" - - "_artifacts/**" - - "**/_artifacts/**" release: types: [published] workflow_dispatch: {} @@ -31,16 +23,11 @@ jobs: - name: Checkout uses: actions/checkout@v7 - - name: Setup Node - uses: actions/setup-node@v7 - with: - node-version: 20 - - - name: Install intent - run: npm install -g @tanstack/intent@0.3.4 + - name: Setup Bun and install + uses: ./.github/actions/setup - name: Validate skills - run: intent validate --github-summary + run: bun run intent:validate -- --github-summary review: name: Check intent skill coverage @@ -55,18 +42,13 @@ jobs: with: fetch-depth: 0 - - name: Setup Node - uses: actions/setup-node@v7 - with: - node-version: 20 - - - name: Install intent - run: npm install -g @tanstack/intent@0.3.4 + - name: Setup Bun and install + uses: ./.github/actions/setup - name: Check skills id: stale run: | - intent stale --github-review --package-label "@stainless-code/persist" + bun run intent:stale -- --github-review --package-label "@stainless-code/persist" - name: Open or update review PR if: steps.stale.outputs.has_review == 'true' diff --git a/package.json b/package.json index cbb7f8a..8e007e7 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "store", "svelte", "tanstack", + "tanstack-intent", "tanstack-store", "typescript", "valtio", @@ -54,8 +55,7 @@ ], "files": [ "dist", - "skills", - "!skills/_artifacts" + "skills" ], "type": "module", "sideEffects": false, diff --git a/scripts/sync-skill-versions.ts b/scripts/sync-skill-versions.ts index f80f2fe..9a221c2 100755 --- a/scripts/sync-skill-versions.ts +++ b/scripts/sync-skill-versions.ts @@ -1,19 +1,26 @@ #!/usr/bin/env bun -// Stamp `library_version` in every skills/*/SKILL.md frontmatter to the +// Stamp `library_version` in every skills/**/SKILL.md frontmatter to the // current package version. Wired into the `version` script so the changesets // "Version packages" PR carries the skill bump alongside the package bump — // keeps `intent stale` quiet without a manual review PR per release. -import { readFileSync, readdirSync, writeFileSync, existsSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; const { version } = JSON.parse(readFileSync("package.json", "utf8")); const FIELD = /^( *library_version:\s*")([^"]*)(")/m; +function* walkSkillFiles(dir: string): Generator { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const nested = join(dir, entry.name); + const skill = join(nested, "SKILL.md"); + if (existsSync(skill)) yield skill; + yield* walkSkillFiles(nested); + } +} + let touched = 0; -for (const entry of readdirSync("skills", { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const file = join("skills", entry.name, "SKILL.md"); - if (!existsSync(file)) continue; +for (const file of walkSkillFiles("skills")) { const before = readFileSync(file, "utf8"); const after = before.replace(FIELD, `$1${version}$3`); if (after !== before) { diff --git a/skills/alpine-persist/SKILL.md b/skills/alpine-persist/SKILL.md new file mode 100644 index 0000000..1d71348 --- /dev/null +++ b/skills/alpine-persist/SKILL.md @@ -0,0 +1,62 @@ +--- +name: alpine-persist +description: Gate Alpine.js UI on Persist hydration (Alpine.plugin + useHydrated / $hydrated). Use when wiring HydrationSignal into Alpine data components. +license: MIT +metadata: + type: framework + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "alpine" +requires: + - persist +sources: + - stainless-code/persist:src/adapters/frameworks/alpine.ts +--- + +# Alpine hydration gate + +`@stainless-code/persist/frameworks/alpine` is **plugin-first**: `Alpine.plugin(persist)` registers `$hydrated` and enables reactive `useHydrated`. Call `destroy()` from `Alpine.data` teardown. + +## Install + +```bash +bun add @stainless-code/persist alpinejs +``` + +Peer: `alpinejs` `>=3.0.0` (types via `@types/alpinejs` — package ships none). + +## Minimal wiring + +```ts +import Alpine from "alpinejs"; +import persist, { + useHydrated, +} from "@stainless-code/persist/frameworks/alpine"; + +Alpine.plugin(persist); + +Alpine.data("prefs", () => { + const hydration = useHydrated(prefsHydration); + return { + get hydrated() { + return hydration.hydrated; + }, + destroy() { + hydration.destroy(); + }, + }; +}); +``` + +Template: `x-show="$hydrated(prefsHydration).hydrated"` — `$hydrated(signal)` returns a **bag** (`{ hydrated, destroy }`), not a boolean (a bare bag is always truthy in `x-show`). Magic caches per element. + +## Common mistakes + +- **Skipping `Alpine.plugin(persist)`.** `useHydrated` falls back to a plain object + one-time non-prod warn. +- **`x-show="$hydrated(...)"` without `.hydrated`.** +- **Not forwarding `destroy()`** from `Alpine.data`. + +## API surface + +- default/`persist(Alpine)` — plugin; registers `$hydrated(signal) → HydratedBag` +- `useHydrated(signal) → { hydrated; destroy() }` (null signal → `{ hydrated: true }`) diff --git a/skills/angular-persist/SKILL.md b/skills/angular-persist/SKILL.md new file mode 100644 index 0000000..90919e2 --- /dev/null +++ b/skills/angular-persist/SKILL.md @@ -0,0 +1,54 @@ +--- +name: angular-persist +description: Gate Angular UI on Persist hydration with useHydrated (readonly Signal). Call inside an injection context; use when avoiding flash of default state on async backends. +license: MIT +metadata: + type: framework + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "angular" +requires: + - persist +sources: + - stainless-code/persist:src/adapters/frameworks/angular.ts +--- + +# Angular hydration gate + +`@stainless-code/persist/frameworks/angular` returns a readonly `Signal`. A real signal needs an **injection context** (`effect()`); null/undefined returns a shared `signal(true)` without `effect()`. + +## Install + +```bash +bun add @stainless-code/persist @angular/core +``` + +Peer: `@angular/core` `>=17.0.0`. + +## Minimal wiring + +```ts +import { useHydrated } from "@stainless-code/persist/frameworks/angular"; + +export class PrefsComponent { + hydrated = useHydrated(prefsHydration); +} +``` + +```html +@if (hydrated()) { + +} @else { + +} +``` + +## Common mistakes + +- **Calling outside injection context.** +- **Treating the signal as store state.** +- **SSR / null signal** — both surface as hydrated `true`. + +## API surface + +- `useHydrated(signal) → Signal` (readonly) diff --git a/skills/lit-persist/SKILL.md b/skills/lit-persist/SKILL.md new file mode 100644 index 0000000..7a86ae9 --- /dev/null +++ b/skills/lit-persist/SKILL.md @@ -0,0 +1,53 @@ +--- +name: lit-persist +description: Gate Lit element UI on Persist hydration with HydrationController. Use when wiring HydrationSignal into a ReactiveControllerHost; not a hook. +license: MIT +metadata: + type: framework + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "lit" +requires: + - persist +sources: + - stainless-code/persist:src/adapters/frameworks/lit.ts +--- + +# Lit hydration gate + +`@stainless-code/persist/frameworks/lit` exports `HydrationController` — a `ReactiveController`, not a hook. Constructor calls `host.addController(this)`; subscribes on `hostConnected`, tears down on `hostDisconnected`. + +## Install + +```bash +bun add @stainless-code/persist lit +``` + +Peer: `lit` `>=3.0.0`. + +## Minimal wiring + +```ts +import { LitElement, html } from "lit"; +import { HydrationController } from "@stainless-code/persist/frameworks/lit"; + +class PrefsEl extends LitElement { + #hydration = new HydrationController(this, prefsHydration); + + render() { + return this.#hydration.hydrated + ? html`` + : html``; + } +} +``` + +## Common mistakes + +- **Inventing `useHydrated` for Lit.** Use the controller. +- **Caching `hydrated` outside render** — the controller already `requestUpdate`s on connect/change; read the getter in `render()`. +- **Null signal ≠ loading** → `hydrated` true, no subscribe. + +## API surface + +- `new HydrationController(host, signal)` — getter `hydrated: boolean` diff --git a/skills/persist-async-storage/SKILL.md b/skills/persist-async-storage/SKILL.md new file mode 100644 index 0000000..36bfbc9 --- /dev/null +++ b/skills/persist-async-storage/SKILL.md @@ -0,0 +1,48 @@ +--- +name: persist-async-storage +description: Persist with React Native AsyncStorage via @stainless-code/persist/backends/async-storage. Use for RN string-wire JSON state; always gate with useHydrated. +license: MIT +metadata: + type: composition + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "@react-native-async-storage/async-storage" +requires: + - persist +sources: + - stainless-code/persist:src/adapters/backends/async-storage.ts +--- + +# AsyncStorage backend + +JSON via `createJSONStorage` under the hood. Fully **async** → **`useHydrated` mandatory**. Factory does **not** accept `clearCorruptOnFailure` — use `createStorage(() => asyncStorageStateStorage(), jsonCodec(), opts)` when you need it. + +## Install + +```bash +bun add @stainless-code/persist @react-native-async-storage/async-storage +``` + +Peer: `@react-native-async-storage/async-storage` `>=1.0.0`. + +## Minimal wiring + +```ts +import { createAsyncStorage } from "@stainless-code/persist/backends/async-storage"; + +const storage = createAsyncStorage()!; +``` + +Optional custom instance for namespacing: `createAsyncStorage(myAsyncStorage)`. + +## Common mistakes + +- **Treating hydrate as sync.** +- **Expecting Set/Map/Date** — use seroval compose or avoid. +- **Passing `clearCorruptOnFailure` to the factory.** + +## API surface + +- `createAsyncStorage(storage?)` · `asyncStorageStateStorage(storage?)` + +See also: `persist-mmkv` (sync RN). diff --git a/skills/persist-compressed/SKILL.md b/skills/persist-compressed/SKILL.md new file mode 100644 index 0000000..0e04c8b --- /dev/null +++ b/skills/persist-compressed/SKILL.md @@ -0,0 +1,45 @@ +--- +name: persist-compressed +description: Compress a string-wire StateStorage with @stainless-code/persist/backends/compressed (gzip/deflate). Backend wrapper — compose with createStorage; stack compress-then-encrypt. +license: MIT +metadata: + type: composition + library: "@stainless-code/persist" + library_version: "0.4.0" +requires: + - persist +sources: + - stainless-code/persist:src/adapters/backends/compressed.ts +--- + +# Compressed backend wrapper + +**Not a `StorageCodec`.** Uses `CompressionStream` / `DecompressionStream` (default `gzip`); output is **base64** on the string wire. Returns `undefined` if streams unavailable. Documented stack with encryption: **compress → encrypt**. + +## Minimal wiring + +```ts +import { createStorage, jsonCodec } from "@stainless-code/persist"; +import { createCompressedStorage } from "@stainless-code/persist/backends/compressed"; + +const storage = createStorage( + () => createCompressedStorage(() => localStorage)!, + jsonCodec(), +); +``` + +Richer graphs → `persist-seroval` as the codec. + +Formats: `gzip` | `deflate` | `deflate-raw` (`deflate-raw` needs Node `20.12+`). + +## Common mistakes + +- **Using as a codec.** +- **Encrypt-then-compress.** +- **Assuming streams exist in every runtime.** + +## API surface + +- `createCompressedStorage(getStorage, { format? }) → StateStorage | undefined` + +See also: `persist-encrypted`. diff --git a/skills/persist-crosstab/SKILL.md b/skills/persist-crosstab/SKILL.md new file mode 100644 index 0000000..1b24b93 --- /dev/null +++ b/skills/persist-crosstab/SKILL.md @@ -0,0 +1,58 @@ +--- +name: persist-crosstab +description: Cross-tab sync for backends without storage events via @stainless-code/persist/transport/crosstab (createBroadcastCrossTab). Wrap storage + pass crossTabEventTarget; call close() on teardown. +license: MIT +metadata: + type: composition + library: "@stainless-code/persist" + library_version: "0.4.0" +requires: + - persist +sources: + - stainless-code/persist:src/adapters/transport/crosstab.ts +--- + +# BroadcastChannel cross-tab transport + +For backends with **no** `storage` events (IndexedDB). Must **`wrap(storage)`** and pass `crossTabEventTarget` with `crossTab: true`. Posts `storageArea: null` (key-only match — each tab owns its backend instance). Returns `undefined` if `BroadcastChannel` missing. Call `close()` on teardown. + +## Install + +```bash +bun add @stainless-code/persist idb-keyval @tanstack/store +``` + +Example peers: `idb-keyval` (IDB backend) + `@tanstack/store` (source). Swap either for your stack. + +## Minimal wiring + +```ts +import { Store } from "@tanstack/store"; +import { createIdbStorage } from "@stainless-code/persist/backends/idb"; +import { persistStore } from "@stainless-code/persist/sources/tanstack-store"; +import { createBroadcastCrossTab } from "@stainless-code/persist/transport/crosstab"; + +const store = new Store({ theme: "light" }); +const bridge = createBroadcastCrossTab({ channelName: "app:prefs" })!; +const persist = persistStore(store, { + name: "app:prefs:v1", + storage: bridge.wrap(createIdbStorage()!), + crossTab: true, + crossTabEventTarget: bridge.crossTabEventTarget, +}); +// teardown: persist.destroy(); bridge.close(); +``` + +`localStorage` can use `crossTab: true` alone — this bridge is for IDB-like backends. + +## Common mistakes + +- **`crossTab: true` alone on IDB** — no native events. +- **Forgetting `wrap` or `close`.** +- **Expecting `storageArea` reference equality across tabs.** + +## API surface + +- `createBroadcastCrossTab({ channelName }) → { crossTabEventTarget, wrap, close } | undefined` + +See also: `persist-idb`. diff --git a/skills/persist-encrypted/SKILL.md b/skills/persist-encrypted/SKILL.md new file mode 100644 index 0000000..1a541a8 --- /dev/null +++ b/skills/persist-encrypted/SKILL.md @@ -0,0 +1,51 @@ +--- +name: persist-encrypted +description: AES-GCM encrypt a string-wire StateStorage with @stainless-code/persist/backends/encrypted (createEncryptedStorage). Backend wrapper — compose with createStorage + codec; wrong key rejects hydrate (not clearCorrupt). +license: MIT +metadata: + type: composition + library: "@stainless-code/persist" + library_version: "0.4.0" +requires: + - persist +sources: + - stainless-code/persist:src/adapters/backends/encrypted.ts +--- + +# Encrypted backend wrapper + +**Not a `StorageCodec`.** Wraps an inner string `StateStorage` with WebCrypto AES-GCM (`base64(iv).base64(ct)`). Returns `undefined` if `crypto.subtle` missing. Stack with compression: **compress → encrypt**. + +Decrypt failure (wrong key / tamper) → backend `getItem` **rejects** → `onError` phase `"hydrate"` — **not** the codec `clearCorruptOnFailure` path. + +## Minimal wiring + +```ts +import { createStorage, jsonCodec } from "@stainless-code/persist"; +import { createEncryptedStorage } from "@stainless-code/persist/backends/encrypted"; + +const key = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"], +); +const storage = createStorage( + () => createEncryptedStorage(() => localStorage, { key })!, + jsonCodec(), + { clearCorruptOnFailure: true }, +); +``` + +Richer graphs → compose with `persist-seroval` instead of `jsonCodec`. + +## Common mistakes + +- **Treating this as a sync codec.** +- **Expecting clearCorrupt on decrypt fail.** +- **Encrypt-then-compress** — reverse of the documented stack. + +## API surface + +- `createEncryptedStorage(getStorage, { key }) → StateStorage | undefined` + +See also: `persist-compressed`. diff --git a/skills/persist-idb/SKILL.md b/skills/persist-idb/SKILL.md new file mode 100644 index 0000000..2de6259 --- /dev/null +++ b/skills/persist-idb/SKILL.md @@ -0,0 +1,54 @@ +--- +name: persist-idb +description: Persist with IndexedDB via @stainless-code/persist/backends/idb (createIdbStorage / idbStateStorage). Use for large or structured-clone state; pair with useHydrated and BroadcastChannel cross-tab. +license: MIT +metadata: + type: composition + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "idb-keyval" +requires: + - persist +sources: + - stainless-code/persist:src/adapters/backends/idb.ts +--- + +# IndexedDB backend + +`createIdbStorage` uses **structured clone** + `identityCodec` — `Set`/`Map`/`Date` work without seroval. Fully **async** → gate UI. No `storage` events → use `persist-crosstab` for multi-tab. + +## Install + +```bash +bun add @stainless-code/persist idb-keyval +``` + +Peer: `idb-keyval` `>=4.0.0`. + +## Minimal wiring + +```ts +import { createIdbStorage } from "@stainless-code/persist/backends/idb"; + +const storage = createIdbStorage(); +``` + +Custom codec: `createStorage(() => idbStateStorage(store), codec, opts)`. Encrypt wraps the **backend**, not the codec arg — `createStorage(() => createEncryptedStorage(() => idbStateStorage(), { key })!, codec, opts)` (see `persist-encrypted`). + +## Gotchas + +- **`clearCorruptOnFailure` is inert** on the default identity path (identity never throws). +- IDB missing → reject on first use (`onError` `"hydrate"`), not at construct. +- `crossTab: true` alone is useless on IDB — no `storage` events; use `persist-crosstab` (`wrap` + `crossTabEventTarget`). + +## Common mistakes + +- **Layering JSON/seroval on default IDB** when structured clone already covers the graph. +- **Skipping `useHydrated`.** +- **Expecting native `storage` events.** + +## API surface + +- `createIdbStorage(options?)` · `idbStateStorage(store?)` + +See also: `persist-crosstab`; `*-persist` for UI gates. diff --git a/skills/persist-jotai/SKILL.md b/skills/persist-jotai/SKILL.md new file mode 100644 index 0000000..08c6184 --- /dev/null +++ b/skills/persist-jotai/SKILL.md @@ -0,0 +1,64 @@ +--- +name: persist-jotai +description: Persist a jotai WritableAtom via createStore with @stainless-code/persist (persistAtom). Use when wiring jotai atoms to localStorage/sessionStorage/IndexedDB, or when default replace-merge for primitive atoms matters. +license: MIT +metadata: + type: composition + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "jotai" +sources: + - stainless-code/persist:src/adapters/sources/jotai.ts + - stainless-code/persist:docs/architecture.md +--- + +# Persisting jotai + +`@stainless-code/persist/sources/jotai` persists a **WritableAtom** through a jotai **Store** (`createStore()` → `{ get, set, sub }`). + +## When to use this skill + +- You own a writable jotai atom and a store, and want reload survival. +- Atom values may be primitives — default merge must **replace**, not shallow-spread. + +Zustand / TanStack Store → those `persist-*` skills. Hand-rolled → `persistSource`. + +## Install + +```bash +bun add @stainless-code/persist jotai +``` + +`jotai` is an optional peer of `/sources/jotai`. + +## Minimal wiring + +```ts +import { atom, createStore } from "jotai"; +import { createJSONStorage } from "@stainless-code/persist"; +import { persistAtom } from "@stainless-code/persist/sources/jotai"; + +const themeAtom = atom<"light" | "dark">("light"); +const store = createStore(); +const persist = persistAtom(store, themeAtom, { + name: "app:theme:v1", + storage: createJSONStorage(() => localStorage), +}); +``` + +Needs **both** `store` and `atom`. Provider-only / atom-without-store is not enough. + +## Replace-merge default + +Default `merge` is `(persisted) => persisted`. Shallow-spreading a primitive corrupts it (`{...number}` → `{}`). Pass your own `merge` for object atoms that need spread-merge. Override uses `??`, so `merge: undefined` still replace-merges. + +## Common mistakes + +- **Omitting `createStore`.** `persistAtom(store, atom, opts)` — store first. +- **Readonly / computed atoms.** Typed as `WritableAtom` only — **no** runtime throw (unlike TanStack `persistAtom`). +- **Assuming object shallow-merge.** Primitives need replace (the default). + +## API surface + +- `persistAtom(store, atom, options) → PersistApi` +- Options / `PersistApi`: see skill `persist`. Replace-merge also used by TanStack `persistAtom` (`persist-tanstack-store`). diff --git a/skills/persist-mmkv/SKILL.md b/skills/persist-mmkv/SKILL.md new file mode 100644 index 0000000..c758355 --- /dev/null +++ b/skills/persist-mmkv/SKILL.md @@ -0,0 +1,46 @@ +--- +name: persist-mmkv +description: Persist with react-native-mmkv via @stainless-code/persist/backends/mmkv (createMmkvStorage). Sync JSON string-wire; id required; optional MMKV encryptionKey (≤16 bytes). +license: MIT +metadata: + type: composition + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "react-native-mmkv" +requires: + - persist +sources: + - stainless-code/persist:src/adapters/backends/mmkv.ts +--- + +# MMKV backend + +**Sync** backend — no hydrate UI gate required for flash. JSON string-wire. `id` namespaces the MMKV file. `encryptionKey` is MMKV's own key (max **16 bytes**), not `persist-encrypted`. + +## Install + +```bash +bun add @stainless-code/persist react-native-mmkv +``` + +Peer: `react-native-mmkv` `>=4.0.0`. + +## Minimal wiring + +```ts +import { createMmkvStorage } from "@stainless-code/persist/backends/mmkv"; + +const storage = createMmkvStorage({ id: "app-prefs" })!; +``` + +## Common mistakes + +- **Gating like IDB** — MMKV is sync. +- **Confusing `encryptionKey` with `backends/encrypted`.** +- **Omitting `id`.** + +## API surface + +- `createMmkvStorage({ id, path?, encryptionKey? })` · `mmkvStateStorage(instance)` + +See also: `persist-async-storage`; `persist-encrypted` for WebCrypto AES-GCM. diff --git a/skills/persist-mobx/SKILL.md b/skills/persist-mobx/SKILL.md new file mode 100644 index 0000000..e27f837 --- /dev/null +++ b/skills/persist-mobx/SKILL.md @@ -0,0 +1,61 @@ +--- +name: persist-mobx +description: Persist a MobX observable object with @stainless-code/persist (persistObservable). Use when wiring MobX state to localStorage/sessionStorage/IndexedDB under enforceActions. +license: MIT +metadata: + type: composition + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "mobx" +sources: + - stainless-code/persist:src/adapters/sources/mobx.ts + - stainless-code/persist:docs/architecture.md +--- + +# Persisting MobX + +`@stainless-code/persist/sources/mobx` maps an **observable object** through `toJS` / `runInAction`+`Object.assign` / `observe` onto `persistSource`. + +## When to use this skill + +- You have `observable.object({…})` (or compatible object observable) and want reload survival. +- You use `configure({ enforceActions: "always" })` — hydrate must run inside an action. + +## Install + +```bash +bun add @stainless-code/persist mobx +``` + +`mobx` is an optional peer of `/sources/mobx`. + +## Minimal wiring + +```ts +import { observable } from "mobx"; +import { createJSONStorage } from "@stainless-code/persist"; +import { persistObservable } from "@stainless-code/persist/sources/mobx"; + +const prefs = observable.object({ theme: "light" as const }); +const persist = persistObservable(prefs, { + name: "app:prefs:v1", + storage: createJSONStorage(() => localStorage), +}); +``` + +## Hydrate semantics + +Hydrate uses `runInAction(() => Object.assign(observable, next))`. Bare assign would throw under `enforceActions: "always"`. Keys absent from `next` are not deleted (same as valtio). + +## Common mistakes + +- **Writing hydrate outside actions** in custom `persistSource` ports — this adapter already wraps `runInAction`. +- **Class instances / primitives** as the persist root — target is an object observable. +- **Expecting deep replace** from `Object.assign` alone. + +## API surface + +- `persistObservable(observable, options) → PersistApi` +- Options / `PersistApi`: same as `persistSource` + +See also: `persist-valtio` — same object + assign hydrate pattern without MobX actions. diff --git a/skills/persist-node-fs/SKILL.md b/skills/persist-node-fs/SKILL.md new file mode 100644 index 0000000..0e6dd37 --- /dev/null +++ b/skills/persist-node-fs/SKILL.md @@ -0,0 +1,41 @@ +--- +name: persist-node-fs +description: Persist to the filesystem with @stainless-code/persist/backends/node-fs (nodeFsStateStorage). StateStorage only — compose createStorage + codec; one file per key with hash suffix. +license: MIT +metadata: + type: composition + library: "@stainless-code/persist" + library_version: "0.4.0" +requires: + - persist +sources: + - stainless-code/persist:src/adapters/backends/node-fs.ts +--- + +# Node filesystem backend + +Exports **`nodeFsStateStorage({ dir })` only** — compose with `createStorage` + a codec. One file per key; sanitized name + **djb2 hash** suffix (collision-safe). Refuses keys that sanitize to `.` / `..` / `""`. ENOENT → null / no-op remove. + +## Minimal wiring + +```ts +import { createStorage, jsonCodec } from "@stainless-code/persist"; +import { nodeFsStateStorage } from "@stainless-code/persist/backends/node-fs"; + +const storage = createStorage( + () => nodeFsStateStorage({ dir: "./.persist" }), + jsonCodec(), +); +``` + +No optional peer for this backend — uses `node:fs`. Richer graphs → `persist-seroval` as the codec. + +## Common mistakes + +- **Expecting a `create*Storage` PersistStorage factory.** +- **Assuming sanitize alone makes unique paths** — the hash suffix is required for collisions. +- **Path-traversal keys** — refused after sanitize. + +## API surface + +- `nodeFsStateStorage({ dir }) → StateStorage` diff --git a/skills/persist-pinia/SKILL.md b/skills/persist-pinia/SKILL.md new file mode 100644 index 0000000..5a11115 --- /dev/null +++ b/skills/persist-pinia/SKILL.md @@ -0,0 +1,62 @@ +--- +name: persist-pinia +description: Persist a Pinia store instance with @stainless-code/persist (persistStore). Use when wiring Pinia option/setup stores to storage; hydrate applies via $state = (not $patch), with default shallow merge. +license: MIT +metadata: + type: composition + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "pinia" +sources: + - stainless-code/persist:src/adapters/sources/pinia.ts + - stainless-code/persist:docs/architecture.md +--- + +# Persisting Pinia + +`@stainless-code/persist/sources/pinia` maps a **store instance** onto `persistSource`. Hydrate applies via **`$state =`** (not `$patch`); default `merge` still shallow-spreads, so keys absent from the payload can remain. Subscribe uses `$subscribe(…, { detached: true })` so the listener survives Vue `effectScope` stop. + +## When to use this skill + +- You have `const store = useX()` and want reload survival for option or setup stores. +- You need Persist's gate / codecs / cross-tab instead of (or beside) pinia-plugin-persistedstate. + +## Install + +```bash +bun add @stainless-code/persist pinia +``` + +`pinia` is an optional peer of `/sources/pinia` (Vue app + `createPinia()` still required by Pinia). + +## Minimal wiring + +```ts +import { defineStore } from "pinia"; +import { createJSONStorage } from "@stainless-code/persist"; +import { persistStore } from "@stainless-code/persist/sources/pinia"; + +const usePrefs = defineStore("prefs", { + state: () => ({ theme: "light" as const }), +}); + +const store = usePrefs(); // instance, after pinia is active +const persist = persistStore(store, { + name: "app:prefs:v1", + storage: createJSONStorage(() => localStorage), +}); +``` + +## Common mistakes + +- **Passing `defineStore(...)` / the factory** instead of `usePrefs()` instance. +- **Expecting `$patch` semantics on hydrate.** Persist assigns `$state` after `merge`. +- **Name clash with Redux `persistStore`.** Import from `/sources/pinia` (or alias). +- **Call before `setActivePinia` / app.use(pinia).** Same as any Pinia store use. + +## API surface + +- `persistStore(store, options) → PersistApi` — `store` is a Pinia `Store` +- Options / `PersistApi`: same as `persistSource` + +See also: `react-persist` / Vue hydration adapters for UI gating on async backends. diff --git a/skills/persist-redux/SKILL.md b/skills/persist-redux/SKILL.md new file mode 100644 index 0000000..ea444dc --- /dev/null +++ b/skills/persist-redux/SKILL.md @@ -0,0 +1,73 @@ +--- +name: persist-redux +description: Persist a Redux/RTK store with @stainless-code/persist (persistStore + persistableReducer). Use when wrapping the root reducer for hydrate SET actions, or migrating off redux-persist. +license: MIT +metadata: + type: composition + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "redux" +sources: + - stainless-code/persist:src/adapters/sources/redux.ts + - stainless-code/persist:docs/architecture.md +--- + +# Persisting Redux / RTK + +`@stainless-code/persist/sources/redux` has no native `setState` — hydrate dispatches a private `@stainless-code/persist/SET` action. The **root reducer must** be wrapped with `persistableReducer` so that action replaces state. + +## When to use this skill + +- Classic `redux` or `@reduxjs/toolkit` `configureStore` / `createStore`. +- Migrating off `redux-persist` while keeping one Persist stack across the app. + +## Install + +```bash +bun add @stainless-code/persist redux +# and/or +bun add @reduxjs/toolkit +``` + +`redux` `>=5` is the optional peer (covers classic + RTK stores). + +## Minimal wiring + +```ts +import { createStore } from "redux"; +import { createJSONStorage } from "@stainless-code/persist"; +import { + persistStore, + persistableReducer, +} from "@stainless-code/persist/sources/redux"; + +const store = createStore(persistableReducer(rootReducer)); +const persist = persistStore(store, { + name: "app:root:v1", + storage: createJSONStorage(() => localStorage), +}); +``` + +RTK: pass `reducer: persistableReducer(rootReducer)` (or wrap the combined root) into `configureStore`. + +## `persistableReducer` (required) + +- Wrap the **root** only — not each slice, not leaves inside `combineReducers`. +- Per-slice wrap corrupts hydrate (payload is the full root state). +- Without the wrap, hydrate no-ops; in DEV Persist `console.warn`s if state identity is unchanged after the SET dispatch. +- Named `persistableReducer` to avoid clashing with redux-persist's `persistReducer`. + +## Common mistakes + +- **Forgetting `persistableReducer`.** Silent no-op hydrate. +- **Wrapping slices.** Full-state SET payload won't match slice shape. +- **`replaceReducer` for hydrate.** Not the Persist path. +- **Name clash with redux-persist `persistStore`.** Alias one import. + +## API surface + +- `persistableReducer(baseReducer) → Reducer` +- `persistStore(store, options) → PersistApi` +- Options / `PersistApi`: same as `persistSource` + +See also: `persist-zustand` for a store API with native `setState` (no reducer wrap). diff --git a/skills/persist-secure-store/SKILL.md b/skills/persist-secure-store/SKILL.md new file mode 100644 index 0000000..2be6da6 --- /dev/null +++ b/skills/persist-secure-store/SKILL.md @@ -0,0 +1,55 @@ +--- +name: persist-secure-store +description: Persist small secrets with Expo SecureStore via @stainless-code/persist/backends/secure-store. ~2KB/key — use partialize; async → useHydrated. +license: MIT +metadata: + type: composition + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "expo-secure-store" +requires: + - persist +sources: + - stainless-code/persist:src/adapters/backends/secure-store.ts +--- + +# SecureStore backend + +For **tokens / small secrets**, not full app state (~**2KB/key**). Async → gate UI. Keys sanitized to `/^[\w.-]+$/` (`:` → `_`). + +## Install + +```bash +bun add @stainless-code/persist expo-secure-store zustand +``` + +Peers: `expo-secure-store` `>=12.0.0`; example uses `zustand` (any `./sources/*` adapter works). + +## Minimal wiring + +```ts +import { create } from "zustand"; +import { createSecureStoreStorage } from "@stainless-code/persist/backends/secure-store"; +import { persistStore } from "@stainless-code/persist/sources/zustand"; + +type Auth = { token: string }; +const useAuth = create(() => ({ token: "" })); +const storage = createSecureStoreStorage()!; +persistStore(useAuth, { + name: "auth:token:v1", + storage, + partialize: (s) => s.token, +}); +``` + +## Common mistakes + +- **Storing large documents** — wrong backend. +- **Skipping `partialize`.** +- **Assuming colon keys are stored literally.** + +## API surface + +- `createSecureStoreStorage()` · `secureStoreStateStorage()` + +See also: `persist-mmkv`; `persist-encrypted`. diff --git a/skills/persist-seroval/SKILL.md b/skills/persist-seroval/SKILL.md new file mode 100644 index 0000000..de6a018 --- /dev/null +++ b/skills/persist-seroval/SKILL.md @@ -0,0 +1,51 @@ +--- +name: persist-seroval +description: Persist Set/Map/Date (and richer graphs) with @stainless-code/persist/codecs/seroval (serovalCodec / createSerovalStorage). Use when JSON.stringify would lose types on string-wire backends. +license: MIT +metadata: + type: composition + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "seroval" +requires: + - persist +sources: + - stainless-code/persist:src/adapters/codecs/seroval.ts +--- + +# Seroval codec + +`@stainless-code/persist/codecs/seroval` round-trips values JSON cannot (`Set`/`Map`/`Date`) via `seroval` `toJSON`/`fromJSON` on string-wire backends (`localStorage`, etc.). + +## Install + +```bash +bun add @stainless-code/persist seroval +``` + +Peer: `seroval` `>=1.0.0` (optional of this subpath). + +## Minimal wiring + +```ts +import { createSerovalStorage } from "@stainless-code/persist/codecs/seroval"; + +const storage = createSerovalStorage(() => localStorage, { + clearCorruptOnFailure: true, +}); +``` + +Or `createStorage(getStorage, serovalCodec(), opts)`. + +## Common mistakes + +- **Using `createJSONStorage` for Set/Map/Date** — they won't round-trip. +- **Layering seroval on default IDB** — `persist-idb` already uses structured clone / `identityCodec`. +- **Forgetting the `seroval` peer.** + +## API surface + +- `serovalCodec() → StorageCodec` +- `createSerovalStorage(getStorage, options?) → PersistStorage | undefined` + +See also: `persist-idb` for structured-clone without seroval. diff --git a/skills/persist-standard-schema/SKILL.md b/skills/persist-standard-schema/SKILL.md new file mode 100644 index 0000000..c8d7d77 --- /dev/null +++ b/skills/persist-standard-schema/SKILL.md @@ -0,0 +1,59 @@ +--- +name: persist-standard-schema +description: Validate persisted state with Standard Schema (~standard) via withStandardSchema / withStandardSchemaAsync / createStandardSchemaStorage. Use for Zod/Yup (etc.) sync vs async lanes and PersistDecodeRethrowError. +license: MIT +metadata: + type: composition + library: "@stainless-code/persist" + library_version: "0.4.0" +requires: + - persist +sources: + - stainless-code/persist:src/adapters/codecs/standard-schema.ts + - stainless-code/persist:apps/docs/content/recipes/standard-schema.mdx +--- + +# Standard Schema codec / wraps + +Types for `~standard` are **vendored** — no runtime peer. Bring Zod / Yup / Valibot yourself. + +Validates persisted **`state` only** (not envelope `version` / `timestamp` / `buster`). Encode writes schema **output** (defaults/transforms applied). + +## Sync vs async lanes + +| API | When | +| -------------------------------------------------------------- | ------------------------------------------------ | +| `withStandardSchema` / `createStandardSchemaStorage` | Sync `validate` (Zod ≥3.24 / v4 via `~standard`) | +| `withStandardSchemaAsync` / `createStandardSchemaStorageAsync` | Async `validate` (Yup, async Zod) | + +Wrong lane: sync wrap + async schema → `PersistDecodeRethrowError` (rethrown; **not** `clearCorrupt`). Async lane → async hydrate → gate UI (`react-persist`, …). + +Sync wrap is still Promise-aware for async **backend** `getItem` (e.g. IDB). + +## Minimal wiring + +```ts +import { withStandardSchema } from "@stainless-code/persist/codecs/standard-schema"; +import { createIdbStorage } from "@stainless-code/persist/backends/idb"; + +const storage = withStandardSchema(createIdbStorage()!, prefsSchema, { + clearCorruptOnFailure: true, +}); +``` + +JSON sugar: `createStandardSchemaStorage(() => localStorage, schema)`. + +## Common mistakes + +- **Async schema on the sync wrap.** +- **Treating wrong-lane throws as corrupt** — they must not clear storage. +- **Schema-checking the whole envelope** — only `state`. +- **Skipping `useHydrated` on the async lane.** +- **Stacking wrap + `standardSchemaCodec`** — double-validates; pick one. + +## API surface + +- `standardSchemaCodec(schema)` · `withStandardSchema` / `withStandardSchemaAsync` +- `createStandardSchemaStorage` / `createStandardSchemaStorageAsync` + +See also: `persist-idb` for async backends. diff --git a/skills/persist-tanstack-store/SKILL.md b/skills/persist-tanstack-store/SKILL.md new file mode 100644 index 0000000..14ee23a --- /dev/null +++ b/skills/persist-tanstack-store/SKILL.md @@ -0,0 +1,75 @@ +--- +name: persist-tanstack-store +description: Persist a @tanstack/store Store or writable Atom with @stainless-code/persist (persistStore/persistAtom). Use when wiring TanStack Store to storage, or choosing persistAtom replace-merge vs persistStore. +license: MIT +metadata: + type: composition + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "@tanstack/store" +requires: + - persist +sources: + - stainless-code/persist:src/adapters/sources/tanstack-store.ts +--- + +# Persisting TanStack Store + +Thin adapters over `persistSource` — shared Options / gate / throttle / cross-tab live in skill `persist`. + +`persistStore(store, options)` for `Store` (action-bearing included). `persistAtom(atom, options)` for a writable `Atom`. + +## Install + +```bash +bun add @stainless-code/persist @tanstack/store +# Set/Map/Date on string-wire backends: +bun add seroval +``` + +`@tanstack/store` is an optional peer of `/sources/tanstack-store`. + +## Minimal wiring + +```ts +import { Store } from "@tanstack/store"; +import { createJSONStorage } from "@stainless-code/persist"; +import { persistStore } from "@stainless-code/persist/sources/tanstack-store"; + +const store = new Store({ theme: "light" }); +const persist = persistStore(store, { + name: "app:prefs:v1", + storage: createJSONStorage(() => localStorage), +}); +``` + +`Set`/`Map`/`Date` → `createSerovalStorage` from `/codecs/seroval` (see `persist-seroval`). + +Keep the `PersistApi` for `rehydrate()` / `destroy()` / `onHydrate` / `clearStorage()`. Non-singletons must `destroy()` on teardown. + +## `persistAtom` vs `persistStore` + +`persistAtom` only: + +- **Default `merge` REPLACES** (`(persisted) => persisted`) — shallow-spreading a primitive corrupts it. Override uses `??`, so `merge: undefined` still replace-merges. +- **Throws on readonly atoms** — `"[persistAtom] Cannot persist a readonly atom."` + +```ts +import { createAtom } from "@tanstack/store"; +import { persistAtom } from "@stainless-code/persist/sources/tanstack-store"; + +const theme = createAtom<"light" | "dark">("light"); +persistAtom(theme, { name: "app:theme:v1" }); +``` + +## When to drop to `persistSource` + +- No first-party adapter for the store shape. +- You need subscription timing without the adapter's opinions. + +Zustand → `persist-zustand`. UI gate → `react-persist` (or other `*-persist`). + +## API surface + +- `persistStore(store, options) → PersistApi` +- `persistAtom(atom, options) → PersistApi` (writable only; replace-merge default) diff --git a/skills/persist-valtio/SKILL.md b/skills/persist-valtio/SKILL.md new file mode 100644 index 0000000..27248e7 --- /dev/null +++ b/skills/persist-valtio/SKILL.md @@ -0,0 +1,61 @@ +--- +name: persist-valtio +description: Persist a valtio proxy object with @stainless-code/persist (persistProxy). Use when wiring valtio state to localStorage/sessionStorage/IndexedDB via snapshot/subscribe. +license: MIT +metadata: + type: composition + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "valtio" +sources: + - stainless-code/persist:src/adapters/sources/valtio.ts + - stainless-code/persist:docs/architecture.md +--- + +# Persisting valtio + +`@stainless-code/persist/sources/valtio` maps a **proxy object** through `snapshot` / `Object.assign` / `subscribe` (`valtio/vanilla`) onto `persistSource`. + +## When to use this skill + +- You have a `proxy({…})` object state and want it to survive reload. +- State is an **object** (not a primitive proxy target). + +## Install + +```bash +bun add @stainless-code/persist valtio +``` + +`valtio` is an optional peer of `/sources/valtio`. + +## Minimal wiring + +```ts +import { proxy } from "valtio"; +import { createJSONStorage } from "@stainless-code/persist"; +import { persistProxy } from "@stainless-code/persist/sources/valtio"; + +const prefs = proxy({ theme: "light" as const }); +const persist = persistProxy(prefs, { + name: "app:prefs:v1", + storage: createJSONStorage(() => localStorage), +}); +``` + +## Hydrate semantics + +Writes apply with `Object.assign(proxy, next)` — keys **absent** from `next` are not deleted. Design `partialize` / migrate so hydrate payloads carry the shape you need, or reset keys yourself. + +## Common mistakes + +- **Persisting a non-object.** `TState extends object`. +- **Expecting deep replace / key deletion** on hydrate via assign alone. +- **Forgetting `destroy()`** for non-singleton proxies. + +## API surface + +- `persistProxy(proxyObject, options) → PersistApi` +- Options / `PersistApi`: same as `persistSource` + +See also: `persist-mobx` — same object + assign pattern with `runInAction`. diff --git a/skills/persist-zustand/SKILL.md b/skills/persist-zustand/SKILL.md new file mode 100644 index 0000000..52eb9e6 --- /dev/null +++ b/skills/persist-zustand/SKILL.md @@ -0,0 +1,98 @@ +--- +name: persist-zustand +description: Persist a zustand StoreApi with @stainless-code/persist (persistStore). Use when wiring zustand to localStorage/sessionStorage/IndexedDB, replacing zustand/middleware persist, or choosing between persistStore and persistSource for a zustand store. +license: MIT +metadata: + type: composition + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "zustand" +sources: + - stainless-code/persist:README.md + - stainless-code/persist:docs/architecture.md + - stainless-code/persist:src/adapters/sources/zustand.ts +--- + +# Persisting zustand + +`@stainless-code/persist/sources/zustand` maps zustand's `StoreApi` (`getState` / `setState` / `subscribe`) onto `persistSource`. Prefer this over zustand's built-in `persist` middleware when you want Persist's hydration gate, trailing throttle, cross-tab helpers, or the same storage/codec stack as other Persist adapters. + +## When to use this skill + +- You have a zustand store (`create(...)` / `StoreApi`) and want it to survive reload. +- You're migrating off `zustand/middleware`'s `persist`. +- You need async-backend hydration gating (`useHydrated`) shared with other Persist sources. + +Other sources → matching `persist-*` skill. UI gate → `*-persist`. + +## Install + +```bash +bun add @stainless-code/persist zustand +# only when you use a codec that needs it: +bun add seroval +``` + +`zustand` is an optional peer of the `/sources/zustand` subpath — importing the subpath is the dep opt-in. + +## Minimal wiring + +```ts +import { create } from "zustand"; +import { createJSONStorage } from "@stainless-code/persist"; +import { persistStore } from "@stainless-code/persist/sources/zustand"; + +const usePrefs = create(() => ({ theme: "light" as const })); +const persist = persistStore(usePrefs, { + name: "app:prefs:v1", + storage: createJSONStorage(() => localStorage), +}); +``` + +Pass the store API (`usePrefs`), not a hook call. `persist` is a `PersistApi` — keep it for `rehydrate()` / `destroy()` / `onHydrate` / `clearStorage()`. + +## vs zustand/middleware `persist` + +| Concern | zustand `persist` | `@stainless-code/persist` | +| ------------------ | -------------------- | ---------------------------------------------------- | +| Hydration gate | library-specific | built-in; pair with `useHydrated` for async backends | +| Storage / codecs | JSON-centric helpers | `createStorage` + json / seroval / identity / custom | +| Cross-tab | optional listeners | `crossTab` + `crossTabEventTarget` for IDB | +| Multi-library apps | zustand-only | same Options / `PersistApi` across source adapters | + +Keep zustand middleware for zustand-only apps that already depend on its persist API. Switch when you want one persist stack across stores. + +## Hydration gate + +Writes are gated until hydration settles (same as other Persist sources). Sync backends settle in a microtask; IndexedDB needs a UI gate: + +```ts +import { toHydrationSignal } from "@stainless-code/persist"; +import { useHydrated } from "@stainless-code/persist/frameworks/react"; + +export const prefsHydration = toHydrationSignal(persist); +const { hydrated } = useHydrated(prefsHydration); +``` + +## Teardown — required for non-singletons + +`persistStore` subscribes to the store. For route/component-scoped stores, `destroy()` on unmount. + +```ts +useEffect(() => { + const persist = persistStore(store, opts); + return () => persist.destroy(); +}, [store]); +``` + +## Common mistakes + +- **Passing `usePrefs()` (state) instead of `usePrefs` (API).** The adapter needs `getState` / `setState` / `subscribe`. +- **Stacking zustand `persist` middleware and this adapter.** Pick one write path. +- **Gating writes manually before hydration.** The gate is built in. +- **`identityCodec` with string-only backends.** Use `jsonCodec` / `createJSONStorage` for `localStorage`; `identityCodec` is for structured-clone IDB. + +## API surface for this skill + +- `persistStore(store, options) → PersistApi` — `store: StoreApi` +- Options / `PersistApi`: see skill `persist`. diff --git a/skills/persist/SKILL.md b/skills/persist/SKILL.md new file mode 100644 index 0000000..4718116 --- /dev/null +++ b/skills/persist/SKILL.md @@ -0,0 +1,81 @@ +--- +name: persist +description: Core @stainless-code/persist concepts — persistSource, PersistOptions, PersistApi, hydration gate, throttle, cross-tab, migrate. Load before framework hydration skills or when wiring a custom PersistableSource. +license: MIT +metadata: + type: core + library: "@stainless-code/persist" + library_version: "0.4.0" +sources: + - stainless-code/persist:src/core/persist-core.ts + - stainless-code/persist:docs/architecture.md + - stainless-code/persist:README.md +--- + +# Persist + +Zero-dep `persistSource(source, options)` owns hydrate → subscribe → write. First-party `./sources/*` adapters map library APIs onto `PersistableSource` (`getState` / `setState` / `subscribe`) and may add opinions (default `merge`, reducer wraps, assign vs `$patch`). + +## When to use this skill + +- Custom store shape (no `persist-*` adapter). +- Shared Options / `PersistApi` semantics across adapters. +- Before `react-persist` (and other framework hydration skills). + +Library-specific wiring → the matching `persist-*` composition skill. + +## Minimal custom source + +```ts +import { createJSONStorage, persistSource } from "@stainless-code/persist"; + +const persist = persistSource( + { + getState: () => state, + setState: (updater) => { + state = updater(state); + }, + subscribe: (listener) => { + listeners.add(listener); + return { unsubscribe: () => listeners.delete(listener) }; + }, + }, + { + name: "app:custom:v1", + storage: createJSONStorage(() => localStorage), + }, +); +``` + +## Contracts agents must not invent + +- **Hydration write gate** — `setState` before hydrate settles does not clobber storage. Don't double-gate manually. +- **`throttleMs`** — trailing-only; first write waits out the window. `destroy()` flushes pending writes. +- **`maxAge`** — opt-in; prefs should not silently expire. +- **`instanceof Promise`** on reads — not thenable duck-typing. +- **`PersistDecodeRethrowError`** — decode errors that must rethrow / skip `clearCorrupt` (e.g. wrong sync/async schema lane). +- **`migrate`** — called once with `(state, fromVersion)`; multi-step chains → `createMigrationChain`. +- **Teardown** — `persist.destroy()` for non-singleton lifetimes. + +## `PersistApi` (keep the reference) + +`rehydrate()`, `hasHydrated()`, `onHydrate`, `onFinishHydration`, `setOptions`, `clearStorage`, `getOptions`, `destroy`. + +## Hydration signal for UI + +```ts +import { toHydrationSignal } from "@stainless-code/persist"; + +export const hydration = toHydrationSignal(persist); +// frameworks/react → useHydrated(hydration) — see react-persist +``` + +## Backend × codec (short) + +| State | Backend | Helper / codec | +| ------------------ | -------------- | ------------------- | +| JSON-able | `localStorage` | `createJSONStorage` | +| `Set`/`Map`/`Date` | `localStorage` | seroval codec | +| Structured clone | IndexedDB | `identityCodec` | + +See also: `persist-*` for sources / codecs / backends / transport; `*-persist` for UI frameworks. diff --git a/skills/preact-persist/SKILL.md b/skills/preact-persist/SKILL.md new file mode 100644 index 0000000..ef2ecd0 --- /dev/null +++ b/skills/preact-persist/SKILL.md @@ -0,0 +1,50 @@ +--- +name: preact-persist +description: Gate Preact UI on Persist hydration with useHydrated (preact/compat useSyncExternalStore). Use when avoiding flash of default state on async backends or wiring HydrationSignal into Preact. +license: MIT +metadata: + type: framework + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "preact" +requires: + - persist +sources: + - stainless-code/persist:src/adapters/frameworks/preact.ts +--- + +# Preact hydration gate + +`@stainless-code/persist/frameworks/preact` mirrors the React adapter via `preact/compat` `useSyncExternalStore` (SSR snapshot always `true`). Not a state source — pair with a `./sources/*` adapter. + +## Install + +```bash +bun add @stainless-code/persist preact +``` + +Peer: `preact` `>=10.19.0`. + +## Minimal wiring + +```tsx +import { useHydrated } from "@stainless-code/persist/frameworks/preact"; + +const { hydrated } = useHydrated(prefsHydration); +if (!hydrated) return ; +return ; +``` + +`prefsHydration = toHydrationSignal(persist)` from your store module. + +## Common mistakes + +- **Importing `/frameworks/react` in a Preact app.** Use this subpath. +- **Treating the hook as state.** Read the store separately. +- **Null signal ≠ loading** — means no persist → hydrated. + +## API surface + +- `useHydrated(signal) → { hydrated: boolean }` + +See also: `react-persist` (same contract). diff --git a/skills/react-persist/SKILL.md b/skills/react-persist/SKILL.md new file mode 100644 index 0000000..76f6b3c --- /dev/null +++ b/skills/react-persist/SKILL.md @@ -0,0 +1,74 @@ +--- +name: react-persist +description: Gate React UI on Persist hydration with useHydrated + toHydrationSignal. Use when avoiding flash of default state on async backends (IndexedDB), SSR snapshot true, or wiring HydrationSignal into React 18/19. +license: MIT +metadata: + type: framework + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "react" +requires: + - persist +sources: + - stainless-code/persist:src/adapters/frameworks/react.ts + - stainless-code/persist:docs/architecture.md +--- + +# React hydration gate + +`@stainless-code/persist/frameworks/react` exports `useHydrated(signal)` — a thin `useSyncExternalStore` wrapper over `HydrationSignal`. It does **not** persist state; pair it with a `./sources/*` adapter (`persist-zustand`, `persist-tanstack-store`, …). + +## When to use this skill + +- Async backend (IndexedDB) — gate UI until hydrate settles. +- SSR — server snapshot is always `hydrated: true` (nothing to wait for server-side). +- `null` / `undefined` signal → treat as hydrated (no persistence configured). + +## Install + +```bash +bun add @stainless-code/persist react +``` + +`react` `^18 || ^19` is the optional peer of `/frameworks/react`. + +## Minimal wiring + +```ts +// store module +import { toHydrationSignal } from "@stainless-code/persist"; +import { persistStore } from "@stainless-code/persist/sources/zustand"; + +const persist = persistStore(store, { name: "app:prefs:v1", storage }); +export const prefsHydration = toHydrationSignal(persist); +``` + +```tsx +// component +import { useHydrated } from "@stainless-code/persist/frameworks/react"; + +const { hydrated } = useHydrated(prefsHydration); +const prefs = useStore(store); // your selector — not from useHydrated +if (!hydrated) return ; +return ; +``` + +## Contracts + +- Return shape is **only** `{ hydrated: boolean }` — read state via your store's selector. +- Gates **UI flash**, not store reads; pre-hydration `getState()` can still see defaults. +- Sync backends settle in a microtask; module-load stores usually hydrate before first paint, but `useHydrated` is still the safe read. + +## Common mistakes + +- **Treating `useHydrated` as the state source.** It only reports the gate. +- **Expecting `hydrated: false` on the server.** SSR snapshot is `true`. +- **Forgetting `toHydrationSignal(persist)`.** Pass a `HydrationSignal`, not `PersistApi`. +- **Assuming `null` signal means “loading”.** It means “no persist” → hydrated. + +## API surface + +- `useHydrated(signal: HydrationSignal | null | undefined) → { hydrated: boolean }` +- Core helper: `toHydrationSignal(persist)` from `@stainless-code/persist` + +See also: the matching `persist-*` source skill for your store. diff --git a/skills/solid-persist/SKILL.md b/skills/solid-persist/SKILL.md new file mode 100644 index 0000000..fc0402d --- /dev/null +++ b/skills/solid-persist/SKILL.md @@ -0,0 +1,49 @@ +--- +name: solid-persist +description: Gate Solid UI on Persist hydration with useHydrated (Accessor). Use when avoiding flash of default state on async backends; read hydrated() in reactive scopes. +license: MIT +metadata: + type: framework + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "solid" +requires: + - persist +sources: + - stainless-code/persist:src/adapters/frameworks/solid.ts +--- + +# Solid hydration gate + +`@stainless-code/persist/frameworks/solid` returns an `Accessor` — call `hydrated()` inside tracking scopes (`createEffect`, JSX, …). + +## Install + +```bash +bun add @stainless-code/persist solid-js +``` + +Peer: `solid-js` `>=1.6.0`. + +## Minimal wiring + +```tsx +import { useHydrated } from "@stainless-code/persist/frameworks/solid"; + +const hydrated = useHydrated(prefsHydration); +// }>… +``` + +## Contracts + +Null/undefined signal → hydrated `true`. SSR policy: treat as hydrated `true`. + +## Common mistakes + +- **Using `hydrated` as a boolean** — it's an accessor; call it. +- **Reading outside a reactive scope** — won't update. +- **Null signal ≠ loading** → hydrated true. + +## API surface + +- `useHydrated(signal) → Accessor` diff --git a/skills/svelte-persist/SKILL.md b/skills/svelte-persist/SKILL.md new file mode 100644 index 0000000..a0b9b4a --- /dev/null +++ b/skills/svelte-persist/SKILL.md @@ -0,0 +1,59 @@ +--- +name: svelte-persist +description: Gate Svelte 5 runes UI on Persist hydration with hydratedRune (.current). Use for Svelte 5; for stores / Svelte 3–4 use svelte-store-persist. +license: MIT +metadata: + type: framework + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "svelte" +requires: + - persist +sources: + - stainless-code/persist:src/adapters/frameworks/svelte.ts +--- + +# Svelte 5 runes hydration gate + +`@stainless-code/persist/frameworks/svelte` exports `hydratedRune(signal)` → `{ readonly current: boolean }`. Read `.current` inside `$derived` / `$effect` / `{#if}` (createSubscriber is lazy inside a reactive owner). + +**Stores / Svelte 3–4 / Svelte 5 store users → `svelte-store-persist`** (`./frameworks/svelte-store`). + +## Install + +```bash +bun add @stainless-code/persist svelte +``` + +Needs Svelte **5.7+** for `svelte/reactivity` `createSubscriber` (package peer allows `>=3` because of the separate store entry). + +## Minimal wiring + +```svelte + + +{#if !hydrated.current} + +{:else} + +{/if} +``` + +## Contracts + +Null/undefined → `current: true`. SSR: hydrated `true`. + +## Common mistakes + +- **Using this entry for store-based UI.** Import `svelte-store-persist`. +- **Reading `.current` outside a reactive owner** — subscribe is a no-op. +- **Expecting a `useHydrated` name** — runes API is `hydratedRune`. + +## API surface + +- `hydratedRune(signal) → { readonly current: boolean }` + +See also: `svelte-store-persist`. diff --git a/skills/svelte-store-persist/SKILL.md b/skills/svelte-store-persist/SKILL.md new file mode 100644 index 0000000..cb35862 --- /dev/null +++ b/skills/svelte-store-persist/SKILL.md @@ -0,0 +1,57 @@ +--- +name: svelte-store-persist +description: Gate Svelte store-based UI on Persist hydration with hydratedStore (Readable). Use for Svelte 3–4 or Svelte 5 apps that still use stores; runes UI → svelte-persist. +license: MIT +metadata: + type: framework + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "svelte-store" +requires: + - persist +sources: + - stainless-code/persist:src/adapters/frameworks/svelte-store.ts +--- + +# Svelte stores hydration gate + +`@stainless-code/persist/frameworks/svelte-store` exports `hydratedStore(signal) → Readable`. Use `$hydrated` auto-subscribe in templates. **Separate public entry** from `./frameworks/svelte` (runes). + +## Install + +```bash +bun add @stainless-code/persist svelte +``` + +Peer: `svelte` `>=3.0.0`. + +## Minimal wiring + +```svelte + + +{#if !$hydrated} + +{:else} + +{/if} +``` + +## Contracts + +Null/undefined → `readable(true)`. SSR: hydrated `true`. + +## Common mistakes + +- **Using this for Svelte 5 runes UI.** Use `svelte-persist` / `hydratedRune`. +- **Forgetting the `$` prefix** in templates. +- **Null signal ≠ loading** → `readable(true)`. + +## API surface + +- `hydratedStore(signal) → Readable` + +See also: `svelte-persist`. diff --git a/skills/tanstack-store/SKILL.md b/skills/tanstack-store/SKILL.md deleted file mode 100644 index 6b8ebaf..0000000 --- a/skills/tanstack-store/SKILL.md +++ /dev/null @@ -1,168 +0,0 @@ ---- -name: tanstack-store -description: Persist a @tanstack/store Store or writable Atom with @stainless-code/persist (persistStore/persistAtom). Use when persisting TanStack Store state to localStorage/sessionStorage/IndexedDB, gating UI on hydration for an async backend, or deciding persistStore vs persistSource. -license: MIT -metadata: - library: "@stainless-code/persist" - library_version: "0.4.0" - framework: "tanstack-store" -sources: - - README.md - - docs/architecture.md ---- - -# Persisting TanStack Store - -`@stainless-code/persist/sources/tanstack-store` ships two adapters over the store-agnostic `persistSource` core: `persistStore(store, options)` for `@tanstack/store`'s `Store` (action-bearing stores included), and `persistAtom(atom, options)` for a writable `Atom`. The middleware owns the lifecycle so the store stays a plain store; the adapters are thin wrappers that supply the `PersistableSource` shape. - -## When to use this skill - -- You have a `@tanstack/store` `Store` (or a writable Atom) and want it to survive reload. -- You need a hydration signal to gate UI flash on async backends (IndexedDB). -- You're deciding between `persistStore` and dropping to `persistSource`. - -If you're persisting zustand / Redux / a hand-rolled atom instead, skip to `persistSource` — the adapters here only earn their keep for `@tanstack/store` shapes. - -## Install - -```bash -bun add @stainless-code/persist @tanstack/store -# only when you use a codec that needs it: -bun add seroval -``` - -`@tanstack/store` is an optional peer of the `/sources/tanstack-store` subpath — importing the subpath is the dep opt-in. - -## Minimal wiring - -```ts -import { Store } from "@tanstack/store"; -import { createSerovalStorage } from "@stainless-code/persist/codecs/seroval"; -import { persistStore } from "@stainless-code/persist/sources/tanstack-store"; - -const store = new Store({ theme: "light" }); -const persist = persistStore(store, { - name: "app:prefs:v1", - storage: createSerovalStorage(() => localStorage), -}); -``` - -The middleware hydrates on create, subscribes to `setState`, and writes through. `persist` is a `PersistApi` — keep the reference for `rehydrate()` / `destroy()` / `onHydrate` / `clearStorage()`. - -## `persistAtom` vs `persistStore` - -`persistAtom` has two opinionations `persistStore` doesn't: - -- **Default `merge` REPLACES, not shallow-spreads.** Atoms commonly hold primitives (`"light"`, a number); a shallow spread of a primitive corrupts it (`{}` for a number). `persistAtom` overrides `merge` to `(persisted) => persisted`. Pass your own `merge` to restore spread-merge for object atoms. The override uses `??` (not spread order), so an explicit `merge: undefined` still gets replace-merge. -- **Throws on readonly atoms.** A computed/readonly atom has no `set`; `persistAtom` throws `[persistAtom] Cannot persist a readonly atom.` rather than silently no-op'ing. Only writable atoms are persistable. - -```ts -import { createAtom } from "@tanstack/store"; -import { persistAtom } from "@stainless-code/persist/sources/tanstack-store"; - -const theme = createAtom<"light" | "dark">("light"); -const persist = persistAtom(theme, { name: "app:theme:v1" }); -// hydrate REPLACES the primitive; theme.set() writes through -``` - -## Hydration gate - -Writes are **gated until hydration settles** — a `setState` fired before the stored state is loaded will not clobber stored state with the constructor default. This is why you don't need to manually defer your first write. - -- **Sync backend (localStorage):** hydration settles before first paint for stores created at module load. Caveat: `hydrate` is async and `await`s the (sync) `getItem` return, so the flag flips in a **microtask**, not synchronously — `hasHydrated()` is `false` for a brief window right after `persistStore()` returns. Module-load creation settles before React's first render; creation inside a component mount may not. No flash, no `Suspense`; `useHydrated` is still the safe way to read it. -- **Async backend (IndexedDB):** hydration settles after first paint. Gate the UI on `useHydrated` (`@stainless-code/persist/frameworks/react`) or read `persist.hasHydrated()` before rendering persisted-dependent UI. - -```ts -import { toHydrationSignal } from "@stainless-code/persist"; -import { useHydrated } from "@stainless-code/persist/frameworks/react"; - -export const prefsHydration = toHydrationSignal(persist); -// in a component: -const { hydrated } = useHydrated(prefsHydration); -``` - -SSR: render `hydrated: true` on the server (no storage server-side). `null` signal = no persistence = hydrated. - -## Trailing-only throttle - -`throttleMs` coalesces bursts (typing, dragging) into **trailing** writes. The first eligible `setState` schedules a timer of `throttleMs`; further calls within the window coalesce; when the timer fires, ONE write happens with the state read at flush time (last write wins). **The first write does NOT fire immediately** — it waits out the window. This trades first-write latency for a single-timer model (TanStack Query's persister throttle is leading+trailing; ours is trailing-only). - -Not throttled: `skipPersist` removals (a reset-to-default drops the key immediately, cancelling any pending write) and the one-shot post-migrate write-back. `destroy()` flushes a pending write immediately so no coalesced state is silently dropped. Set `throttleMs` when `setState` fires at high frequency and the backend is slow (IndexedDB) or networked — and only when you can tolerate first-write latency. - -## Teardown — required for non-singletons - -`persistStore` subscribes to the store. For a singleton app store you can let it live for the process. For stores tied to a component/route lifetime, call `persist.destroy()` on unmount — otherwise the subscription and write timer leak and stale retries can fire after the owner is gone. - -```ts -useEffect(() => { - const persist = persistStore(store, opts); - return () => persist.destroy(); -}, [store]); -``` - -## Cross-tab sync - -`crossTab: true` enables `storage`-event sync for `localStorage`. Pair with `onCrossTabRemove` when using `skipPersist` — it fires when another tab clears the key, so you can reset the store: - -```ts -persistStore(store, { - name, - storage, - crossTab: true, - onCrossTabRemove: () => store.actions.reset(), -}); -``` - -Caveats that bite: `sessionStorage` is per-tab — `crossTab` is meaningless there. IndexedDB has no `storage` events — bridge a `BroadcastChannel` via `crossTabEventTarget` instead. - -## Schema evolution - -Bump `version` in options and provide `migrate`. Payloads carry `version`; on hydrate, the middleware walks migrations to the current version before seeding the store. - -```ts -persistStore(store, { - name: "app:prefs:v1", - storage, - version: 2, - migrate: (state, from) => ({ ...state, newField: "default", _v: from }), -}); -``` - -## When to drop to `persistSource` - -Use `persistSource({ getState, setState, subscribe }, opts)` directly when: - -- The store isn't `@tanstack/store` (zustand, Redux, hand-rolled atom). -- You want to control subscription timing without the adapter's opinion. -- You're building a framework adapter (the React `useHydrated` hook is the template — a thin layer over `HydrationSignal`; see its JSDoc for the subscribe contract). - -The TanStack adapters exist because `Store`/`Atom` have a known shape; anything else is `persistSource`. - -## Common mistakes - -- **Gating writes manually before hydration.** Don't — the gate is built in. Manually deferring usually double-gates and drops legitimate writes. -- **`identityCodec` with a string-only backend.** `identityCodec` is for structured-clone backends (IndexedDB via `idbStateStorage`). With `localStorage` use `jsonCodec` (default) or `serovalCodec`. -- **Treating `maxAge` as on by default.** It's opt-in — prefs shouldn't silently expire. Add it only for cache-shaped state. -- **Duck-typing a `then` field as a pending read.** The read path switches on `instanceof Promise`, not thenable — so a stored value with a `then` property is safe. Don't "fix" this by awaiting thenables elsewhere. - -## Backend × codec choice - -| State shape | Backend | Codec | Notes | -| ------------------ | -------------- | --------------- | ------------------------------------------- | -| Plain JSON-able | `localStorage` | `jsonCodec` | default; no extra dep | -| `Set`/`Map`/`Date` | `localStorage` | `serovalCodec` | needs `seroval` peer | -| Large / structured | IndexedDB | `identityCodec` | structured-clone mode via `idbStateStorage` | -| Encrypted at rest | any | custom | `encode`/`decode` pair over the backend | - -`createStorage(backend, codec, options)` composes any other cell. - -## API surface for this skill - -- `persistStore(store, options) → PersistApi` (accepts action-bearing `Store`) -- `persistAtom(atom, options) → PersistApi` (writable atoms only; throws on readonly; default `merge` replaces) -- Options: `name`, `storage`, `partialize`, `merge`, `onRehydrateStorage`, `version`, `migrate`, `skipHydration`, `skipPersist`, `crossTab`, `crossTabEventTarget`, `onCrossTabRemove`, `maxAge`, `buster`, `throttleMs`, `retryWrite`, `onError`, `registry` -- `PersistApi`: `rehydrate()`, `hasHydrated()`, `onHydrate(fn)`, `onFinishHydration(fn)`, `setOptions(partial)`, `clearStorage()`, `getOptions()`, `destroy()` - -Notes: `registry` + `clearStorage()` wipe every persisted key in one `registry.clearAll()` (session-end / clear-all-on-demand). `partialize` projects `TState` to the persisted slice; `merge` combines persisted with current on hydrate (default shallow spread). - -Full contracts live in the JSDoc of each module (hovers + published `.d.mts`). diff --git a/skills/vue-persist/SKILL.md b/skills/vue-persist/SKILL.md new file mode 100644 index 0000000..f74d66b --- /dev/null +++ b/skills/vue-persist/SKILL.md @@ -0,0 +1,54 @@ +--- +name: vue-persist +description: Gate Vue 3 UI on Persist hydration with useHydrated (shallowRef). Use in setup/effectScope when avoiding flash of default state on async backends. +license: MIT +metadata: + type: framework + library: "@stainless-code/persist" + library_version: "0.4.0" + framework: "vue" +requires: + - persist +sources: + - stainless-code/persist:src/adapters/frameworks/vue.ts +--- + +# Vue hydration gate + +`@stainless-code/persist/frameworks/vue` returns a `Ref` (`shallowRef`). Call inside `setup()` / an active `effectScope()` so `onScopeDispose` unsubscribes. + +## Install + +```bash +bun add @stainless-code/persist vue +``` + +Peer: `vue` `>=3.3.0`. + +## Minimal wiring + +```ts +import { toHydrationSignal } from "@stainless-code/persist"; +import { useHydrated } from "@stainless-code/persist/frameworks/vue"; + +const prefsHydration = toHydrationSignal(persist); +// in setup(): +const hydrated = useHydrated(prefsHydration); +``` + +```vue + + +``` + +## Common mistakes + +- **Calling outside setup / scope** — leak or no teardown. +- **Treating the ref as store state.** +- **SSR / null signal.** Null stays `true`; there is no separate server snapshot (unlike React `getServerSnapshot`). + +## API surface + +- `useHydrated(signal) → Ref` + +See also: `persist-pinia` for Pinia store wiring.