Skip to content

fix(persist-core): shape-check storage backend; collapse to no-op on broken globals#4

Merged
SutuSebastian merged 2 commits into
mainfrom
fix/storage-shape-check
Jul 4, 2026
Merged

fix(persist-core): shape-check storage backend; collapse to no-op on broken globals#4
SutuSebastian merged 2 commits into
mainfrom
fix/storage-shape-check

Conversation

@SutuSebastian

@SutuSebastian SutuSebastian commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Summary

  • 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, so persistSource/persistStore/persistAtom collapse to the no-op PersistApi instead of throwing.
  • Two createStorage unit tests (all-methods-undefined, one-method-missing) + one persistSource integration test reproducing the SSR crash → no-op path.

Changeset

.changeset/hot-badgers-run.md@stainless-code/persist patch.

Commits

  • fix(persist-core): shape-check storage backend for defined-but-broken globals
  • style(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
  • pre-commit hooks green (format, lint, typecheck, paired test) on both commits
  • CI on PR

… 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-bot

changeset-bot Bot commented Jul 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3cc03ba

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@stainless-code/persist Patch

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

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds a runtime shape check to createStorage in src/persist-core.ts, returning undefined when a storage backend is present but missing callable getItem/setItem/removeItem methods, preventing crashes during SSR hydration. Tests and a changeset accompany the fix.

Changes

Storage Backend Validation Fix

Layer / File(s) Summary
Backend shape validation
src/persist-core.ts
createStorage now checks that getItem, setItem, and removeItem are callable functions, returning undefined if the backend is defined-but-broken.
Test coverage
src/persist-core.test.ts
Adds tests for createStorage returning undefined on broken/incomplete backends and a persistSource test verifying no-op fallback with error reporting when globalThis.localStorage is broken.
Changeset
.changeset/hot-badgers-run.md
Documents the patch release fixing backend shape validation behavior.

Estimated code review effort: 1 (Trivial) | ~5 minutes

Poem

A rabbit checked the storage door,
"Are you real, or just for show?"
No getItem? No dice, no more—
Fall back gently, undo the woe.
Tests confirm the hop is sound,
No crash where broken storage's found. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: validating the storage backend shape and falling back to a no-op on broken globals.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/storage-shape-check

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Behavior-preserving. Also tightens the changeset to lead with the fix.
@SutuSebastian SutuSebastian changed the title fix(persist-core): shape-check storage backend for defined-but-broken globals fix(persist-core): shape-check storage backend; collapse to no-op on broken globals Jul 4, 2026
@SutuSebastian SutuSebastian merged commit efc5614 into main Jul 4, 2026
10 checks passed
@SutuSebastian SutuSebastian deleted the fix/storage-shape-check branch July 4, 2026 10:46
@github-actions github-actions Bot mentioned this pull request Jul 4, 2026
SutuSebastian added a commit that referenced this pull request Jul 6, 2026
…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.
SutuSebastian added a commit that referenced this pull request Jul 6, 2026
…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…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant