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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tanstack-intent-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stainless-code/persist": minor
---

Ship consumer Agent Skills via TanStack Intent. Adds `skills/tanstack-store/SKILL.md` (packaged in the npm tarball), the `tanstack-intent` keyword for registry discovery, `intent:validate` / `intent:stale` scripts, `intent validate` gated in `prepublishOnly`, and a `check-skills.yml` CI workflow for skill validation + post-release staleness review. No runtime API change.
103 changes: 103 additions & 0 deletions .github/workflows/check-skills.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# 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.
#
# intent-workflow-version: 3

name: Check Skills

on:
pull_request:
paths:
- "skills/**"
- "**/skills/**"
- "_artifacts/**"
- "**/_artifacts/**"
release:
types: [published]
workflow_dispatch: {}

jobs:
validate:
name: Validate intent skills
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20

- name: Install intent
run: npm install -g @tanstack/intent@0.3.4

- name: Validate skills
run: intent validate --github-summary

review:
name: Check intent skill coverage
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20

- name: Install intent
run: npm install -g @tanstack/intent@0.3.4

- name: Check skills
id: stale
run: |
intent stale --github-review --package-label "@stainless-code/persist"

- name: Open or update review PR
if: steps.stale.outputs.has_review == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
VERSION="${RELEASE_TAG:-manual}"
BRANCH="skills/review-${VERSION}"
BASE_BRANCH="$DEFAULT_BRANCH"

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

git fetch origin "$BRANCH" || true
if git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
git checkout -B "$BRANCH" "origin/$BRANCH"
else
git checkout -b "$BRANCH"
git commit --allow-empty -m "chore: review intent skills for ${VERSION}"
git push origin "$BRANCH"
fi

PR_URL="$(gh pr list --head "$BRANCH" --json url --jq '.[0].url')"
if [ -n "$PR_URL" ]; then
gh pr edit "$PR_URL" --body-file pr-body.md
else
gh pr create \
--title "Review intent skills (${VERSION})" \
--body-file pr-body.md \
--head "$BRANCH" \
--base "$BASE_BRANCH"
fi
9 changes: 8 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 9 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"persistence",
"state",
"store",
"tanstack"
"tanstack",
"tanstack-intent"
],
"homepage": "https://github.com/stainless-code/persist#readme",
"bugs": {
Expand All @@ -22,7 +23,9 @@
"url": "https://github.com/stainless-code/persist.git"
},
"files": [
"dist"
"dist",
"skills",
"!skills/_artifacts"
],
"type": "module",
"sideEffects": false,
Expand Down Expand Up @@ -62,14 +65,16 @@
"format": "oxfmt --no-error-on-unmatched-pattern",
"format:changes": "bun scripts/run-on-changed-files.ts format",
"format:check": "oxfmt --check --no-error-on-unmatched-pattern",
"intent:stale": "intent stale",
"intent:validate": "intent validate",
"lint": "oxlint --no-error-on-unmatched-pattern",
"lint-staged": "lint-staged",
"lint:changes": "bun scripts/run-on-changed-files.ts lint",
"lint:ci": "oxlint --quiet",
"lint:fix": "bun run lint --fix",
"lint:fix:changes": "bun scripts/run-on-changed-files.ts lint:fix",
"prepare": "husky || true",
"prepublishOnly": "bun run check",
"prepublishOnly": "bun run check && bun run intent:validate",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"release": "changeset publish",
"test": "bun test ./src",
"test:dom": "vitest run",
Expand All @@ -79,6 +84,7 @@
"devDependencies": {
"@changesets/changelog-github": "0.7.0",
"@changesets/cli": "2.31.0",
"@tanstack/intent": "0.3.4",
"@tanstack/store": "0.11.0",
"@testing-library/dom": "10.4.1",
"@testing-library/react": "16.3.2",
Expand Down
168 changes: 168 additions & 0 deletions skills/tanstack-store/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
---
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.0.0"
framework: "tanstack-store"
sources:
- README.md
- docs/architecture.md
---

# Persisting TanStack Store

`@stainless-code/persist/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 `/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/seroval";
import { persistStore } from "@stainless-code/persist/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/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/react`) or read `persist.hasHydrated()` before rendering persisted-dependent UI.

```ts
import { toHydrationSignal } from "@stainless-code/persist";
import { useHydrated } from "@stainless-code/persist/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<TState, StoreActionMap>`)
- `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`).
Loading