-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(skills): ship consumer Agent Skills via TanStack Intent #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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`). |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.