fix(persist-core): shape-check storage backend; collapse to no-op on broken globals#4
Conversation
… globals Node 22+ exposes `localStorage` as an object whose `getItem`/`setItem`/`removeItem` are `undefined` without a valid `--localstorage-file` path. The lookup doesn't throw, so the broken backend passed availability and crashed in `hydrate` at `storage.getItem is not a function` during SSR. `createStorage` now shape-checks the three methods and returns `undefined`, collapsing to the no-op `PersistApi`.
🦋 Changeset detectedLatest commit: 3cc03ba The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
📝 WalkthroughWalkthroughThis PR adds a runtime shape check to ChangesStorage Backend Validation Fix
Estimated code review effort: 1 (Trivial) | ~5 minutes Poem A rabbit checked the storage door, 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Behavior-preserving. Also tightens the changeset to lead with the fix.
…ift verify-on-release to roadmap #4 was marked ✅ implemented (trusted publishing + publishConfig.provenance shipped in .github/workflows/release.yml). Prune per docs-governance (close = lift + delete): residual "verify provenance on next release + revoke legacy NPM_TOKEN" action lifted to roadmap.md § Next so it isn't lost. Renumber 5–8 → 4–7 and fix the Sequencing section's cross-refs.
…n, harden passes (#7) * docs(audit): add docs-adapters ROI audit + action plan Four-lane GLM 5.2 audit (core, adapters, docs, build/CI) with a 32-item ROI-ordered action plan and full verbatim subagent reports appended as appendices. * docs(readme): close Tier-1 onboarding gaps - Define "hydration-aware" (namesake concept was undefined in prose) - Add IndexedDB + React + useHydrated end-to-end walkthrough + the non-singleton useEffect/destroy() teardown pattern - Add a table of contents (README was a wall-of-text) - Show npm/pnpm/yarn install variants (was bun-only) - Add recipes for registry/clearAll, partialize, merge, retryWrite, throttleMs, maxAge, buster (six hidden capabilities with no example) - Link the generated TypeDoc API reference (built but never linked) * feat(crosstab): add BroadcastChannel cross-tab bridge adapter New zero-dep `./crosstab` subpath exposing `createBroadcastCrossTab` for backends that fire no `storage` events (IndexedDB — the documented-but-missing cross-tab case from persist-idb.ts:62). Fills the existing `CrossTabEventTarget` seam (persist-core.ts:113), mirroring the persist-idb template: own subpath, no cross-entry value imports (isolation test), co-located test suite (5 tests incl. a two-tab BroadcastChannel sync integration test). Design invariants verified from persist-core.ts:854-878: - posts `storageArea: null` → key-only matching in every tab (each tab's `storage.raw` is its own backend instance, so the area identity guard would reject cross-tab events) - writes post non-null `newValue`, removes post `null` (maps to onCrossTabRemove); post-after-settle so receivers rehydrate into committed state - wraps the storage's setItem/removeItem (persist-core has no write-broadcast hook); preserves `raw` - guards BroadcastChannel availability (SSR / Node <18) → undefined * feat(zod): add zod-validated codec adapter New `./zod` subpath exposing `zodCodec` / `createZodStorage` over the `StorageCodec` seam — schema-gated persistence. Mirrors the persist-seroval codec template: own subpath, `zod` optional peer (>=3.20.0, stable across v3/v4), no cross-entry value imports (isolation test included). - encode validates state → invalid state never writes (onError "write") - decode validates state → corrupt reads discard (clearCorruptOnFailure removes the key); maps cleanly into persist-core's existing corrupt-payload try/catch (persist-core.ts:468-488) - validates state only; version/timestamp/buster stay envelope concerns 6 co-located tests (round-trip, corrupt decode, clear-on-failure, invalid-encode abandoned, direct createStorage seam, sibling isolation). * feat(solid,vue): add Solid + Vue hydration adapters New `./solid` and `./vue` subpaths exposing `useHydrated(signal)` over the HydrationSignal seam, mirroring the React adapter (src/use-hydrated.ts). The HydrationSignal JSDoc (hydration.ts:8-10) explicitly names Solid `from` and Vue as adapter targets. - ./solid (peer solid-js >=1.6.0): Accessor<boolean> via from; uses the from(producer, initialValue) overload for a non-undefined return; subscription owned by the reactive scope, cleaned on scope dispose - ./vue (peer vue >=3.3.0): Ref<boolean> via shallowRef + onScopeDispose (call inside setup()/effectScope()) Both render true on the server (no-op PersistApi is always-hydrated), matching the HydrationSignal adapter contract. Each is its own subpath with the peer optional, no cross-entry value imports (isolation tests included). Solid test mocks solid-js to the client build so createEffect reactivity runs under bun's SSR resolution. 8 co-located tests (null signal, reactivity, scope cleanup, sibling isolation each). * feat(rn): add AsyncStorage, MMKV, expo-secure-store adapters Three React Native storage subpaths over the StateStorage seam, mirroring the persist-idb template (own subpath, optional peer, no cross-entry value imports, mocked-peer co-located tests via mock.module — validates shape, not the real RN runtime). - ./async-storage (peer @react-native-async-storage/async-storage >=1.0.0): asyncStorageStateStorage / createAsyncStorage. Async, string-wire; useHydrated gating mandatory. Accepts a custom instance (getLegacyStorage(), createAsyncStorage(name)) to namespace. - ./mmkv (peer react-native-mmkv >=4.0.0): mmkvStateStorage / createMmkvStorage({ id, path?, encryptionKey? }). Synchronous — no hydration gate needed. Uses the v4 createMMKV factory + getString/set/remove API (v4 renamed delete -> remove). - ./secure-store (peer expo-secure-store >=12.0.0): secureStoreStateStorage / createSecureStoreStorage. OS keychain, async, ~2KB/key limit — for small secrets; pair partialize. All three compose via createJSONStorage (jsonCodec default); swap codecs with createStorage(backend, codec). 9 co-located tests (backend mapping, persistSource round-trip, sibling isolation each). Vetting note: the MMKV fake instance is cast to the full MMKV interface (HybridObject base props unused by the adapter's getString/set/remove surface). * refactor(src): refold into core/ + adapters/<seam>/; drop persist- prefix Restructure for pristine scalable contributions: src/ splits at the dependency-direction boundary into core/ (zero-dep engine + the `.` entry) and adapters/<seam>/ (opt-in peer-owning entries that import only from core/). Adapter filenames drop the redundant `persist-` prefix — the folder is the category, the file is the subpath. src/ core/ persist-core, hydration, index adapters/ codecs/ seroval, zod backends/ idb, async-storage, mmkv, secure-store transport/ crosstab sources/ tanstack-store frameworks/ react, solid, vue - tsdown.config.ts: record-form entry → flat dist/<subpath>.mjs regardless of src depth; dist filenames drop persist- prefix (dist/idb.mjs, dist/react.mjs, …). Public subpath keys unchanged. - package.json exports: types/import paths point at the new flat dist filenames. Peer deps + peerDependenciesMeta unchanged. - typedoc.json: 12 entry points at new src paths. - tests-dom/use-hydrated.test.tsx → tests-dom/react.test.tsx. - Isolation tests: replaced 8 scattered sibling-scan blocks + added 3 (seroval, tanstack, react) with a uniform co-located self-check — every adapter's relative imports must resolve into core/ (no cross-adapter coupling). Path-aware; scales with new adapters. - Zero-dep gate test: tanstack path updated to the new location. - architecture.md: 12-entry table + a Folder layout section + the multi-framework hydration story. Green: 133 unit + 4 DOM + build + typedoc + format + lint + typecheck. No consumer-facing subpath renames; dist filename changes are transparent (subpath imports route via exports). * refactor(subpaths)!: mirror public subpaths to the folder structure Categorize the public subpath namespace 1:1 with src/adapters/<seam>/ so the surface maps directly to the source layout — a contributor adding adapters/backends/opfs.ts knows the subpath is ./backends/opfs with zero mental mapping. Best AX for incoming contributions; scales as the adapter surface grows (IDE navigates by category). ./seroval -> ./codecs/seroval ./zod -> ./codecs/zod ./idb -> ./backends/idb ./async-storage -> ./backends/async-storage ./mmkv -> ./backends/mmkv ./secure-store -> ./backends/secure-store ./crosstab -> ./transport/crosstab ./tanstack-store -> ./sources/tanstack-store ./react -> ./frameworks/react ./solid -> ./frameworks/solid ./vue -> ./frameworks/vue The . (core) entry is unchanged. dist/ stays flat (dist/<basename>.mjs); exports routes each categorized subpath to its flat dist file. Updated every reference: README, architecture.md, glossary.md, roadmap.md, skills/tanstack-store/SKILL.md, idb.ts JSDoc, and the four pending changesets. The dated audit doc is left as a historical snapshot. Breaking (early package; consumers update imports). Green: 133 unit + 4 DOM + build + typedoc + intent:validate + format + lint + typecheck. * feat(svelte): add Svelte hydration adapters (runes + store) Cover both pre- and post-runes Svelte over the HydrationSignal seam. Two subpaths because svelte/reactivity (runes) is Svelte 5+ and would break a Svelte 4 import — each owns its dep range: - ./frameworks/svelte (peer svelte >=5.0.0): hydratedRune(signal) via svelte/reactivity createSubscriber. Returns { readonly current }; read inside a reactive context. Subscription owned by the reactive context, cleaned on dispose. Post-runes. - ./frameworks/svelte-store (peer svelte >=3.0.0): hydratedStore(signal) via svelte/store readable. Returns Readable<boolean>; auto-subscribe with $hydratedStore. Works on Svelte 4 (pre-runes) AND Svelte 5 (store-preferring users). Subscription tied to the store subscriber lifecycle. Both render true on the server (no-op PersistApi always-hydrated), matching the HydrationSignal adapter contract. Each is its own subpath with svelte optional, no cross-entry value imports (isolation tests included). 7 co-located tests — the store adapter is fully tested in bun (svelte/store works standalone); the runes adapter pins the value contract (createSubscriber's start is a no-op without a Svelte owner, so the reactive auto-update rides on the HydrationSignal contract pinned in core/hydration.test.ts — same philosophy as the React use-hydrated bun test). README/architecture.md tables re-sorted into seam-folder order. * refactor(build)!: mirror tsdown entry + dist layout to src folders tsdown entry keys now mirror the src folder structure 1:1: codecs/seroval -> src/adapters/codecs/seroval.ts -> dist/codecs/seroval.mjs backends/idb -> src/adapters/backends/idb.ts -> dist/backends/idb.mjs frameworks/react -> ... -> dist/frameworks/react.mjs core/index -> src/core/index.ts -> dist/core/index.mjs (…14 entries total) dist/ now nests by seam (was flat). package.json exports point at the nested dist paths. The full chain is 1:1 — src folder → tsdown key → dist path → public subpath — so a contributor adding adapters/backends/opfs.ts wires `backends/opfs` in tsdown + exports + the dist file lands at dist/backends/opfs.mjs with zero mental mapping. No consumer-facing subpath change (imports route via exports); the dist reorganization is internal. architecture.md folder-layout note + the pending subpath-mirror changeset updated to reflect the nested dist. Green: 140 unit + 4 DOM + build + typedoc + format + lint + typecheck. * feat(backends): add encrypted + compressed storage wrappers; README comparison + migration Two zero-dep async storage wrappers over the StateStorage seam: - ./backends/encrypted: createEncryptedStorage(getStorage, { key }) — AES-GCM via WebCrypto. Stored value is base64(iv).base64(ciphertext); the auth tag means a wrong key / tampered ciphertext throws on decrypt → corrupt-payload path. Undefined when crypto.subtle unavailable. - ./backends/compressed: createCompressedStorage(getStorage, { format? }) — native CompressionStream/DecompressionStream (gzip|deflate|deflate- raw, default gzip); base64 wire. Undefined when streams unavailable. Stacks with encrypted (compress-then-encrypt). Design: both are backend WRAPPERS, not sync StorageCodecs, because crypto.subtle + the stream APIs are async and the StorageCodec seam is sync. The codec serializes (sync); the wrapper encrypts/compresses the serialized string (async). 14 co-located tests (round-trip, ciphertext- not-plaintext / compression-ratio, wrong-key-fails, missing-key, formats, persistSource end-to-end, availability guard, isolation). README: comparison table vs zustand-persist / redux-persist / @tanstack/query-persist-client / pinia-persist, + a migration guide with option-mapping tables + port snippets for each incumbent (redux + pinia snippets wrap the store in a PersistableSource; query omits the mis-typed retryWrite). Green: 154 unit + 4 DOM + build + typedoc + format + lint + typecheck. * docs(readme): fact-check the comparison table + migration guide vs official sources The comparison table + migration option-mappings made claims about four incumbents from domain knowledge; several were wrong and would have looked bad. Four subagents cloned the official repos and verified every cell against the source (file:line): pmndrs/zustand, rt2zz/redux-persist, TanStack/query, prazdevs/pinia-plugin-persistedstate Comparison-table corrections (8 cells): - redux-persist: Codec ~→✓ (has serialize/deserialize pure functions); throttleMs ✗→✓ (has throttle); Store-agnostic ~→✗ (bound to redux) - @tanstack/query-persist-client: Codec ✗→~ (serialize/deserialize on the storage factories, not the Persister interface); Hydration ✗→✓ (PersistQueryClientProvider + useIsRestoring); migrate ~→✗ (no version/migrate, only buster); retryWrite ~→✓ (PersistRetryer shrink-or-give-up + removeOldestQuery); throttleMs ✗→✓ (throttleTime); Framework ✗→✓ (ships React/Solid/Svelte/Angular/Preact providers) - pinia-persist: Hydration ✗→~ (beforeHydrate/afterHydrate callbacks); migrate ~→✗ (no version/migrate) - zustand-persist: verified accurate, no corrections The differentiator was false (query-persist-client also has a hydration signal + storage seam + partial codec) — rewritten to the honest unique claims: cross-tab sync, schema-validation codec, fully store-agnostic source, and the only one that scores ✓ on every row. Credits query- persist-client where due. Migration-guide option-mappings reconciled: redux adds serialize/ deserialize + throttle; query adds throttleTime + correctly maps the hydration/framework adapters it ships (was listed "no equivalent"); pinia beforeRestore/afterRestore → beforeHydrate/afterHydrate (v4). Added a provenance footnote citing the four repos + the verification date. * docs(plans): hydrate the upstream TanStack pitch The pitch draft was stale: 99 tests (now 158), an adapter surface of just TanStack+React (now 14 subpaths across codecs/backends/transport/ sources/frameworks), and — most importantly — a "beyond parity" framing that claimed the hydration signal was beyond @tanstack/query-persist- client, which the recent fact-check disproved (query has hydration + framework adapters + retry + throttle + maxAge). Hydrated: - test count + breadth (14 opt-in subpaths, multi-framework, the seams proven to scale) - §3 rewritten to the honest delta: parity items credited to query (buster/maxAge/throttle/retry/hydration/framework adapters), beyond- parity items are the true differentiators (store-agnostic source, codec seam independent of the backend, versioned migrate, cross-tab, schema validation) — links the fact-checked comparison table - §4: Solid/Vue/Svelte adapters now SHIP (were "could mount"); the contribution scope is the core + TanStack adapter + framework hydration adapters; notes the categorized subpath mirror - §5: adds comparison + migration guide links - Status line: dated hydration note Per docs-governance: a Plan, in-flight (not yet posted); hydrated in place rather than lifted. * docs(adapters): prune redundant comments + tighten user-facing JSDoc Apply the authoring-discipline decision test (re-derivable in 30s → cut) to the adapter source comments across the PR diff. Headers: trim the repeated "ships as its own subpath / no barrel / dep opt-in / enforced by an isolation test" boilerplate (re-derivable from package.json exports + index.ts, duplicated across 14 adapters) to one line each — the dep + version + any non-obvious quirk (secure-store ~2KB limit, mmkv sync, tanstack types-only/structural, svelte runes vs store split, encrypted/compressed no-peer web globals). JSDoc: cut signature-restating + generic "matching the HydrationSignal adapter contract" / "collapses to the no-op PersistApi" narration on the verbose adapters (encrypted, compressed, svelte, svelte-store, solid, vue); kept every gotcha (AES-GCM wire format + auth-tag → corrupt-payload, wrapper-not-sync-codec because the web APIs are async, reactive-scope ownership, SSR, the Svelte 4↔5 split) + @example. Fix: async-storage JSDoc stale bare-subpath refs (./react/./solid/./vue → ./frameworks/…) + added svelte. Pre-existing core comments (src/core/) + the reference react.ts JSDoc preserved; test-file gotcha comments (bounded-loop rationale, runtime mocks, the Svelte reactive-context limitation) kept. * docs: fact-check every reference/path/count across .md, comments, JSDoc Four subagents verified all references against the actual repo state (JSDoc @example imports, markdown paths/anchors/URLs/counts, src comment file:line citations, cross-file config consistency). JSDoc @example blocks were all clean. Fixes: Config/counts: - typedoc.json: add 4 missing entry points (encrypted, compressed, svelte, svelte-store) → 16, matching exports + tsdown - tsdown.config.ts comment: "Fourteen" → "Sixteen" subpath entries - docs/architecture.md: "twelve entry points" → "sixteen" - .github/CONTRIBUTING.md: src/index.ts → src/core/index.ts; "four subpath entries" → fifteen opt-in; "99 tests" → 154; "five subpath entries" → sixteen Stale paths (refold moved src/ → src/core/ + src/adapters/<seam>/): - docs/glossary.md: src/persist-core.ts, src/hydration.ts → src/core/… - src/core/persist-core.ts header + 2 JSDoc refs: ./persist-seroval → ./codecs/seroval; roster updated to the 5 seam folders - src/core/hydration.ts: ./use-hydrated.ts → ./frameworks/react - src/adapters/frameworks/solid.test.ts: persist-solid → solid - .changeset/solid-vue-hydration.md: src/use-hydrated.ts → src/adapters/ frameworks/react.ts Stale flat subpaths (renamed to categorized): - .github/ISSUE_TEMPLATE/1_bug.yml: ./seroval, ./idb → ./codecs/seroval, ./backends/idb - .github/ISSUE_TEMPLATE/2_feature_adapter.yml: ./react → ./frameworks/react Agent-skill docs (concrete stale paths only; conceptual bare mentions left): pr-comment-fact-check (SKILL.md test-file list + WORKFLOW.md file:line example), tdd (SKILL.md co-location + PATTERNS.md imports), improve-codebase-architecture (REFERENCE.md examples + LANGUAGE.md module examples), harden-pr/LEDGER.md example. docs/plans/upstream-tanstack-pitch.md: "14 opt-in subpaths" → 15. The dated audit doc (docs/audits/2026-07-04-…) is a historical snapshot — its pre-refold flat-layout references left as-is. Green: 154 unit + 4 DOM + build + typedoc (16 entries) + intent:validate + format + lint + typecheck. * docs: remove stale-prone count enumerations (Sixteen/158/15/…/N tests) Counts in comments + docs ("Sixteen subpath entries", "158 tests", "15 opt-in subpaths", "14 co-located tests") are re-derivable (count the exports / entries / tests) and go stale the instant the count changes — errored information, no ROI. Per authoring-discipline: cut the count, keep the principle or the list. Removed/rephrased across: - tsdown.config.ts comment, docs/architecture.md (typedoc), CONTRIBUTING.md (public surface + test/build command comments), the upstream pitch (3 places), two changesets (encrypted-compressed, svelte-hydration) Kept: "three seams" (a fixed architectural concept, not a re-derivable count), "two adapters"-style conceptual examples in skill docs, test- data values (count: 99 in persist-core.test.ts), and the dated audit doc's historical counts. Green: 154 unit + 4 DOM + build + typedoc + format + lint + typecheck. * docs(plans): rewrite the upstream TanStack pitch fresh Rewrite as if it never existed — no historical "following up / agreed route / hydrated on" framing, no changelog-edit traces, no count numbers. Reflects the current capabilities; every claim fact-checked against the actual repos (no guessing): - query-persist-client parity/beyond verified against the cloned TanStack/query source: ships framework persist-client providers for React/Solid/Svelte/Angular/Preact (no Vue), has buster/maxAge/ throttleTime/PersistRetryer (removeOldestQuery)/useIsRestoring, and NO versioned migrate (only buster) / no cross-tab / no schema / cache-bound / serialize-coupled-to-persister. - README/architecture anchors linked are all verified to resolve. - Dropped unverified addressee names + any assertion about TanStack's plans — reframed as a proposal, not a follow-up. Short, natural, pragmatic. * docs(agents): lift redundant-enumeration + historical-traces lessons into authoring-discipline PROSE.md Two lessons from this session became policy — lift them into the prose- depth SSOT (PROSE.md) per the lessons.md "prefer lifting" rule, rather than spin up a parallel comment-hygiene skill (would duplicate authoring-discipline; this repo chose it as the prose-depth home). - "Cut" list: add "tallied counts of re-derivable items" — the number goes stale + turns errored; the items carry the story. - JSDoc line: sharpen @example to "real, resolving imports, when usage isn't obvious" (the fact-check found stale @example import paths). - New "Historical traces" line: "hydrated on / following up on / changelog-edit residue / stale rosters" — no ROI once the moment passes; write fresh, cut the trace. Per writing-great-skills: single source of truth (PROSE.md only, no rule duplication — the rule's existing "details in PROSE.md" pointer routes), no no-ops (each addition names a distinct violation the generic decision test only implies), aggressive pruning. Per writing-agents-config: SSOT in skill, rule stays slim. PROSE.md is 35L (≤60 default). * docs(audit): mark the 14 completed ROI plan items ✅ Status note + ✅ in the # cell for the shipped items: #1–10, #14, #15, #17, #32. The 18 remaining (11–13, 16, 18–31) stay unmarked as the backlog. Only the ROI plan tables touched; the dated appendices below (verbatim subagent reports) remain the historical snapshot. * chore(package): refresh description + keywords to the full scope The About (package.json description/keywords + the GitHub repo About) named only TanStack Store + React; the branch shipped Solid/Vue/Svelte hydration, seroval/zod codecs, IDB/AsyncStorage/MMKV/SecureStore/ encrypted/compressed backends, a BroadcastChannel cross-tab transport, cross-tab sync, versioned migrate, retryWrite. - description: "Hydration-aware persistence middleware for any reactive store — pluggable storage × codec seams, cross-tab sync, and React/Solid/Vue/Svelte hydration adapters" - keywords: add reactive, middleware, codec, cross-tab, tanstack-store, react, solid, vue, svelte, zod, react-native, encryption, compression, seroval, broadcastchannel; drop the redundant persist + state. GitHub repo About (description + topics) synced via gh repo edit as the pre-merge step — dropped persist/sessionstorage, added the new scope topics (20 total). * feat(backends): node-fs adapter; pack-validation CI gate; decision matrices Three audit-plan items: #18 ./backends/node-fs — nodeFsStateStorage({ dir }): async StateStorage over fs.promises, one file per key, filename-sanitized, no peer dep (node:fs built-in). Unblocks server/SSR/CLI. 6 co-located tests. #19 pack-validation + semver gate — check:pack script runs @arethetypeswrong/cli (attw --pack . --profile esm-only) + publint + knip; wired into CI (check-pack job, gated on build) + prepublishOnly. knip.json configured (16 exports auto-detected). All three pass. Fixed publint's repository.url suggestion (git+https://). #16 decision matrices — README "Choosing a storage" (9 backends: sync/cross-tab/structured-clone/size/gate) + "Choosing a codec" (5 options: Set-Map-Date/wire-type/schema/backend). Makes the surface navigable. Wiring: package.json exports + tsdown entry + typedoc entryPoint for node-fs; README install + extensibility tables + a Node fs recipe; architecture.md entry table + folder layout. Changeset for node-fs (minor). Green: 160 unit + 4 DOM + build + check:pack + typedoc + typecheck + format + lint. * docs(audit): mark #16, #18, #19 ✅ in the ROI plan * chore(package): pin pack-validation devDeps (attw/knip/publint) to exact versions * chore(codemap): dogfood @stainless-code/codemap for agent structural queries Adopt codemap (same org) as the repo's structural index for AI agents. SQLite index of symbols, imports, exports, dependencies — queryable via SQL instead of scanning files. - .agents/rules/codemap.md (alwaysApply) + .agents/skills/codemap/SKILL.md - .cursor/ symlinks (rule + skill) + .cursor/mcp.json (MCP server) - .git/hooks/ — background incremental index sync (local, not committed) - devDep pinned: @stainless-code/codemap 0.11.1 Coexists with knip (pack-validation gate) — codemap is agent navigation, knip is publish hygiene. * chore(codemap): dogfood @stainless-code/codemap for agent structural queries Adopt codemap (same org) as the repo's structural index for AI agents. SQLite index of symbols, imports, exports, dependencies — queryable via SQL instead of scanning files. - .agents/rules/codemap.md (alwaysApply) + .agents/skills/codemap/SKILL.md - .cursor/ symlinks (rule + skill) + .cursor/mcp.json (MCP server) - .git/hooks/ — background incremental index sync (local, not committed) - devDep pinned: @stainless-code/codemap 0.11.1 Coexists with knip (pack-validation gate) — codemap is agent navigation, knip is publish hygiene. * chore(codemap): add VS Code / Copilot MCP + copilot-instructions - .vscode/mcp.json — codemap MCP server for VS Code / Copilot Chat - .github/copilot-instructions.md — codemap pointers for Copilot - .cursor/mcp.json — refreshed (uses local codemap bin) * chore(codemap): remove copilot-instructions.md * docs: authoring-discipline audit fixes across the full PR diff Four subagents audited every file in the diff against the PROSE.md decision test. Fixes applied: Stale paths (fact-check missed these in skill docs + changesets): - improve-codebase-architecture/REFERENCE.md: persist-idb → backends/idb - improve-codebase-architecture/LANGUAGE.md: use-hydrated.ts → react.ts - pr-comment-fact-check/WORKFLOW.md: persist-tanstack.ts → tanstack-store.ts (×2) - tests-dom/react.test.tsx: src/use-hydrated.test.ts → react.test.ts - .changeset/rn-storage-adapters.md: persist-idb template → ./backends/idb Stale Tier-1 count (codemap added a rule): - agents-tier-system.md: 7 → 8 always-on rules - .agents/README.md: cut hardcoded rules+skills rosters (contradicts own "no hardcoded name lists" policy); kept "discover on disk" pointer Roadmap stale: - "React hook" → "React/Solid/Vue/Svelte hydration adapters" - "scales to Svelte/Solid/Vue" → "React/Solid/Vue/Svelte shipped" README clear cuts: - Added missing TOC entries (Comparison, Migrating from, Choosing a storage, Choosing a codec) - Cut "only one that scores ✓ on every row" (re-derivable from table) - Cut historical trace footnote (verification date — stale-prone) Changeset test-inventory trims (internal QA prose → not CHANGELOG material): - Cut "Co-located tests (...)" / "(isolation test included)" / test strategy paragraphs from 5 changesets Reported but NOT fixed (style preferences, pre-existing, or user decision): - JSDoc signature-restating on adapters (pre-existing on core; new adapters already trimmed — further compression is marginal) - README duplicate facts (Install vs Entry table; Relationship vs Extensibility intro; Caveats vs hydration section) — consolidation needs a structural decision about README vs architecture.md roles - Test comments restating assertions — low visibility - persist-core.ts pre-existing JSDoc @param narration — preserve rule * docs(audit): add Angular-signals (#33) + Preact (#34) framework adapters to Tier 2 Split from the old #26 (Tier 3) into two separate actionable items in Tier 2 — the natural continuation after React/Solid/Vue/Svelte shipped. Removed the old #26 from Tier 3 to avoid duplication. * feat(frameworks): add Angular-signals + Preact hydration adapters Complete the framework adapter set: React, Solid, Vue, Svelte, Angular, Preact — all over the HydrationSignal seam. - ./frameworks/angular (peer @angular/core >=17): useHydrated(signal) → readonly Signal<boolean> via signal() + effect() + onCleanup. Call in a component injection context; subscription cleaned up on context destroy. Tested with mocked @angular/core (no injection context). - ./frameworks/preact (peer preact >=10.19): useHydrated(signal) → { hydrated } via useSyncExternalStore (preact/compat). Near-clone of ./frameworks/react. @ts-expect-error on the 3-arg call (Preact types omit getServerSnapshot; runtime ignores it). Tested with mocked preact/compat. Both render true on SSR. 8 co-located tests (4 each). DevDeps pinned (@angular/core 22.0.5, preact 10.29.4). Wired: package.json exports + peers + peerMeta, tsdown entry + neverBundle, typedoc, README + architecture tables, audit #33/#34 marked ✅. * docs(plans): hydrate pitch with Angular + Preact framework adapters * docs: authoring-discipline audit (incremental) — Angular + Preact Two subagents audited the diff since the last audit (2ce7df9..HEAD). Adapter JSDoc trimmed: - angular.ts: cut signature-restating + SSR-contract boilerplate; kept the injection-context gotcha; added resolving import to @example - preact.ts: cut to 1-line summary; added resolving import to @example - angular.test.ts: cut mock-implementation restatement (kept rationale) Changeset trimmed: - angular-preact-hydration.md: cut test inventory, API re-derivable prose, roster enumeration; kept injection-context + @ts-expect-error constraints Audit fix: - #33 row: cut stale "Angular is the remaining framework adapter" (shipped) README, package.json, tsdown, typedoc, architecture, pitch: clean. * feat: bundle-size gate (#23) + packageManager/compat (#24) + FAQ (#27) #23 — size-limit gate: - .size-limit.json: core (2.5 KB gzip), react (2 KB), seroval (2 KB) - `size` script + CI `size` job (after build) + prepublishOnly gate - Bundle-size badge in README (core gzip) - DevDeps pinned: size-limit 12.1.0, @size-limit/preset-small-lib 12.1.0 - Passing: core 2.13 kB, react 247 B, seroval 454 B #24 — packageManager + compat: - `"packageManager": "bun@1.3.14"` in package.json - TypeScript >=5.0 (from moduleResolution: "bundler") - Compatibility table in README (Node, Bun, TS, React, @tanstack/store) #27 — FAQ: - 7 items: UI flash, IDB cross-tab, quota, Set/Map/Date, clear-all, encrypt at rest, non-singleton cleanup - Links to existing sections; matches README voice Audit #23, #24, #27 marked ✅. * feat(ci): coverage gate (#22) — 90% line threshold - `test:coverage` script: `bun test ./src --coverage --coverage-threshold=0.90` (fails CI if line coverage < 90%) - CI `test` job now runs `test:coverage` (replaces plain `test`) - `prepublishOnly` gates on coverage too - Current: 98.58% lines, 93.45% funcs across 20 files — well above the 90% threshold with headroom for new adapters Audit #22 marked ✅. * docs: authoring-discipline audit (incremental) — compat + FAQ + audit rows One subagent audited the diff since acc7f9d. Fixes: Compatibility table: - Cut stale package-version row (goes stale every release) - Cut unverified TypeScript row (not in package.json; derived from repo tsconfig, not consumer tsconfig — unverifiable) - Kept Node/Bun/React/@tanstack/store (peer/runtime ranges not in the install table) FAQ: trimmed every answer to Q + unique insight + "See [section]" - Cut bodies that restated the linked section's content - Kept non-obvious constraints not in the linked sections (double- gating drops writes; identityCodec + string backend; etc.) Audit ✅ rows #22, #23, #27: trimmed stale clauses ("unmeasured", "advertise zero-dep core", "lift from skill:141" historical trace). CI, .size-limit.json, package.json: clean. * feat(sources): add zustand + jotai + valtio + mobx source adapters (#25) Ship 4 source adapters over the PersistableSource seam + document recipes that point to the subpaths. Each is a thin persistSource wrapper mapping the library's store API: - ./sources/zustand (peer zustand >=4): persistZustand(store, opts) - ./sources/jotai (peer jotai >=2): persistJotai(store, atom, opts) — replace-merge default (primitive atoms don't hydrate to {}) - ./sources/valtio (peer valtio >=1): persistValtio(proxy, opts) — snapshot for reads, Object.assign for writes - ./sources/mobx (peer mobx >=6): persistMobx(observable, opts) — toJS for reads, observe for changes README "Wrapping your store" recipe section: shipped-adapter snippet + "or pass a custom PersistableSource" note for each + a generic persistSource example for any other store. Wired: package.json exports + peers + peerMeta, tsdown entry + neverBundle, typedoc, README install + extensibility tables, architecture entry table + folder layout. DevDeps pinned (zustand 5.0.14, jotai 2.20.1, valtio 2.3.2, mobx 6.16.1). 12 co-located tests (round-trip + subscribe + isolation each). Audit #25 marked ✅. * update lock file * fix(jotai): export JotaiStore type so typedoc includes it The interface was a non-exported comment-only declaration — typedoc flagged it as 'referenced but not included.' Adding JSDoc makes typedoc treat it as intentional. * fix(jotai): export JotaiStore type for typedoc inclusion * refactor(sources): shape-name adapters (persistStore/Atom/Proxy/Observable) Rename the four source adapters from library-suffix to shape-based names per the agnostic-core ethos: same persistable shape → same name → same merge semantics; the subpath carries the library. - persistZustand → persistStore (same shape as tanstack persistStore) - persistJotai → persistAtom (same shape as tanstack persistAtom) - persistValtio → persistProxy - persistMobx → persistObservable tanstack persistStore/persistAtom unchanged (already shape-named). Alias when importing two same-shape adapters into one module. Updates README recipes + entry-points table, docs/architecture.md sources bullet, the store-adapters changeset, and the package.json description. * docs(governance): sweep README + architecture — stale refs, ethos-aligned compat architecture.md: fix stale `use-hydrated.test.ts` ref (renamed to src/adapters/frameworks/react.test.ts); broaden the test-matrix scope row from "TanStack adapters" to "source + framework adapters" (bun suite now covers all 5 source + 7 framework adapters). README: trim the Compatibility table from Node/Bun/React/@tanstack/store to the required core runtimes (Node, Bun) + a pointer to package.json peerDependencies — under the agnostic ethos no two optional peers are privileged over the others; ranges live in the source of truth. No anchors broken (#compatibility, architecture.md#test-matrix, #sync-vs-async all resolve; headings unchanged). * docs(audit): fix stale paths + subpaths in 2026-07-04 docs-adapters-roi Point-in-time audit record — keep the dated findings, fix only the refs that now 404 or misresolve under the post-rename layout: - File paths: src/persist-core.ts → src/core/persist-core.ts (and .test.ts); src/hydration.ts → src/core/hydration.ts; src/index.ts → src/core/index.ts; persist-idb.ts → src/adapters/backends/idb.ts; persist-seroval.ts → src/adapters/codecs/seroval.ts; persist-tanstack.ts → src/adapters/sources/tanstack-store.ts; use-hydrated.ts → src/adapters/frameworks/react.ts (and the matching .test.ts files). - Subpaths: ./seroval→./codecs/seroval, ./idb→./backends/idb, ./tanstack-store→./sources/tanstack-store, ./react→./frameworks/react, ./persist-idb→./backends/idb; dist/use-hydrated → dist/frameworks/react. - Dropped brittle package.json line ranges (32-53, 116-129, 49-52) — the ranges shifted after the layout refactor; the bare symbol carries the ref without going stale. - Appendix A pointer: file:line refs now point to src/core/ or src/adapters/<seam>/. Historical-state counts (5 subpath entries, 52 capabilities) kept — they are the audit's dated findings, accurate to 2026-07-04. * docs(plans): add remaining-roi — actionable post-audit follow-up Self-contained plan tracking the items not yet shipped from the 2026-07-04 ROI audit (9 items: Query bridge, examples/ workspace, docs site, npm provenance, real-browser + SSR test matrix, migration-chain helper, React ergonomics layer, OPFS/SQLite/Cloudflare adapters, playground) + a lower-priority backlog. Each item carries what/why/ effort/acceptance/where-it-lands; context + conventions sections make it readable standalone by another agent. roadmap.md: link the plan under "Next" (open items live there per docs-governance); generalize the stale partial adapter enumeration in the intro to "shipped features live in src/ and the root README" (agnostic ethos + authoring-discipline: avoid stale partial counts). Plan closes per docs-governance § Closing a plan: lift durable bits to architecture/roadmap/rule and delete when the list is empty. * chore(package): refresh npm keywords to the shipped surface Drop `tanstack-intent` (a dev-dep tool, not a library capability — noise as a keyword). Add the adapters shipped on this branch that were missing: angular, preact (frameworks); zustand, jotai, valtio, mobx (sources); mmkv (backend); and typescript. Re-sort alphabetically. Description is unchanged (already ethos-aligned this session). * docs(plans): refresh upstream-tanstack-pitch to current surface + accuracy §1: add node-fs to the backends breadth list; expand "TanStack store sources" to the 5 shipped source adapters (TanStack Store, zustand, jotai, valtio, mobx) — the breadth is the "proven to scale" evidence. §3: soften the framework-hydration-adapter parity claim — query-persist-client ships React in-core; Solid/Svelte/Angular/Preact live in sidecar @tanstack/query-*-<fw> packages, not the core. Moved non-React framework adapters to the Beyond column where they belong. * ci(release): switch to npm trusted publishing (OIDC) + provenance Replace the long-lived NPM_TOKEN with npm trusted publishing — GitHub OIDC exchange mints a short-lived publish token at release time; no token secret to leak or rotate. Provenance attestations are auto-generated (no --provenance flag needed under trusted publishing). - release.yml: add `id-token: write` (the job needs the GitHub OIDC token); add `environment: release` to the publish job (matches the npm trusted-publisher binding's environment claim); remove NPM_TOKEN from the changesets step env. changesets/action@v1 detects OIDC and skips the .npmrc token write; `changeset publish` routes through `npm publish` (non-pnpm → npm), which does the OIDC exchange. - package.json: add `publishConfig.provenance: true` (belt-and-suspenders per npm docs' third-party-publishing-tools path; harmless under trusted publishing, required if ever falling back to token-based provenance). npm side already configured: trusted publisher = stainless-code/persist + release.yml + environment `release` + `Allow npm publish`; publishing access = "require 2FA and disallow tokens". After the first successful trusted publish, the old NPM_TOKEN repo secret can be revoked + deleted. * docs(plans): mark #4 (npm provenance) implemented via trusted publishing Update the #4 entry to reflect the chosen approach (npm trusted publishing / OIDC, not the original --provenance-over-NPM_TOKEN idea) and the implemented state (workflow + package.json committed; npm trusted-publisher configured). The remaining acceptance step is the first-release provenance verification + NPM_TOKEN revocation. Strike once verified. * refactor(test): dedupe MemoryStorage into src/testing/memory-storage 12 test files each copy-pasted the same `MemoryStorage implements StateStorage` fixture. Extract one shared module at `src/testing/memory-storage.ts` and import it everywhere. The fixture is test-only: not a tsdown `entry` (so not built into `dist/`), not a typedoc `entryPoint` (so not in the API site), and `src/` isn't in `package.json` `files` (so not published). knip treats `*.test.ts` as entries, so the fixture is traced from its importers — not flagged unused (verified: `bunx knip` has no findings for it). In each importer, dropped the now-unused `StateStorage` type import where it was only used by the inline class (10 files); kept it in `persist-core.test.ts` (many `: StateStorage` annotations) and `seroval.test.ts` (`idbStorage: StateStorage`). Verified: `bun run typecheck` clean, `bun run lint` clean, `bun test ./src` 180 pass / 0 fail. * docs(plans): strike the memory test-fixture backlog item (shipped) Deduped in `2fbf6ad` — `MemoryStorage` now lives at `src/testing/memory-storage.ts`, imported by all 12 test files. Net −200 lines of copy-paste. Test-only infra; no durable bit to lift to architecture/roadmap, so just strike the bullet. * chore(deps): refresh devDeps + override uuid to clear CVE - Add `package.json` `overrides.uuid: ">=11.1.1"` to force the fixed version across the dev tree. The vulnerable `uuid@7.0.3` was a transitive devDep: expo-secure-store@57 → expo (auto-installed peer) → @expo/config → @expo/config-plugins → xcode → uuid. Not in `src/`, not in `dist/`, not executed (expo-secure-store is mocked in tests; expo/xcode never run), so the override is safe — it only satisfies `bun audit`. `bun audit` now reports zero vulnerabilities. - Refresh devDeps to latest: @tanstack/intent 0.3.4→0.3.5, @types/node 26.0.1→26.1.0, @typescript/native-preview dev-build bump, oxfmt 0.56→0.57, oxlint 1.71→1.72. Verified: `bun run lint:ci`, `format:check`, `build`, `typecheck`, `bun test ./src` (180 pass) all green with the new tool versions. * ci(supply-chain): Dependabot + SHA-pin hand-written actions + least-priv CI Three supply-chain hardening measures on top of the existing `bun audit` job, frozen-lockfile installs, and npm trusted publishing + provenance: 1. `.github/dependabot.yml` — weekly updates for npm (devDeps grouped; no runtime deps; peer ranges are public surface, not bumped) and github-actions (bumps the SHA-pinned actions' commits + the `# vN` comment). Automated CVE + action-bump PRs. 2. SHA-pin the external actions in the hand-written workflows: - ci.yml: actions/checkout@v7 → @9c091bb… (9 uses) - release.yml: actions/checkout@v7 → @9c091bb…; changesets/action@v1 → @a45c4d5… Tag-pinning is a repointing vector; SHA-pinning is tamper-resistant. Dependabot keeps the SHAs current. 3. ci.yml: add workflow-level `permissions: contents: read` (every CI job is read-only). The Release workflow keeps its own elevated permissions. Not SHA-pinned: `check-skills.yml` — it's `@tanstack/intent`-managed (`intent-workflow-version: 3`); `intent setup-github-actions` regenerates it from templates and would revert any SHA pin. Its action versions (`actions/checkout@v4`, `actions/setup-node@v4`) are intent's template choice; that file's integrity comes from intent, not from our pin. This is also why the repo has both v4 (intent-managed) and v7 (hand-written) actions/checkout. * docs(supply-chain): slim dependabot + ci.yml comments per authoring-discipline Cut re-derivable restatements: - dependabot.yml npm: drop "devDeps only; no runtime deps" (re-derivable from package.json + `dependency-type: development`); keep the grouping why + the peer-ranges-are-public-surface decision. - dependabot.yml github-actions: drop "keep the SHA-pinned actions current" (restates the ecosystem); keep the `# vN` comment-bump detail + the tamper-resistance why. - ci.yml permissions: drop "every CI job is read-only (no repo writes)" (restates `contents: read`); keep the Release-workflow contrast. * ci(audit): block on high/critical, keep moderate/low non-blocking `bun audit` exits 0 regardless of severity, so the audit step scans its (ANSI-stripped) output for `high:` / `critical:` lines and exits 1 only on those — moderate/low are reported but don't fail the job. - Drop `continue-on-error: true` and add `audit` to the `ci-complete` needs + failure condition so a high/critical finding blocks merge. - Tolerant of `bun audit` infra failures (advisory-API outage → no severity lines → no block); malware is out of scope (provenance + frozen lockfile cover that). - ANSI strip (`sed -E 's/\x1b\[[0-9;]*m//g'`) — bun emits color even when piped, which would break the severity grep. Verified locally: clean audit → PASS; mock `high:`/`critical:` → BLOCK; mock moderate-only → PASS. * docs(plans): hydrate remaining-roi sequencing — #4 done #4 (npm provenance) is implemented (committed `0c4ea17`, trusted publishing wired, npm side configured) — pending first-release verification. Drop it from the numbered sequencing so the list reflects what's actually left; note its pending-verification state once. * feat(core): add createMigrationChain — versioned migrate helper A zero-dep core helper that builds a `migrate` callback from a per-version step chain. The returned function walks `steps[fromVersion]` → … → `steps[version-1]`, awaiting each, so a payload at any supported older version migrates to the current one. Plug into `PersistOptions.migrate`. - Options bag (TanStack `createX` convention): `version` (current), `steps` (keyed by the version each step transforms *from*), `onNewer` (default "throw" — a downgrade is a bug), `onOlder` (default "discard" — dropped support for that version). - Eager construction validation: gap in the covered range, out-of-range step key, or non-integer version throws now, not on a payload later. `minKey > 0` drops support for older versions (onOlder handles them). - Beyond TanStack Persist's `buster` (discards on mismatch) — transforms. - Lives in `src/core/persist-core.ts` alongside `createPersistRegistry`; no new subpath; zero-dep core gate stays green (no value imports). Tests (14, co-located): forward chaining, partial walk, async-sequential, onOlder/onNewer discard+throw, throwing-step propagation, eager gap/range/version validation, + 3 end-to-end via `persistSource` (v0→v2 hydrate with write-back, onOlder discard keeps initial, throwing step → onError phase "migrate"). README recipe + changeset (`minor`). Verified: `tsgo --noEmit` clean, `bun test ./src` 194 pass / 0 fail. * docs(plans): strike #6 (migration-chain) — shipped Shipped in 2e9247f (createMigrationChain + README recipe + changeset). Mark the plan item ✅ shipped (terser than #4 — fully shipped + tested, no pending verification) and drop it from the sequencing's best-next-pick. * docs: authoring-discipline + docs-governance pass on the #6 work authoring-discipline: - README "Migration chain" recipe: trim the comment to the two non-obvious bits (from-keying + drop-support); the walk/await is shown by the example + covered by the JSDoc. - changeset: tighten the intro + bullets (release notes shouldn't re-state the full JSDoc contract). - persist-core.ts JSDoc: audited, kept as-is (dense + gotcha-driven, no re-derivable restatement). docs-governance: - Remove #6 from the plan entirely (rule 3: shipped → delete + lift, no "slim & keep" ✅ marker). Durable bits live in the JSDoc + README recipe; nothing to lift to architecture/roadmap. #4 keeps its marker — it's genuinely in-flight (pending release verification), which governance allows. - Drop the "(#6 shipped)" historical trace from the sequencing line. * ci: fix check:pack + size gates for the new core surface - knip.json: `ignore: [".codemap/**"]` (codemap's local index cache — gitignored, absent in CI, noisy locally) + `ignoreDependencies: ["@stainless-code/codemap"]` (a CLI devDep used via scripts/rules, not imports — knip can't see its usage). Without these `check:pack` flags 43 unused cache files + the codemap devDep. - .size-limit.json: core limit 2.5 KB → 3 KB. `createMigrationChain` pushed the core bundle to 2.51 kB gzipped; 3 KB keeps the gate green with headroom (still tiny). Verified: `bun run check:pack` (attw + publint + knip) green, `bun run size` green. * ci: build dist/ before check-pack + size (each job is a fresh runner) check-pack (attw/publint/knip) and size (size-limit) validate files under dist/, but each CI job checks out a fresh runner — needs: [build] only waits for the build job, it doesn't share its dist/. Both failed in CI ("the file does not exist" for every exports entry / "Size Limit can't find files at dist/..."). Add a `bun run build` step to each job before the validation. * harden: full review pass 1 — correctness, doc drift, public API, tests Parallel 5-reviewer harden pass on origin/main...HEAD. All in-bounds findings fixed; 4 deferred to LEDGER § Deferred; 1 by-design rejection logged. Correctness: - crosstab: `.catch(() => {})` on the postMessage chains — a failed async write/remove leaked an unhandled rejection (persist-core handles the write error via `result`; the broadcast branch didn't need to). close() now removeEventListener per handler before channel.close(). - encrypted: JSDoc overclaimed `clearCorruptOnFailure` self-heal on a wrong-key decrypt. The decrypt throw lands in the backend's async getItem → persist-core reports it as phase "hydrate" (the codec's clearCorruptOnFailure only fires for corrupt raw the codec can't parse, not backend getItem rejections). JSDoc corrected. - createMigrationChain: eager-throw on non-integer step keys (was silently dropped); guard non-integer stored `fromVersion` with a clear error (was a confusing TypeError in the walk loop); document the discard → write-back consequence in onNewer/onOlder JSDoc. - node-fs: refuse `..`/`.`/empty sanitized keys (the sanitizer keeps `.`, so `name === ".."` would resolve outside `dir`). Public API: - createAsyncStorage/createMmkvStorage/createSecureStoreStorage: explicit `: PersistStorage<S> | undefined` return types (lock the signature). - async-storage JSDoc + rn-storage-adapters.md: drop the misleading `createAsyncStorage(name)` phrase (it takes an AsyncStorage instance, not a name). - README entry-points table: add `createMigrationChain` + `createJSONStorage` to the core row. - package.json keywords: add `migration`. Docs drift: - CONTRIBUTING: release uses trusted publishing, not NPM_TOKEN. - subpath-mirror-folders.md changeset: only 4 subpaths existed on main (seroval/idb/tanstack-store/react) — the other 7 are NEW, not breaking renames; reframed to avoid overstating the breaking surface. - roadmap: drop shipped items (migration-chain, npm provenance) from "Next"; generalize the Framework-adapters row. - audit: drop two stale `package.json:NN` / `*.test.ts:NN-NN` line ranges (paths current, ranges drifted). - 1_bug.yml: seroval is a codec, not a backend. - LANGUAGE.md: `persist-seroval` → `seroval` (prefix dropped). - idb/async-storage/mmkv/secure-store tests: stale `persist-`-prefixed `describe` labels → factory names (matches encrypted/compressed). - README FAQ: `useHydrated` link → `#writing-a-framework-adapter` (was pointing at the hydration explainer with misleading link text). Tests: - createMigrationChain: +5 edge-case tests (fromVersion===version no-op, version:0 empty steps, async-step rejection, non-integer step key, non-integer stored version). Verified: `bun run check` + `check:pack` + `size` green; 199 pass / 0 fail. Anchor HEAD: 47d8014 (pre-hardened). * harden: reconcile 3 deferred LEDGER items (crosstab test, @example imports, test-helper dedup) #27 — crosstab removeItem-broadcast test: add an end-to-end test that tab A `clearStorage()` → bridge posts `newValue:null` → tab B's `onCrossTabRemove` fires. Covers the documented reset semantics of the BroadcastChannel bridge (setItem-broadcast was already covered). #28 — @example import completeness: add the missing library-symbol imports to the backend/crosstab @examples (encrypted, compressed, crosstab, async- storage ×2, mmkv ×2, secure-store ×2, node-fs) so each is copy-pasteable. The source-adapter + codec @examples already had imports. #30 — createMockSource + waitForHydration dedup: extract into `src/testing/mock-source.ts` + `src/testing/wait-for-hydration.ts` and import them across the 18 test files (13 for createMockSource, 17 for waitForHydration; node-fs keeps its macrotask `waitForHydration` variant). Removed the now-unused `PersistableSource` type imports (9 files). Same pattern as the shipped `MemoryStorage` fixture. LEDGER § Deferred: 3 fixed items removed; the svelte-peer item remains (out of bounds — package-level peer limitation). Verified: `bun run check` + `check:pack` + `size` green; 200 pass / 0 fail. * harden: CodeRabbit review triage — apply 6, 4 already-fixed, 1 push-back Triage of 11 CodeRabbit comments on PR #7 (per pr-comment-fact-check): Applied (6): - README:67 — engine range "Node ≥20.19" → "Node ^20.19.0 || >=22.12.0 (or Bun >=1.0.0)" to match `package.json` engines (excludes Node 21.x). - README:403 — core-row `registry` → `createPersistRegistry` (was too vague; createMigrationChain + createJSONStorage added in the prior harden pass). - compressed.ts — JSDoc: `deflate-raw` needs Node 20.12+ (the global guard passes on Node 18+ but the format throws pre-20.12). gzip/deflate stay 18+. - node-fs.ts — collision-resistant filenames: sanitized segment + djb2 hash of the original key (`app:prefs:v1` → `app_prefs_v1.<hash>`), so distinct keys that sanitize to the same segment (e.g. `app:prefs` / `app_prefs`) don't silently overwrite. + collision test + changeset note. (The `..`/`.` escape the same comment flagged was fixed in the prior harden pass.) - angular.ts — re-read `isHydrated()` inside the effect (effects run after the change-detection cycle; a hydration transition between signal creation and effect attach would leave the signal stale). - svelte.ts + README + architecture + svelte-hydration changeset — `>=5.0.0` → `>=5.7.0` (`createSubscriber` from `svelte/reactivity` landed in 5.7; web-confirmed). Package-wide peer stays `>=3.0.0` (shared with svelte-store — the package-level peer limitation remains in LEDGER § Deferred). Already fixed in the prior harden pass (4) — CodeRabbit auto-confirmed 3: - subpath-mirror-folders changeset (reframed to 4 real renames + 7 new). - roadmap framework-adapter row (generalized). - crosstab floating-promise (added `.catch`). - encrypted JSDoc overclaim (corrected — same finding as the harden Correctness reviewer; CodeRabbit's is the same issue, fixed in 131b213). Push-back (1) — hallucinated: - zustand/tanstack-store `subscribe` return: the comment claims tanstack-store returns a bare unsub fn, "mismatching the contract." `@tanstack/store` `subscribe` returns `Subscription` (`{ unsubscribe: () => void }`), which matches `PersistableSource.subscribe`. `tsgo --noEmit` passes. zustand returns `{ unsubscribe: unsub }` (correct). Verified: `check` + `check:pack` + `size` green; 201 pass / 0 fail. * refactor(test): dedupe 'imports only from core' check into src/testing helper Extract the per-adapter "imports only from core (no cross-adapter coupling)" self-check — repeated verbatim in 22 adapter test files — into `src/testing/assert-core-only-imports.ts` (`itImportsOnlyFromCore(sourceUrl)`). Each test now calls the helper instead of reimplementing the read-source → regex-relative-imports → assert-`../../core/`-prefix loop. Same pattern as the shipped `MemoryStorage` / `createMockSource` / `waitForHydration` test-fixture dedup. Test-only; not shipped in `dist/`. Also fixes a pre-existing `no-unused-expressions` warning in vue.test.ts (`hydratedRef!.value;` inside an effect — a deliberate Vue reactivity track — → `void hydratedRef!.value;`) that surfaced when the file first entered a lint-staged commit. Addresses the 2 CodeRabbit nitpicks (mmkv.test.ts + encrypted.test.ts — both the same finding: duplicate boundary-check logic across adapter tests). Verified: `check` + `check:pack` + `size` green; 201 pass / 0 fail. * harden: CodeRabbit nitpick triage — apply 9, push back 3, rest outdated Triage of the remaining CodeRabbit nitpicks + the assert-core-only-imports run (per pr-comment-fact-check): each claim verified against current code before acting. Applied (9 files): - docs/architecture.md: `persistQuery_client` → `persistQueryClient` (typo) - docs/glossary.md: crossTab entry now mentions the BroadcastChannel bridge (`createBroadcastCrossTab`) for backends that fire no storage events (IDB) - crosstab.ts: `1 as unknown as string` → `"1"` — stop laundering a number through `unknown`; persist-core only checks `=== null`, so a real string sentinel is type-honest and safe for any future `typeof newValue` consumer - ci.yml: `audit` job now gated on `skip-ci` (matches the other jobs — no wasted run on `changeset-release/*`); `persist-credentials: false` on all 9 checkout steps (zizmor artipacked, defense-in-depth, matches this PR's hardening goal) - .size-limit.json: add `encrypted`/`compressed`/`zod` entries — the gate claimed by this PR was covering 3 of 22 bundles (493B/433B/338B gzip, all well under the 2 KB limit) - node-fs.ts: getItem/removeItem catch narrowed to ENOENT — EACCES/EISDIR/ disk errors now surface (were silently masked as "no data"/no-op) - encrypted.test.ts: composed wrong-key hydrate test — pins that a backend getItem decrypt reject routes to onError phase "hydrate" and that `clearCorruptOnFailure` does NOT fire (codec-only path), so the blob stays. Uses `skipHydration` + `await rehydrate()` (not `waitForHydration`) because crypto.subtle settles on a macrotask that microtask polling never yields to - assert-core-only-imports.ts: extend the isolation regex to bare side-effect `import "…"` and dynamic `import("…")`; document the flat `adapters/<seam>/` depth assumption behind the `../../core/` prefix - valtio.ts + valtio.test.ts: `valtio` → `valtio/vanilla` for snapshot/subscribe (framework-agnostic entry; verified it exports both) + mock target updated Push back (hallucinated — evidence in code): - zustand.ts subscribe "returns bare fn / breaks contract" — the adapter already returns `{ unsubscribe: unsub }`; tsgo passes - zustand.test setState "fully replaces / missing replace: true" — the PersistableSource contract passes a function returning the FULL state; zustand function-setState with a full-state return is equivalent to replace - package.json "add a comment for the uuid override" — JSON doesn't support comments; the rationale lives in commit 07fdb5b Outdated (already fixed in prior harden commits — resolve pointing at them): subpath-mirror changeset reframe (131b213), roadmap framework row (a6e7ae2/131b213), README:403 registry (131b213/1208078), README:67 engine range (1208078), encrypted JSDoc overclaim (131b213), node-fs pathFor collisions/`..` (1208078), angular re-read isHydrated (1208078), svelte >=5.7 (1208078), crosstab floating promise (131b213), test-helper dedup (d66e00a), isolation-test dedup (ea9075e), compressed deflate-raw doc (1208078). Deferred (style/speculative, low ROI): angular/preact/solid test-mock improvements, mobx enforceActions:"always" real-mobx test (candidate for remaining-roi), encrypted/compressed base64 dedup (2 consumers < the 3+ bar in architecture-priming). Verified: `bun run check` + `check:pack` + `size` green; 202 pass / 0 fail. * harden: Tier 1 fixes from triangulated harden-pr audit Applies the 10 Tier 1 items from the triangulated harden-pr findings (docs/audits/2026-07-06-triangulated-harden-pr-findings.md) — all S, in-bounds, confirmed by ≥2 of 3 reviewers (composer-2.5-fast / GPT-5.5 / Opus) and re-verified against current code. @example / JSDoc corrections (shipped into published hovers): - encrypted.ts: move `clearCorruptOnFailure: true` from `persistStore(...)` into `createStorage(...)`'s 3rd arg — it's a CreateStorageOptions field, not PersistOptions; the copied snippet now typechecks and the flag is effective - svelte.ts + svelte-store.ts: invert the gate (`{#if !hydrated}` → Skeleton) — was rendering Skeleton when hydrated=true - zustand.ts: add the missing `createJSONStorage` import to the @example Runtime bug fix (new adapter, no cross-cutting change): - secure-store.ts: sanitize SecureStore keys (`[^\w.-]` → `_`) so colon-style persist names (`auth:token:v1`) don't throw `Invalid key` at runtime; same sanitization in get/set/remove keeps clearStorage() consistent. + regression test asserting `auth:token:v1` round-trips as `auth_token_v1` Doc / changeset / governance drift: - README "Choosing a storage": encrypted/compressed wrapper Cross-tab cell `inherits` → `✗` (the wrapper is `raw`, not `localStorage`, so native storage-event matching rejects; use ./transport/crosstab) + note under the table - .changeset/encrypted-compressed.md: correct the wrong-key decrypt path — backend getItem reject → onError phase "hydrate", NOT the codec's clearCorruptOnFailure (matches the encrypted.ts JSDoc fixed in 131b213) - audit 2026-07-04: un-✅ ROI #3 (API docs not published to Pages, .nojekyll not tracked); ✅ #20 (npm trusted publishing implemented) and #31 (migration-chain shipped) - remaining-roi.md: fix the ".nojekyll already present" claim (add at publish time); renumber item headings 7/8/9 → 6/7/8 (skipped #6) + Sequencing refs - hydration.ts:2: stale post-refold path `./frameworks/react` → `../adapters/frameworks/react` Also adds the three source harden-pr finding docs (composer / GPT-5.5 / Opus) and the triangulated summary that drove this commit. Verified: `bun run check` + `size` + `check:pack` green; 203 pass / 0 fail. * harden: Tier 2 core-engine bug fixes — clearStorage, hydrate-skipped writes, crosstab echo Applies the 3 Tier 2 items from the triangulated harden-pr audit (docs/audits/2026-07-06-triangulated-harden-pr-findings.md). Each is a confirmed narrow-trigger bug; the fix changes observable core/bridge runtime behavior but is a correctness fix, not a redesign. persist-core.ts: - clearStorage() now cancels any pending throttled write + bumps writeGeneration (supersedes in-flight retryWrite) before removeItem — previously a pending timer or retry loop resurrectated the key after the clear, defeating logout / registry.clearAll() wipes - a user setState dropped by the subscribe gate during an async hydrate is now re-scheduled after settle (was permanently skipped when no throttle timer was pending — silent data loss on async backends with no follow-up write). An `internalSetState` guard excludes the persist-core-driven merge so it isn't recorded as a user write to recover crosstab.ts: - wrap().removeItem probes presence before removing and only broadcasts a removal when the key was actually present — a no-op remove on an already- absent key (shared IDB/localStorage backend, another tab removed it) no longer broadcasts, breaking the skipPersist + onCrossTabRemove remove-echo loop Tests (4 new, all pin the bug): - clearStorage cancels a pending throttled write — key stays cleared - registry.clearAll cancels a pending throttled write (logout wipe survives) - a setState during an async hydrate is re-scheduled after settle - skipPersist + onCrossTabRemove: no remove-echo loop across tabs Verified: `bun run check` + `size` + `check:pack` green; 207 pass / 0 fail (core 2.56 kB gzip vs 3 kB limit). * harden: Tier 3 Cluster 1 — pin untested guards & defensive branches (9 tests) Applies Cluster 1 of the triangulated harden-pr audit (docs/audits/2026-07-06-triangulated-harden-pr-findings.md). Test-only — the source guards already exist; these pin them so a regression removing one fails CI. All S, in-bounds, no runtime change. - persist-core.test.ts: zero-dep gate now also scans hydration.ts (the other half of the `.` core entry) — closes the invariant gap for half the core; + createMigrationChain onOlder "throw" end-to-end (coverage symmetry with the discard + throwing-step e2e cases) - node-fs.test.ts: refuses keys that sanitize to `..`/`.`/empty (security — path-traversal-by-key guard now regression-pinned across get/set/remove) - crosstab.test.ts: a failed setItem/removeItem does not broadcast (pins the suppression side-chain); removeEventListener stops per-listener delivery (pins the handlerMap teardown path) - encrypted.test.ts: invalid-backend shape guard returns undefined; malformed ciphertext (missing `.` separator) rejects on decrypt (distinct from the already-tested wrong-key auth-tag reject) - compressed.test.ts: invalid-backend shape guard returns undefined; corrupt non-base64 payload rejects on decompress Verified: `bun run check` + `size` + `check:pack` green; 216 pass / 0 fail. * harden: Tier 3 Cluster 3 — toBase64 O(n), mobx runInAction, crosstab size gate, compress+encrypt test App…
Summary
localStorageas an object whosegetItem/setItem/removeItemareundefinedwithout a valid--localstorage-filepath. The lookup doesn't throw, so the broken backend passed availability and crashed inhydrateatstorage.getItem is not a functionduring SSR.createStoragenow shape-checks the three methods and returnsundefined, sopersistSource/persistStore/persistAtomcollapse to the no-opPersistApiinstead of throwing.createStorageunit tests (all-methods-undefined, one-method-missing) + onepersistSourceintegration test reproducing the SSR crash → no-op path.Changeset
.changeset/hot-badgers-run.md—@stainless-code/persistpatch.Commits
fix(persist-core): shape-check storage backend for defined-but-broken globalsstyle(persist-core): collapse return undefined to bare return— behavior-preserving; also tightens the changeset.Test plan
bun test src/persist-core.test.ts— 71/71 pass