diff --git a/.changeset/tanstack-intent-skills.md b/.changeset/tanstack-intent-skills.md new file mode 100644 index 0000000..d9cb864 --- /dev/null +++ b/.changeset/tanstack-intent-skills.md @@ -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. diff --git a/.github/workflows/check-skills.yml b/.github/workflows/check-skills.yml new file mode 100644 index 0000000..48a6576 --- /dev/null +++ b/.github/workflows/check-skills.yml @@ -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 diff --git a/bun.lock b/bun.lock index 39cfa7a..52b39d2 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,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", @@ -274,6 +275,8 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tanstack/intent": ["@tanstack/intent@0.3.4", "", { "dependencies": { "cac": "^6.7.14", "jsonc-parser": "^3.3.1", "semver": "^7.8.4", "std-env": "^4.1.0", "yaml": "2.9.0" }, "bin": { "intent": "dist/cli.mjs" } }, "sha512-yK1VRa118Xdcj+YmuVPVVkSLtyabYnr7Wcn6sBMdHhFEfav/QHIP0G7NdNOA7qSHgbrXiKmLMyYJCQpJ8OLgfQ=="], + "@tanstack/store": ["@tanstack/store@0.11.0", "", {}, "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], @@ -368,7 +371,7 @@ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], @@ -494,6 +497,8 @@ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -778,6 +783,8 @@ "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "tsdown/cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], + "unconfig-core/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], diff --git a/package.json b/package.json index 35aa806..ee3e568 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "persistence", "state", "store", - "tanstack" + "tanstack", + "tanstack-intent" ], "homepage": "https://github.com/stainless-code/persist#readme", "bugs": { @@ -22,7 +23,9 @@ "url": "https://github.com/stainless-code/persist.git" }, "files": [ - "dist" + "dist", + "skills", + "!skills/_artifacts" ], "type": "module", "sideEffects": false, @@ -62,6 +65,8 @@ "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", @@ -69,7 +74,7 @@ "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", "release": "changeset publish", "test": "bun test ./src", "test:dom": "vitest run", @@ -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", diff --git a/skills/tanstack-store/SKILL.md b/skills/tanstack-store/SKILL.md new file mode 100644 index 0000000..a716491 --- /dev/null +++ b/skills/tanstack-store/SKILL.md @@ -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`) +- `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`).