From e5d5e5315dd02c376dd70132ac5118c155acf9c2 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sat, 4 Jul 2026 18:48:42 +0300 Subject: [PATCH 01/77] 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/audits/2026-07-04-docs-adapters-roi.md | 785 ++++++++++++++++++++ 1 file changed, 785 insertions(+) create mode 100644 docs/audits/2026-07-04-docs-adapters-roi.md diff --git a/docs/audits/2026-07-04-docs-adapters-roi.md b/docs/audits/2026-07-04-docs-adapters-roi.md new file mode 100644 index 0000000..041a2a4 --- /dev/null +++ b/docs/audits/2026-07-04-docs-adapters-roi.md @@ -0,0 +1,785 @@ +# Audit — Docs adequacy, adapter landscape, ROI action items + +**Date:** 2026-07-04 +**Method:** Four GLM 5.2 subagents read every file in the repo (core, adapters, docs, build/CI), then synthesized. +**Audited lanes:** [core capabilities](#core-capability-inventory) · [adapter landscape](#adapter-landscape) · [consumer docs](#consumer-docs-adequacy) · [build/ci/release](#build--tooling). + +--- + +## TL;DR answers + +**Are consumer docs enough to explain full capabilities?** **No.** The README is a dense wall-of-text reference doubling as landing page. The real consumer doc is `skills/tanstack-store/SKILL.md` (excellent, but lives inside the tarball, not the repo surface). The library's namesake concept — **"hydration-aware" — is never defined** anywhere in consumer prose. The headline use case that justifies the library (IndexedDB + async hydration gate) has **no end-to-end example**. Five exported capabilities are effectively hidden: `createPersistRegistry`/clear-all-on-logout, `partialize`/`merge`, `retryWrite`, `alwaysHydratedSignal`, and the generated `docs/api/` site (built, git-ignored, never linked from README). Sufficient for an experienced TanStack Store user willing to read JSDoc; **not** sufficient for 5-minute cold onboarding. + +**Enough examples?** **No.** Zero `examples/`/`demo/`/`playground/` directory exists. Only inline JSDoc `@example` blocks + README/skill snippets + tests. No runnable app demonstrates store+storage+codec+hydration wiring outside the test suite. ~12 README snippets / ~8 skill snippets, but **no example** for: IDB+React end-to-end, IDB+identity wired to a store, IDB cross-tab via BroadcastChannel, zustand/Redux via `persistSource`, `partialize`, `merge`, `registry`/clearAll, `throttleMs`, `maxAge`, `buster`, `retryWrite` (in prose), SSR/Next.js. + +**More adapters?** **Yes — high ROI.** The core is a zero-dep engine with **three clean seams already in place** (`StateStorage`, `StorageCodec`, `PersistableSource`) plus the `HydrationSignal` framework seam. Adding adapters is a one-line composition, not a feature request — so adapter ROI is unusually high. Brainstormed matrix below. + +--- + +## Core capability inventory + +52 distinct capabilities confirmed by the core audit (read `src/persist-core.ts` + `src/persist-core.test.ts` in full). Highlights: + +Two-axis `storage × codec` seam · structured-clone mode via `identityCodec` (Set/Map/Date survive without a codec) · trailing throttle with bypass for `skipPersist` removals · `destroy()` flushes pending throttled writes in `noRetry` mode · uncapped `retryWrite` that owns termination and covers post-migrate write-back · write-generation guard spanning throttle+retry+destroy+rehydrate · cross-tab `storageArea` identity guard with key-only fallback · `onCrossTabRemove` ownership primitive · `PersistRegistry` clear-all with `allSettled` + rethrow-first · `HydrationSignal` pull-model adapter contract (no initial notify, no payload, SSR renders hydrated) · `alwaysHydratedSignal()` collapses the no-persist ternary · `instanceof Promise` (same-realm) over thenable duck-typing · Node 22+ broken-`localStorage` shape guard · expiry-before-migrate ordering. + +Most of these are **only documented in JSDoc + tests**, not consumer prose. + +### Extension seams (interface shapes) + +- **Storage backend** — `StateStorage` (`src/persist-core.ts:20`): `getItem`/`setItem`/`removeItem`, sync or async (detected via `instanceof Promise`). +- **Codec** — `StorageCodec` (`src/persist-core.ts:74`): `encode`/`decode` of `StorageValue` ↔ wire type. +- **Reactive source** — `PersistableSource` (`src/persist-core.ts:357`): `getState`/`setState`/`subscribe`. +- **Framework hydration** — `HydrationSignal` (`src/hydration.ts:28`): `subscribeHydrated`/`isHydrated`, pull model. +- **Cross-tab event target** — `CrossTabEventTarget` (`src/persist-core.ts:113`). +- **Registry** — `PersistRegistry` (`src/persist-core.ts:369`). + +### Core gaps (no shipped impl, seam only) + +No IDB transactions · no `BroadcastChannel` transport shipped · no migration-chain helper · no compression/encryption shipped (seam only) · no multi-key batching · no key-namespacing helper · trailing-only throttle (no leading edge) · `retryWrite` uncapped by design (callback owns termination) · `setOptions` can't re-wire structural options (`registry`/`crossTab`/`crossTabEventTarget`) · no telemetry/observability beyond `onError` · no selective per-field hydrate · no SSR serialize-and-rehydrate (adapter's job). + +--- + +## Adapter landscape + +5 subpath entries today (`package.json:32-53`): `.` (zero-dep core) · `./seroval` (seroval codec) · `./idb` (idb-keyval, structured-clone mode) · `./tanstack-store` (`persistStore`/`persistAtom`) · `./react` (`useHydrated` only). + +Each optional peer is isolated behind its own subpath entry — importing the subpath IS the dep opt-in (enforced by `persist-idb.test.ts:175`). + +**React surface is just `useHydrated`.** No provider/context, no auto store binding, no auto-`destroy()` on unmount, no RN-specific entry. `use-hydrated.ts:22` JSDoc signals richer ergonomics are intentionally deferred to a higher-layer package. + +### Missing adapters — brainstormed + +**Storage:** sessionStorage (S/med) · OPFS (M/med) · Node fs (S/med) · memory (S/low, dedupes 3 test copies) · Redis (M/med) · SQLite WASM (L/med) · **expo-secure-store (S/high)** · **MMKV (S/high)** · **AsyncStorage (S/high)** · Chrome storage.area (S/med) · cookies (M/low) · Cloudflare KV/DO (M/med) · **BroadcastChannel bridge for IDB cross-tab (S/high)**. + +**Codecs:** **zod-validated (S/high)** · **encryption-at-rest via WebCrypto (M/high)** · MessagePack/cbor-x/CBOR (S/med) · compression via `CompressionStream` (M/med) · superjson/devalue (S/med) · structuredClone (S/low) · protobuf (L/low). + +**Framework:** **Solid `from(signal)` (S/high)** · **Vue `shallowRef`+watch (S/high)** · Svelte (S/med) · Preact (S/med) · Angular signals (S/med) · **TanStack Query persister bridge (M/high)** · **React provider/context/auto-binding (M/high)** · zustand/jotai/valtio/mobx/signals source adapters (S each, decide ship vs recipe). + +--- + +## Consumer docs adequacy + +### Documented vs hidden (per public export) + +| Export | Status | Where | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | -------------------------------- | +| `persistSource`, `createStorage`, `jsonCodec`, `identityCodec`, `persistStore`, `persistAtom`, `useHydrated`, `serovalCodec`, `createSerovalStorage`, `createIdbStorage`, `toHydrationSignal` | ✅ | README + skill | +| `PersistApi` full surface, `createJSONStorage`, `idbStateStorage`, `HydrationSignal`/`HydrationSource`, `UseHydratedResult` | 🟡 | partial / JSDoc only | +| **`createPersistRegistry` / `registry` / clear-all** | ❌ | exported, zero consumer mention | +| **`alwaysHydratedSignal`** | ❌ | JSDoc only | +| **`partialize` / `merge` / `onRehydrateStorage`** | ❌ in README | skill only | +| **generated `docs/api/` site** | ❌ | built, git-ignored, never linked | +| `retryWrite`, `onError`, `maxAge`, `buster`, `throttleMs`, `skipHydration` | 🟡 | mentioned in prose, no recipe | + +### Onboarding gaps (ranked) + +1. No "what is hydration / why does it flash" explainer — namesake concept undefined. +2. No complete IDB + React + `useHydrated` + `destroy()` walkthrough. +3. No full React component example in the README. +4. `createPersistRegistry` / clear-all invisible. +5. Generated API reference not linked. +6. `bun add` only — no npm/pnpm/yarn; engines supports Node ≥20.19. +7. TanStack intent opaque in README — five equal-weight subpaths, no "blessed path" signal. + +### Missing doc types + +Docs site (VitePress/Starlight) · Getting Started · Adapters catalog · Storage/codec decision matrices · Recipes section · Migration/porting guide from incumbents · Comparison table (zustand-persist / redux-persist / query-persist-client / pinia-persist) · Playground/StackBlitz · API reference link · Common-mistakes page · FAQ/troubleshooting. + +--- + +## Build & tooling + +**Strengths:** zero-dep core (test-enforced), correct optional peers, `sideEffects:false`, ESM-only tsdown build, deliberate bun/vitest split (bun unit + vitest/jsdom DOM), changeset release flow, packaged Agent Skills (`skills/` in `files`) intentional. + +**Gaps:** + +- **CI:** three workflows (ci/release/check-skills), single-env matrix. No preview deploy, no pkg-size diff, no bundle visualizer, no semver/export pack-validation (`attw`/`knip`/`publint`). +- **Release:** `release.yml` lacks npm provenance/signing and `id-token: write` permission. +- **Tests:** jsdom only — no real browser (Playwright/Safari), no SSR-framework (Next.js) test, no coverage gate, no integration tests combining multiple adapters. +- **Consumer DX:** no `packageManager` pin, no TS consumer range, no Node/React/TanStack version compat table, no bundle-size badge, no FAQ. +- **Examples:** no `examples/` workspace, no playground, no StackBlitz. + +--- + +## ROI-ordered action items + +Ordered by ROI = impact ÷ effort. **Effort:** S / M / L. + +### Tier 1 — Ship first (high impact, low effort) + +| # | Action | Effort | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | +| 1 | Define "hydration-aware" in the README — one paragraph + a "what flashes without it" diagram. Fixes the single biggest tone gap. | S | +| 2 | Add a complete IDB + React + `useHydrated` + `destroy()` walkthrough to the README. Closes the gap that justifies the library's existence. | S | +| 3 | Link the generated `docs/api/` site from the README + publish to GitHub Pages (`.nojekyll` already present). | S | +| 4 | Document `createPersistRegistry` + clear-all-on-logout with a recipe. | S | +| 5 | Add recipes for `partialize`, `merge`, `retryWrite`, `throttleMs`, `maxAge`, `buster` — six hidden powers, one short block each. | S | +| 6 | BroadcastChannel → `CrossTabEventTarget` bridge adapter for IDB cross-tab. Seam exists (`persist-core.ts:113`); IDB fires no `storage` events so `crossTab` is silently broken on IDB without it (`persist-idb.ts:62`, `skill:116`). | S | +| 7 | `expo-secure-store` / `react-native-mmkv` / `AsyncStorage` storage adapters (one subpath each). Unlocks an entire platform. | S each | +| 8 | `zod`-validated codec adapter — decode runs in existing corrupt-payload try/catch (`persist-core.ts:473`); validation errors map cleanly to `clearCorruptOnFailure`. | S | +| 9 | Solid + Vue framework hydration adapters — `HydrationSignal` JSDoc names both as targets (`hydration.ts:9-10`); each is a one-liner. | S each | + +### Tier 2 — Build out the surface (high impact, medium effort) + +| # | Action | Effort | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| 10 | Encryption-at-rest codec (WebCrypto + codec). Headline "custom codec" example in JSDoc (`persist-core.ts:69`, `persist-idb.ts:52`, `skill:155`) with no shipped impl. | M | +| 11 | `examples/` monorepo workspace — runnable `tanstack-idb-react`, `tanstack-localstorage-react`, `nextjs-ssr`, `react-native-mmkv`. No runnable demo today. | M | +| 12 | Docs site (VitePress / Astro Starlight) — split wall-of-text README into Getting Started → Adapters → Recipes → Adapter authoring → Reference; host `docs/api/` under it. | M | +| 13 | TanStack Query persister bridge (`persistQueryClient`-shaped). JSDoc cites Query as reference design (`persist-core.ts:12,263`). Flagship integration. | M | +| 14 | Migration/porting guide — option mapping + conceptual diff vs zustand-persist / redux-persist / query-persist-client / pinia-persist. | S | +| 15 | Comparison table across the 4 incumbents. README paragraph → table. | S | +| 16 | Storage & codec decision matrices — lift + expand the skill's 4-row version to consumer docs. | S | +| 17 | `CompressionStream` codec — native API, ~S now; pairs with binary `TRaw`. | M | +| 18 | Node `fs` storage adapter — trivial `StateStorage`; unblocks server/SSR/CLI. | S | +| 19 | Pack-validation + semver gate in CI — `attw --pack` + `knip` + `publint`. Prevents shipping a broken `exports` map. | S | + +### Tier 3 — Maturity & polish (medium impact, medium effort) + +| # | Action | Effort | +| --- | ------------------------------------------------------------------------------------------------------------------------------- | ------ | +| 20 | npm provenance + signing in `release.yml` (`id-token: write` + `--provenance`). | S | +| 21 | Test matrix — real browser (Playwright) + Safari + SSR-framework (Next.js hydration). Today jsdom only. | M | +| 22 | Coverage gate. Suite is excellent but unmeasured. | S | +| 23 | Bundle-size badge + `size-limit` gate; advertise zero-dep core. | S | +| 24 | `packageManager` pin + TS consumer range + Node/React/TanStack compat table. | S | +| 25 | `zustand`/`jotai`/`valtio`/`mobx`/signals source adapters — decide ship-as-subpath vs recipe first. | S each | +| 26 | Preact + Angular-signals hydration adapters. | S each | +| 27 | FAQ / troubleshooting page — lift from `skill:141` + "why does my UI flash" + "IDB cross-tab isn't syncing" + "quota exceeded". | S | + +### Tier 4 — Strategic bets (high impact, high effort) + +| # | Action | Effort | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| 28 | React ergonomics layer — `` + context + `usePersisted(store)` selector binding + auto-`destroy()`. `use-hydrated.ts:22` signals this is intentionally deferred. | M-L | +| 29 | StackBlitz / CodeSandbox playground embedded in docs site. | M | +| 30 | OPFS + SQLite-WASM + Cloudflare KV/Durable Objects storage adapters. | M-L | +| 31 | Migration-chain helper — `createMigrationChain({...})`; today `migrate` is a single callback, v0→v1→v2 chaining is user-written. | M | +| 32 | Continue the TanStack upstream pitch (`docs/plans/upstream-tanstack-pitch.md`) — adapter breadth (#13, #9) + docs polish (#1, #2, #12) are the leverage. Ongoing. | ongoing | + +--- + +## Recommended sequencing + +- **Week 1:** #1, #2, #3, #4, #5, #6, #9 — docs + BroadcastChannel + Solid/Vue. All S, mostly docs, unlock existing-but-hidden powers. +- **Week 2:** #7, #8, #10 — RN platform + encryption headline gap. +- **Week 3-4:** #11, #12, #14, #15, #16 — structural fix for wall-of-text README + zero-examples problem. +- **Ongoing:** #19, #20, #21, #23 — CI/release hygiene so the growing surface doesn't break consumers. +- **Strategic:** #28 (React ergonomics) + #32 (TanStack upstream) after the surface is documented and exemplified. + +--- + +## Audit agents + +Four GLM 5.2 subagents ran in parallel, one per lane. Resume any for a deeper drill: + +- [core capabilities](a4dcef3c-700e-4e3b-a222-7edf7311f017) +- [adapter landscape](9e6af533-7aa7-4d05-8e92-53ba25d25ee9) +- [consumer docs](b6cf6e7d-ac59-4c94-8d07-abbbb107c9c6) +- [build/CI](d0f5c223-e7eb-4495-b33d-2a55f0d1a43c) + +--- + +# Appendix A — Core capability inventory (full subagent report) + +Read-only audit of `src/persist-core.ts`, `src/persist-core.test.ts`, `src/hydration.ts`, `src/hydration.test.ts`, `src/index.ts`. All file:line refs point to `src/`. + +## A.1 Public API surface + +### `persist-core.ts` + +**Types / interfaces** + +| Export | Line | One-line | +| ----------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `StateStorage` | `20:24` | Minimal string-keyed storage backend (matches `localStorage`); generic `TRaw` lets structured-clone backends carry objects. | +| `StorageValue` | `27:41` | Envelope persisted per key: `state` + optional `version` / `timestamp` / `buster`. | +| `PersistStorage` | `49:64` | Encoded storage layer `persistSource` reads/writes; `getItem`/`setItem`/`removeItem` over `StorageValue` + optional `raw` for cross-tab area matching. | +| `StorageCodec` | `74:77` | Pure encode/decode of `StorageValue` ↔ backend wire type. | +| `JsonStorageOptions` | `80:83` | `JSON.parse` reviver / `JSON.stringify` replacer pass-through. | +| `CreateStorageOptions` | `85:93` | `clearCorruptOnFailure` flag — self-heal a corrupt payload by removing the key. | +| `CrossTabStorageEvent` | `101:105` | Structural `storage`-event shape (so non-DOM targets can dispatch fakes). | +| `CrossTabEventTarget` | `113:122` | Event-target seam for cross-tab (inject `BroadcastChannel`/fake). | +| `PersistOptions` | `124:309` | Full options bag (see capability table below for each field). | +| `PersistApi` | `314:350` | Lifecycle handle returned by `persistSource` (`setOptions`/`clearStorage`/`rehydrate`/`hasHydrated`/`onHydrate`/`onFinishHydration`/`getOptions`/`destroy`). | +| `PersistableSource` | `357:361` | Minimal reactive source shape (`getState`/`setState`/`subscribe`) — plug anything in. | +| `PersistRegistry` | `369:377` | Clear-callback registry: `register`/`clearAll`. | + +**Functions / values** + +| Export | Line | One-line | +| ----------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------- | +| `createPersistRegistry()` | `384:401` | Build a `PersistRegistry`; `clearAll` uses `allSettled` + rethrow-first-rejection. | +| `jsonCodec(options?)` | `407:412` | JSON `StorageCodec` (no `Set`/`Map`/`Date` round-trip); accepts reviver/replacer. | +| `identityCodec()` | `421:424` | Pass-through codec for structured-clone backends (`TRaw = StorageValue`). | +| `createStorage(getStorage, codec, options?)` | `444:511` | Backend×codec plumbing: try-guard availability, sync-vs-Promise branch, corrupt-payload handling; exposes `raw`. | +| `createJSONStorage(getStorage, options?)` | `521:526` | `createStorage(getStorage, jsonCodec(options))` convenience. | +| `persistSource(source, options)` | `586:1124` | Attach persist to any `PersistableSource`; returns `PersistApi` (or no-op API when storage unavailable). | + +### `hydration.ts` + +| Export | Line | One-line | +| --------------------------- | --------- | ------------------------------------------------------------------------------------------------ | +| `HydrationSignal` | `28:31` | `subscribeHydrated` + `isHydrated` — framework-agnostic external-store hydration signal. | +| `HydrationSource` | `38:42` | Minimal source shape (`hasHydrated`/`onHydrate`/`onFinishHydration`); `PersistApi` satisfies it. | +| `toHydrationSignal(source)` | `52:95` | Bridge a `HydrationSource` → `HydrationSignal`; null-tolerant (`null`→`null`). | +| `alwaysHydratedSignal()` | `103:108` | Always-hydrated signal for the no-persist path (uniform handle, no `null` branch at call site). | + +### `index.ts` + +Re-exports `./persist-core` + `./hydration` only. No barrel into optional peers. (`1:7`) + +## A.2 Capabilities (exhaustive, 52) + +| # | Capability | Where | Evidence | +| --- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | **Storage-backend abstraction** | `StateStorage` `20:24`, `createStorage` `444:511` | Any backend with `getItem`/`setItem`/`removeItem`; sync or async (Promise detected via `instanceof Promise`, not thenable). | +| 2 | **Codec / serialization seam (two-axis)** | `StorageCodec` `74:77`, `createStorage` `444:511` | Swap serialization (JSON / seroval / superjson / devalue / compression / encryption) independently of backend. | +| 3 | **Structured-clone backends (no string round-trip)** | `identityCodec` `421:424`, test `182:212` | `TRaw = StorageValue` carries objects natively (IndexedDB-friendly); `Set`/`Map`/`Date` survive. | +| 4 | **JSON default storage** | `resolveDefaultStorage` `570:579` | Default `createJSONStorage(() => localStorage)`; zero-dep by design. | +| 5 | **SSR / broken-backend guard** | `createStorage` `450:466`, `570:579`, tests `143:166`, `487:547` | `getStorage` throw → `undefined`; shape-check catches Node 22+ `localStorage` whose methods are `undefined`; no `window`/`localStorage` → no-op `PersistApi`. | +| 6 | **Corrupt-payload self-heal** | `parseStored` `468:489`, test `130:141` | `clearCorruptOnFailure` removes the key on a decode throw; default returns `null` (hydrate-to-nothing). | +| 7 | **Hydration lifecycle (awaitable, race-guarded)** | `hydrate` `982:1066`, `rehydrate` `1090` | `rehydrate()` returns a Promise that resolves after merge + finish listeners; `hydrationVersion` guard discards stale hydrates. | +| 8 | **Hydration listeners** | `onHydrate` `335`, `onFinishHydration` `337`, `beginHydration`/`settleHydration` `927:973` | Start + finish notifications; throwing finish listener contained (test `549:582`). | +| 9 | **`skipHydration`** (manual rehydrate) | option `175`, `1068:1070` | Skip the initial hydrate; caller drives `rehydrate()`. | +| 10 | **`partialize`** (selective field persistence) | option `138`, test `242:260` | Project `TState` → persisted slice; non-persisted field changes alone never write. | +| 11 | **`merge`** (hydrate combine) | option `170`, `shallowSpreadMerge` `532:538`, `applyResolvedState` `948:952` | Default shallow-spread persisted-over-current; custom merge supported; `persistAtom` overrides with replace. | +| 12 | **Schema versioning** | option `152`, `resolveStoredState` `900:922` | `version` stamped on writes; mismatch → `migrate`. | +| 13 | **Migration (`migrate`, sync or async)** | option `161`, `907:912`, tests `262:285`, `454:485` | Receives stored state + STORED version; returns new state or Promise. Throw → phase `"migrate"`. | +| 14 | **Post-migrate write-back** | `hydrate` `1034:1040`, tests `262:285`, `1423:1448` | One-shot, unthrottled write of migrated state with current version. | +| 15 | **`buster` cache-busting** | option `257`, `isStoredExpired` `887:895`, tests `1201:1257` | Mismatch discards + removes key; prefer over migrate when old values are simply wrong. | +| 16 | **`maxAge` expiry** | option `248`, `isStoredExpired` `887:895`, tests `1142:1199` | Payload older than `maxAge` (by `timestamp`; missing timestamp = expired) discarded + key removed. | +| 17 | **Expiry-before-migrate ordering** | `hydrate` `1018:1022`, test `1296:1321` | Expired data is never migrated. | +| 18 | **`timestamp` stamping on every write** | `buildEnvelope` `657:664`, test `1276:1294` | Always stamped (cheap, backward-compatible) — basis for `maxAge`; `buster` only when configured. | +| 19 | **`onRehydrateStorage` callback** | option `144`, `beginHydration` `927:932` | Pre-hydration callback; optional return invoked on settle with `(state, undefined)` or `(undefined, error)`. | +| 20 | **`onError` error sink (4 phases)** | option `193`, `reportError` `607:619` | Phases: `"write"` / `"hydrate"` / `"migrate"` / `"crossTab"`; dev-only `console.*` fallback (prod silent without sink). | +| 21 | **Error containment (never propagates to caller's setState)** | `writeSafe` `751:760`, `hydrate` catch `1049:1065`, tests `312:371` | Sync `setItem` throw / async reject / throwing migrate / throwing listener — all contained. | +| 22 | **`skipPersist` (conditional persistence)** | option `184`, `writeToStorage` `732:743`, test `662:680` | Evaluated against the partialized slice; when true → `removeItem` instead of write. | +| 23 | **Trailing throttle (`throttleMs`)** | option `273`, `scheduleWrite` `800:827`, tests `1342:1370` | Single `setTimeout`; coalesces burst into one trailing write with flush-time state; re-arms after flush. | +| 24 | **Throttle bypass for `skipPersist` removals** | `scheduleWrite` `805:809`, test `1399:1421` | Reset-to-default drops key immediately and cancels pending write. | +| 25 | **`destroy()` flushes pending throttled write** | `destroy` `1101:1120`, `flushPendingWrite` `779:794`, test `1372:1397` | One immediate attempt at teardown (noRetry mode) so no coalesced state is lost. | +| 26 | **`retryWrite` (uncapped shrink-and-retry)** | option `302`, `retryLoop` `673:707`, `writeGuarded` `714:730` | Callback gets `{state, error, errorCount}`; return smaller state to retry, `undefined` to give up (last error reported once); applies to subscribe-writes AND post-migrate write-back. | +| 27 | **Write-generation guard** | `writeGeneration` `652`, `scheduleWrite` `814`, `retryLoop` `682,694`, tests `1551:1656`, `1736:1775` | Newer `setState` / `destroy` / coalesced setState silently abandons in-flight retry loop; stale shrunk state never clobbers fresher state. | +| 28 | **Retry envelope rebuilt fresh per attempt** | `attemptWrite` `666:667`, `buildEnvelope` `657:664`, test `1884:1924` | New `timestamp`, current `version`/`buster` on every retry. | +| 29 | **Sync-path purity** | `writeGuarded` `714:730`, test `1862:1882` | Sync `setItem` failure with no `retryWrite` → reported synchronously, no promise hop. | +| 30 | **Cross-tab sync (`crossTab`)** | option `221`, listener `846:882`, tests `801:998` | Listens for `storage` events on `window` (or injected target); matches key + `storageArea` against `raw`; calls `rehydrate()`. No echo loops (browser never fires in originating tab; `hydrationVersion` dedupes overlapping). | +| 31 | **Cross-tab event-target injection** | option `228`, `CrossTabEventTarget` `113:122` | Inject `BroadcastChannel` bridge or fake (tests, non-DOM runtimes). | +| 32 | **Cross-tab `storageArea` identity guard w/ key-only fallback** | listener `856:863`, test `848:897` | `event.storageArea` compared to `PersistStorage.raw`; hand-rolled impls without `raw` fall back to key-only. | +| 33 | **`onCrossTabRemove` (removal-event ownership)** | option `240`, listener `869:876`, tests `1000:1132` | `newValue: null` events go to callback (rehydrate can't express "reset"); throws contained → phase `"crossTab"`. Without it → rehydrate fallback (documented keep-state divergence). | +| 34 | **Cross-tab listener attaches regardless of `skipHydration`** | `846:882`, test `899:928` | Manual rehydrate path still cross-tab-syncs. | +| 35 | **`destroy()` removes cross-tab listener** | `1104:1106`, test `930:959` | Clean teardown, no post-destroy rehydrate. | +| 36 | **`PersistRegistry` (logout-style clearAll)** | option `206`, `createPersistRegistry` `384:401`, tests `1999:2074` | Stores register `clearStorage`; `clearAll()` wipes every registered key; `allSettled` + rethrow-first-rejection; no ambient registry (opt-in only). | +| 37 | **Registry unregister on destroy (no leak)** | `1103`, tests `694:767` | Non-singleton stores created per mount cleanly unregister. | +| 38 | **`setOptions` (live option merge)** | `1078:1086` | Merge new options (explicit `undefined` ignored via `mergeDefined`); `storage` swappable; structural options (`registry`/`crossTab`/`crossTabEventTarget`) NOT re-wired. | +| 39 | **`mergeDefined` undefined-guard** | `545:553`, tests `1938:1985` | `{ merge: undefined }` can't clobber a default at create time or via `setOptions`. | +| 40 | **`clearStorage()`** | `1087:1089` | Removes the key; state stays in memory. | +| 41 | **`destroy()` full teardown** | `1101:1120` | Detaches source subscription, removes cross-tab listener, unregisters from registry, flushes pending throttled write (noRetry), cancels in-flight hydrate + retryWrite loop. | +| 42 | **`destroy()` cancels in-flight hydrate** | `1116`, test `584:608` | `hydrationVersion++` discards pending async `getItem`/`migrate` — no post-teardown setState. | +| 43 | **No-op `PersistApi` when storage unavailable** | `createNoopApi` `555:568`, tests `487:547` | Always-hydrated stub; reports `storage unavailable` to `onError` once. | +| 44 | **Re-schedule cancelled write post-rehydrate** | `hydrate` `992:993`, `1048`, test `1486:1509` | A pending throttled write cancelled by an incoming hydrate is re-scheduled so unpersisted state isn't stranded. | +| 45 | **Hydration signal for framework adapters** | `toHydrationSignal` `52:95` | Bridges `onHydrate`/`onFinishHydration` into one external-store subscribe target (React `useSyncExternalStore`, Svelte/Solid/Vue). | +| 46 | **Lazy signal attach/detach** | `66:91`, tests `281:310` | Source subscribed on first listener, torn down on last unsubscribe — no leak from recreated wrappers. | +| 47 | **Independent per-subscription wrappers** | `75:84`, test `214:234` | Same listener fn subscribed twice → two independent subs; either unsubscribe doesn't kill the other. | +| 48 | **Idempotent unsubscribe** | `84:90`, test `236:255` | Second call to a stale unsub doesn't re-trigger teardown. | +| 49 | **Pull-model signal (no initial notification, no payload)** | JSDoc `18:21`, tests `198:279` | Listeners never invoked on subscribe; (re)read `isHydrated()` after attaching; missed transitions recovered by snapshot re-read. | +| 50 | **`alwaysHydratedSignal()` (no-persist uniform handle)** | `103:108` | Drops the `persist ? toHydrationSignal(persist) : null` ternary. | +| 51 | **Zero-dep core (enforced by test)** | test `19:30` | `persist-core.ts` has no value imports; type-only imports only. | +| 52 | **Async-vs-sync detection via `instanceof Promise` (not thenable)** | JSDoc `13:18`, `createStorage` `494` | A stored value with a `.then` property is never mistaken for a pending read; same-realm native promises only. | + +## A.3 Extension points / seams (interface shapes) + +**Storage backend** — `StateStorage` (`20:24`): + +```ts +export interface StateStorage { + getItem: (name: string) => TRaw | null | Promise; + setItem: (name: string, value: TRaw) => void | Promise; + removeItem: (name: string) => void | Promise; +} +``` + +**Codec** — `StorageCodec` (`74:77`): + +```ts +export interface StorageCodec { + encode: (value: StorageValue) => TRaw; + decode: (raw: TRaw) => StorageValue; +} +``` + +**Encoded layer** (build with `createStorage` or hand-roll) — `PersistStorage` (`49:64`): + +```ts +export interface PersistStorage { + getItem: ( + name: string, + ) => StorageValue | null | Promise | null>; + setItem: (name: string, value: StorageValue) => void | Promise; + removeItem: (name: string) => void | Promise; + raw?: unknown; // cross-tab storageArea identity compare +} +``` + +**Reactive source** — `PersistableSource` (`357:361`): + +```ts +export interface PersistableSource { + getState: () => TState; + setState: (updater: (prev: TState) => TState) => void; + subscribe: (listener: () => void) => { unsubscribe: () => void }; +} +``` + +**Cross-tab event target** — `CrossTabEventTarget` (`113:122`): + +```ts +export interface CrossTabEventTarget { + addEventListener: ( + type: "storage", + listener: (event: CrossTabStorageEvent) => void, + ) => void; + removeEventListener: ( + type: "storage", + listener: (event: CrossTabStorageEvent) => void, + ) => void; +} +``` + +**Registry** — `PersistRegistry` (`369:377`): + +```ts +export interface PersistRegistry { + register: (clearStorage: () => void | Promise) => () => void; + clearAll: () => Promise; +} +``` + +**Hydration source for signal** — `HydrationSource` (`38:42`): + +```ts +export interface HydrationSource { + hasHydrated: () => boolean; + onHydrate: (listener: () => void) => () => void; + onFinishHydration: (listener: () => void) => () => void; +} +``` + +**Error sink signature** (`193:199`): `(error: unknown, ctx: { name; phase: "write"|"hydrate"|"migrate"|"crossTab" }) => void`. + +**`retryWrite` signature** (`302:308`): `(ctx: { state: TPersistedState; error: unknown; errorCount: number }) => TPersistedState | undefined | Promise`. + +**User-implemented option callbacks**: `partialize`, `merge`, `migrate`, `onRehydrateStorage`, `skipPersist`, `onCrossTabRemove`, `onError`, `retryWrite`. + +## A.4 Hidden / under-documented powers + +- **`PersistStorage.raw` identity compare** (`63`, `505:509`) — re-exposes the backend so cross-tab can match `event.storageArea`; hand-rolled impls silently fall back to key-only. Not obvious unless you read the cross-tab guard. +- **`identityCodec` for structured-clone backends** (`421:424`) — skips serialization entirely; only mentioned in a seroval/IndexedDB context. Powerful for any structured-clone store (in-memory `Map`s, IndexedDB). +- **`retryWrite` is uncapped and IS the termination policy** (`275:288`) — a callback that always returns a state spins forever; `errorCount` is the aggressiveness dial. Not surfaced as a "policy" in consumer docs. +- **`retryWrite` covers the post-migrate write-back** (`1034:1040`, test `1814:1860`) — not just subscribe-writes. +- **Write-generation guard spans throttle + retry + destroy + rehydrate** (`652`, `814`, `999`) — a coalesced `setState` _during the throttle window_ supersedes an in-flight retry loop at schedule time, not flush time. Subtle correctness invariant. +- **Re-scheduling of a cancelled throttled write after rehydrate** (`1048`, test `1486:1509`) — prevents stranded unpersisted state. Easy to miss. +- **`destroy()` teardown flush uses `noRetry` mode** (`779:794`, `1112`) — bypasses retry loop so a teardown-flush failure reaches `onError` instead of being silently abandoned by the generation bump. +- **`mergeDefined` protects against `merge: undefined`** (`545:553`) — both at create and `setOptions`; matters for `persistAtom`'s replace-merge default. +- **Sync-path purity** (`714:730`) — no promise allocation when the first attempt succeeds or fails without `retryWrite`. Performance detail unlikely in docs. +- **`instanceof Promise` (same-realm) over thenable duck-typing** (`13:18`, `494`) — deliberately avoids misclassifying stored values carrying a `then` property. +- **Node 22+ broken-`localStorage` shape check** (`456:466`) — handles the global-exists-but-methods-undefined case the `typeof` guard misses. +- **`HydrationSignal` pull model + lazy attach/detach + fresh-wrapper-per-sub** (`66:91`) — the full adapter contract (independent subs for same fn, idempotent unsub, snapshot re-read recovers missed transitions). Adapter authors need this but it's deep in JSDoc. +- **`alwaysHydratedSignal()` collapses the conditional ternary** — uniform handle drops `persist ? toHydrationSignal(persist) : null`. +- **Cross-tab `storageArea` guard prevents echo across different storage areas** (`856:863`) — same key in `sessionStorage` vs `localStorage` won't cross-fire. +- **`crossTab` listener attaches even with `skipHydration`** (`846:882`) — manual-rehydrate users still get cross-tab. +- **Expiry runs before migrate** (`1018:1022`) — expired data is never migrated; not obvious from option names. +- **`maxAge`/`buster` missing-on-payload = expired/mismatch** (`887:895`, tests `1182:1199`, `1240:1257`) — payloads written by other persist impls (no `timestamp`/`buster`) count as expired when those options are configured. +- **Dev-only `console.error`/`console.warn` fallback is `process.env.NODE_ENV`-gated** (`613`, `628`) — bundler-replaceable; prod tree-shakes it. Prod without `onError` is silent by design. + +## A.5 Gaps / limitations + +- **No IndexedDB integration in core** — only the seam (`StateStorage` + `identityCodec`). Actual IDB lives in the `./persist-idb` subpath (idb-keyval peer). No transaction batching, no key-range cursors, no IDB-specific error mapping in core. +- **No `BroadcastChannel` transport shipped** — only the `CrossTabEventTarget` seam. Default is `window` `storage` events (same-origin only, no large-payload guarantee). A `BroadcastChannel` bridge is user-implemented. +- **No schema-migration helper / migration chain** — `migrate` is a single callback receiving the stored version; multi-step v0→v1→v2 chaining is user-written. +- **No compression** — pluggable via `StorageCodec` (encode/decode), but nothing shipped. +- **No encryption** — same: codec seam only, nothing shipped. +- **No batching of multiple keys** — one key per `persistSource`; no atomic multi-key write, no `clearAll`-across-stores transaction (registry `clearAll` is best-effort `allSettled`). +- **No key namespacing helper** — `name` is a raw string; no built-in prefix convention. +- **Trailing-only throttle (no leading edge)** — first write waits out the window; explicit trade-off vs TanStack Query's leading+trailing (JSDoc `262:266`). +- **`retryWrite` is uncapped by design** — no built-in max-attempts / backoff; the callback owns termination (can spin forever). +- **`setOptions` cannot re-wire structural options** — `registry`, `crossTab`, `crossTabEventTarget` set at create time only (JSDoc `316:320`). +- **No selective rehydrate / per-field hydrate** — `merge` is the only knob; no field-level hydration hooks. +- **No built-in cross-tab payload diff** — full rehydrate on every matching `storage` event; no "only changed fields" optimization. +- **No built-in size/quota introspection** — `retryWrite` reacts to failures but the core never probes quota. +- **No SSR serialize-and-rehydrate** — server-side renders `hydrated = true` (no storage); no mechanism to ship server state to client for first-paint hydration (adapter's job via `useHydrated` server snapshot). +- **Async detection limited to native same-realm `Promise`** — cross-realm or custom thenable backends aren't supported (`13:18`). +- **Cross-tab `onCrossTabRemove` is the only removal primitive** — no symmetric "another tab wrote" callback distinct from `rehydrate()`; consumers must derive intent from a fresh read. +- **No telemetry / observability hooks beyond `onError`** — no write-success, hydrate-success, or retry-attempt events (only start/finish hydration listeners + `onError`). +- **`partialize` runs on every `setState`** — no memoization hook for expensive projections (consumer's responsibility). +- **No built-in devtools / time-travel** — `setOptions` + `rehydrate` are the only runtime knobs. + +### Notes for docs-adequacy / ROI analysis + +- The JSDoc on `PersistOptions` is exceptionally thorough (every invariant, every trade-off) — but several runtime invariants (write-generation guard, re-schedule-after-rehydrate, `noRetry` teardown flush, `instanceof Promise` choice, Node-22 shape check) live only in code comments + tests, not in any consumer-facing README. +- The `HydrationSignal` adapter contract is fully specified in `hydration.ts` JSDoc but is exactly the kind of thing adapter authors need promoted to top-level docs. +- Gaps are mostly "ship more codecs/transports" (compression, encryption, `BroadcastChannel`, IDB transactions) — all have clean seams already in place, so ROI on adding them is high (no core rework needed). + +--- + +# Appendix B — Adapter landscape (full subagent report) + +## B.1 Current adapters / entry points + +The `exports` map (`package.json:32-53`) ships 5 subpath entries. Each optional peer is isolated behind its own subpath entry — importing the subpath IS the dep opt-in (enforced by a test at `persist-idb.test.ts:175-199`). + +### `.` (the core entry) + +- **Provides:** re-exports `persist-core` + `hydration` (`src/index.ts:5-6`). Zero-dep. +- **Implements:** the canonical adapter contracts + `persistSource` engine + `createJSONStorage` / `createStorage` / `jsonCodec` / `identityCodec` / `createPersistRegistry` + `toHydrationSignal` / `alwaysHydratedSignal`. +- **Deps:** none. `peerDependenciesMeta` marks everything optional (`package.json:116-129`). +- **Consumer meaning:** the framework-agnostic persistence middleware. Bring your own reactive source via `persistSource`, or wire a framework adapter on top. This is also the entry non-TanStack users (zustand/Redux/hand-rolled atom) use — `persistSource` is the universal seam. + +### `./seroval` + +- **Provides:** `serovalCodec` + `createSerovalStorage` (`persist-seroval.ts:21,37`). +- **Implements:** `StorageCodec` from persist-core (`persist-seroval.ts:9`). +- **Deps:** `seroval` (optional peer, `>=1.0.0`). +- **Consumer meaning:** a drop-in codec so `Set` / `Map` / `Date` round-trip through any _string-keyed_ backend (`localStorage`, `sessionStorage`, custom). One factory: `createSerovalStorage(() => localStorage)` (`persist-seroval.ts:31-35`). The codec is also exposed standalone so it composes over `createStorage` for non-default backends (`persist-seroval.test.ts:159-180`). + +### `./idb` + +- **Provides:** `idbStateStorage` + `createIdbStorage` (`persist-idb.ts:34,74`). +- **Implements:** `StateStorage` (`persist-idb.ts:11`) with `TRaw = StorageValue` — the **structured-clone mode** that the generic wire-type seam enables. +- **Deps:** `idb-keyval` (optional peer, `>=4.0.0`). +- **Consumer meaning:** IndexedDB-backed persistence that stores the `StorageValue` envelope _natively_ — `Set`/`Map`/`Date` round-trip via structured clone with **no codec at all** (`identityCodec`), better DevTools inspection (objects, not encoded strings). Codec use cases (encryption/compression) compose as a one-liner over `idbStateStorage` + `createStorage` (`persist-idb.ts:52-58`). Custom idb-keyval `store` for namespacing (`persist-idb.ts:16-23`). + +### `./tanstack-store` + +- **Provides:** `persistStore` + `persistAtom` (`persist-tanstack.ts:24,56`). +- **Implements:** thin wrappers that supply the `PersistableSource` shape (`persist-core.ts:357-361`) to `persistSource`. +- **Deps:** `@tanstack/store` (optional peer, `>=0.10.0`); types only (`persist-tanstack.ts:4`). +- **Consumer meaning:** the only shipped reactive-source adapters. `persistStore` wraps a `Store` (action-bearing via `StoreActionMap`); `persistAtom` wraps a writable `Atom` and overrides default `merge` to **replace** (not shallow-spread) so primitive atom values aren't corrupted (`persist-tanstack.ts:74-80`), and throws on readonly atoms (`persist-tanstack.ts:60-62`). + +### `./react` + +- **Provides:** `useHydrated` (`use-hydrated.ts:37`). +- **Implements:** mounts a `HydrationSignal` (`hydration.ts:28-31`) into React via `useSyncExternalStore`. +- **Deps:** `react` (optional peer, `^18.0.0 || ^19.0.0`). +- **Consumer meaning:** the _only_ React surface. Returns `{ hydrated }` to gate the hydrate flash on async backends (IndexedDB). State reads stay on the store (`useSelector`), not this hook (`use-hydrated.ts:7-10`). Null signal → `hydrated: true`. Server snapshot is always `true` (`use-hydrated.ts:42`). + +## B.2 Adapter pattern + +Three orthogonal seams, all in `persist-core.ts` / `hydration.ts`. An "adapter" picks exactly one seam to extend; it never reimplements the `persistSource` plumbing. + +### Seam A — storage backend: `StateStorage` + +```src/persist-core.ts:20:24 +export interface StateStorage { + getItem: (name: string) => TRaw | null | Promise; + setItem: (name: string, value: TRaw) => void | Promise; + removeItem: (name: string) => void | Promise; +} +``` + +Sync or async (detected via `instanceof Promise`, _not_ thenable duck-typing — `persist-core.ts:14-19`). A new backend adapter either hand-rolls these three methods, or wraps an existing backend (like `idbStateStorage` wraps idb-keyval, `persist-idb.ts:37-42`) and passes it to `createStorage`. + +### Seam B — codec: `StorageCodec` + +```src/persist-core.ts:74:77 +export interface StorageCodec { + encode: (value: StorageValue) => TRaw; + decode: (raw: TRaw) => StorageValue; +} +``` + +A new codec plugs into `createStorage(getStorage, codec, options)` (`persist-core.ts:444-448`) — that's the entire integration surface. `serovalCodec` (`persist-seroval.ts:21-24`) is the reference. + +### Seam C — reactive source: `PersistableSource` + +```src/persist-core.ts:357:361 +export interface PersistableSource { + getState: () => TState; + setState: (updater: (prev: TState) => TState) => void; + subscribe: (listener: () => void) => { unsubscribe: () => void }; +} +``` + +A new framework/store adapter constructs this shape and calls `persistSource(source, opts)`. `persistStore`/`persistAtom` (`persist-tanstack.ts:28-38`, `64-72`) are the reference. + +### Seam D — framework hydration: `HydrationSignal` + +```src/hydration.ts:28:31 +export interface HydrationSignal { + subscribeHydrated: (listener: () => void) => () => void; + isHydrated: () => boolean; +} +``` + +Adapter contract (`hydration.ts:14-27`): multiple concurrent subscribers, idempotent unsubscribe, **no** initial notification, **no** payload (pull model), SSR renders `hydrated: true`, `null` signal → hydrated. `useHydrated` is the reference implementation (`use-hydrated.ts:37-44`). + +**Adapter contract checklist for a new adapter:** + +1. Own your dep — ship a new subpath entry, mark it optional in `peerDependenciesMeta`, never import it from core/other entries (the isolation test pattern at `persist-idb.test.ts:175-199`). +2. Map onto exactly one seam (A/B/C/D); compose via the exposed factory (`createStorage` / `persistSource` / `toHydrationSignal`), never reimplement the engine. +3. For framework adapters, implement the SSR policy (`getServerSnapshot` returns `true`) in the adapter, not the signal (`hydration.ts:22-25`). + +## B.3 Missing adapters + +### Storage backends + +| Adapter | Effort | Demand | Justification | +| ---------------------------------------------------------------- | ------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **sessionStorage** wrapper (`createSessionStorage` / doc recipe) | S | med | Already works today via `createJSONStorage(() => sessionStorage)` — but no named factory or SKILL entry. Cross-tab caveat is documented (`skills/tanstack-store/SKILL.md:116`). Low payoff; mostly a DX/ discoverability gap. | +| **OPFS** (Origin Private File System) | M | med | High-volume structured state in browsers; async, file-backed. Fits `StateStorage` naturally. Growing demand as localStorage hits quota. | +| **Node fs / file** | S | med | Server/SSR/CLI persistence. Trivial `StateStorage` over `fs.readFileSync`/`writeFileSync` (or async). Unblocks Node usage entirely — currently the lib is browser-flavored. | +| **memory** (test/fixture storage) | S | low | Already hand-rolled in every test file (`persist-seroval.test.ts:7-25`, `persist-tanstack.test.ts:10-28`, `use-hydrated.test.ts:25-39`). Shipping one would dedupe ~3 copies. | +| **Redis** | M | med | Server-side persistent state; async. Pairs with Node fs entry for a real backend story. | +| **SQLite WASM** (wa-sqlite / sqlite-wasm) | L | med | Structured-clone mode like IDB; powerful but heavy. Better as a community recipe than a shipped peer. | +| **expo-secure-store** | S | high | React Native secure persistence is a top requested feature for any persist lib; small surface. | +| **MMKV** (react-native-mmkv) | S | high | RN default fast KV; synchronous, drops straight into `StateStorage`. Very high RN demand signal. | +| **AsyncStorage** (RN) | S | high | Legacy RN fallback; still widely used. | +| **Chrome `storage.area`** (`local`/`sync`/`session`) | S | med | Extension developers — `localStorage` is forbidden in MV3 service workers. Strong niche demand. | +| **cookies** | M | low | Server-rendered hydration story; awkward (size limits, HTTP coupling). Better as recipe. | +| **Cloudflare KV / Durable Objects** | M | med | Edge runtime persistence; async `StateStorage`. Growing with Workers adoption. | +| **BroadcastChannel bridge** for IDB cross-tab | S | high | Explicitly called out as missing (`persist-idb.ts:62-64`, `skills/tanstack-store/SKILL.md:116`) — IDB fires no `storage` events, so `crossTab` is broken on IDB without a `crossTabEventTarget` bridge. The seam exists (`CrossTabEventTarget`, `persist-core.ts:113-122`); only the adapter doesn't. | + +### Codecs + +| Codec | Effort | Demand | Justification | +| ------------------------------------------------------------ | ------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **JSON** | — | — | Already default (`jsonCodec`, `persist-core.ts:407-412`). | +| **seroval** | — | — | Shipped. | +| **structuredClone** codec | S | low | Largely subsumed by IDB identity mode; marginal value as a codec. | +| **MessagePack / cbor-x / CBOR** | S | med | Compact binary wire format; `cbor-x` is very fast. Drops into `StorageCodec` — needs `TRaw` plumbing already in the seam. | +| **zod-validated encode/decode** | S | high | Schema-gated persistence is a frequently-requested feature; `decode` runs in the existing try/catch corrupt-payload path (`persist-core.ts:473-488`), so validation errors map cleanly to `clearCorruptOnFailure`. | +| **protobuf** | L | low | Strongly-typed but heavy toolchain; better as recipe. | +| **encryption-at-rest** (WebCrypto + codec) | M | high | Explicitly framed as the canonical "custom codec" use case (`persist-core.ts:69-70`, `persist-idb.ts:52-58`, `skills/tanstack-store/SKILL.md:155`). No shipped adapter despite being the headline composition example. | +| **compression** (gzip/brotli via WASM / `CompressionStream`) | M | med | Native `CompressionStream` makes this a ~S now; pairs with binary `TRaw`. | +| **superjson / devalue** | S | med | Already name-dropped as drop-ins (`persist-core.ts:69`, `persist-core.ts:437-440`); seroval covers the same niche so demand is partial. | +| **immutable-hamt** | L | low | Niche; structural sharing for huge state. Better as external lib. | + +### Framework integrations + +| Adapter | Effort | Demand | Justification | +| ------------------------------------------------------------- | ------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **React `useHydrated`** | — | — | Shipped. | +| **Solid** `from(signal)` adapter | S | high | The `HydrationSignal` contract (`hydration.ts:8-10`) explicitly names Solid `from` as a target. Trivial one-liner; high Solid demand. | +| **Vue** (`shallowRef` + watch) | S | high | Also explicitly named in the signal JSDoc (`hydration.ts:10`). | +| **Svelte** (`createSubscriber` / readable store) | S | med | Also named (`hydration.ts:9`). | +| **Preact** | S | med | `useSyncExternalStore` compatible — near-clone of React adapter. | +| **Angular signals** | S | med | `signal` + `effect`-based hydration gate; growing signals userbase. | +| **TanStack Query** persister bridge | M | high | Natural cross-sell (the JSDoc repeatedly cites TanStack Query as the reference design, e.g. `persist-core.ts:12`, `persist-core.ts:263-264`, `persist-core.ts:88`). A `persistQueryClient`-shaped adapter would be a flagship integration. | +| **React provider/context/auto-binding** | M | high | See §B.5 — React users get only a hook, no ergonomics layer. | +| **Zustand / Jotai / Valtio / MobX / signals** source adapters | S each | med | All reduce to `PersistableSource` (the skill explicitly says "pass a custom implementation to persist anything else", `skills/tanstack-store/SKILL.md:135-139`). Each is ~10 lines; the question is whether to ship them or keep them as recipes. | + +**Highest-leverage gaps (rough ranking):** MMKV/AsyncStorage/expo-secure-store (RN block), BroadcastChannel IDB cross-tab bridge (completes a documented-but-missing feature), encryption-at-rest codec (the headline example with no implementation), zod-validated codec, Solid/Vue framework adapters, TanStack Query bridge. + +## B.4 Examples inventory + +**None.** No `examples/`, `example/`, `demo/`, `playground/`, `snippets/`, or `sandboxes/` directory exists (Glob returned 0). The only runnable artefacts beyond `src/*.test.ts` and `tests-dom/*.test.tsx` are: + +- `skills/tanstack-store/SKILL.md` — the single shipped skill (the `skills` dir contains only `tanstack-store/`, confirmed by `ls`). +- Inline `@example` JSDoc blocks in each adapter module (`persist-idb.ts:67-72`, `persist-seroval.ts:29-35`, `persist-tanstack.ts:13-22`, `use-hydrated.ts:25-35`). +- `README.md` and `docs/architecture.md` prose snippets. + +The `package.json:25-29` `files` array ships `dist` + `skills` only — no examples are published either. There is no end-to-end runnable app demonstrating wiring (store + storage + codec + hydration gate) outside the test suite. + +## B.5 `./react` entry nuance + +`useHydrated` is the **entire** React surface. Confirmed: `exports` maps `./react` → only `./dist/use-hydrated.{d.,}mts` (`package.json:49-52`), and `use-hydrated.ts` exports one function (`use-hydrated.ts:37`). + +**What React users do NOT get:** + +- **No provider / context.** No ``, no React context, no Devtools. Each component manually threads a `HydrationSignal` to `useHydrated` (`use-hydrated.ts:32-34`). +- **No automatic store binding.** The hook returns _only_ `hydrated` (`use-hydrated.ts:5-11`); state reads are explicitly the caller's job via `useSelector` from `@tanstack/store` (the JSDoc is emphatic: "Returns ONLY `hydrated` — state reads go through `useSelector`", `use-hydrated.ts:18-19`). There is no `usePersisted(store)` or selector-binding helper. +- **No `persistStore`-aware React hook.** The TanStack adapter (`./tanstack-store`) and the React adapter (`./react`) are decoupled — a React user imports `persistStore` from one subpath, `toHydrationSignal` from `.` core, and `useHydrated` from another, wiring them by hand (the canonical 3-line recipe at `skills/tanstack-store/SKILL.md:75-82`). +- **No automatic `destroy()` on unmount.** Teardown is manual — the skill documents the `useEffect` cleanup pattern as user responsibility (`skills/tanstack-store/SKILL.md:96-101`). +- **No hydration-aware ``/`use()` integration, no `useSyncExternalStore` selector helper, no SSR helper beyond the implicit server-snapshot policy.** +- **No React Native-specific entry** (no MMKV/AsyncStorage/expo-secure-store wiring — see §B.3). + +The design is deliberately minimal — `use-hydrated.ts:22-24` states the hook is "the reference" implementation of the framework-agnostic `HydrationSignal` adapter contract, signaling that richer React ergonomics (provider, auto-binding, Devtools) are intentionally left to consumers or a future higher-layer package. + +--- + +# Appendix C — Consumer docs adequacy (full subagent report) + +## C.1 Doc inventory + +| Doc | Audience | Quality (1–2 sentences) | +| --------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `README.md` (root) | **Consumers** — primary landing page | Strong. Install + quick start + a dense "Extensibility guide" with entry table, three-seam breakdown, recipes, framework-adapter sketch, lifecycle paragraph. Dense to the point of being a wall of text; no table of contents, no progressive disclosure. | +| `docs/README.md` | Maintainers | Thin index. Explicitly says consumer doc is the root README; this folder is "maintainer-facing reference." | +| `docs/architecture.md` | Maintainers | Good maintainer reference for seams, hydration lifecycle, sync/async, test matrix, publishing/API-docs policy. Not consumer-prose — assumes familiarity. | +| `docs/glossary.md` | Maintainers | Excellent ubiquitous-language table. Useful to consumers too, but not linked from README. | +| `docs/roadmap.md` | Maintainers | Forward-looking only; explicitly "not a mirror of src/". Fine. | +| `docs/plans/upstream-tanstack-pitch.md` | Maintainers / TanStack maintainers | A pitch draft, not consumer doc. Good context but irrelevant to a new user. | +| `.github/CONTRIBUTING.md` | Contributors | Dev workflow, hooks, releases, agent rules. Solid. | +| `skills/tanstack-store/SKILL.md` | **Consumers via TanStack Intent** (packaged in tarball) | The single best consumer doc in the repo — wiring, `persistAtom` vs `persistStore`, hydration gate, throttle, teardown, cross-tab, migrate, mistakes, backend×codec matrix, full options/API surface. | +| `typedoc.json` | Tooling config | TypeDoc over 5 entry points → `docs/api/` (git-ignored). `treatWarningsAsErrors`, `invalidLink` gated. | +| `.changeset/README.md` | Contributors | Boilerplate + pointer to CONTRIBUTING releases. Fine. | +| `CHANGELOG.md` | Consumers (release notes) | Two entries (0.1.0, 0.1.1). Adequate. | +| `docs/api/` | Consumers (generated reference) | Generated TypeDoc HTML site (`index.html`, `modules/`, `interfaces/`, `types/`, `hierarchy.html`). **Git-ignored** — so not in the repo, and **not linked from the README**. | + +## C.2 Capabilities: documented vs hidden + +### `@stainless-code/persist` (core) + +| Export | Status | Where | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `persistSource` | ✅ | README:117, skill:131–139 | +| `PersistApi` (interface: `rehydrate`, `hasHydrated`, `onHydrate`, `onFinishHydration`, `setOptions`, `clearStorage`, `getOptions`, `destroy`) | 🟡 | Lifecycle paragraph README:183 mentions `rehydrate`/`destroy`/`onError`; full surface only enumerated in skill:164. README never lists `setOptions`/`getOptions`/`clearStorage`/`onHydrate`/`onFinishHydration`. | +| `createStorage` | ✅ | README:120, 125, 131 | +| `createJSONStorage` | 🟡 | Only appears in README:75 inside a backend example; never explained as a public factory. | +| `jsonCodec` | ✅ | README:95 | +| `identityCodec` | ✅ | README:97, skill:144 | +| `registry` / `createPersistRegistry` / `PersistRegistry` | ❌ | `createPersistRegistry` is exported in `persist-core.ts:384` but **not mentioned anywhere in consumer docs**. The skill:163 lists `registry` as an option and skill:166 mentions `registry.clearAll()` once, but there is no example, no "clear-all-on-logout" recipe, no link to `createPersistRegistry`. | +| `HydrationSignal` / `toHydrationSignal` / `alwaysHydratedSignal` / `HydrationSource` | 🟡 | `toHydrationSignal` in quick-start README:40 and skill:79. `HydrationSignal` named in adapter section README:156. **`alwaysHydratedSignal` is undocumented** in any consumer doc — only in JSDoc. `HydrationSource` not mentioned. | +| Types: `StateStorage`, `StorageValue`, `PersistStorage`, `StorageCodec`, `JsonStorageOptions`, `CreateStorageOptions`, `CrossTabStorageEvent`, `CrossTabEventTarget`, `PersistOptions`, `PersistableSource` | 🟡 | `StateStorage`/`StorageCodec`/`PersistableSource` named in README:70–106. `CrossTabEventTarget` mentioned README:149. `StorageValue`, `PersistStorage`, `CreateStorageOptions`, `JsonStorageOptions`, `CrossTabStorageEvent` not surfaced in prose (only JSDoc + typedoc). | + +### `@stainless-code/persist/seroval` + +| Export | Status | Where | +| ---------------------- | ------ | -------------------------------- | +| `serovalCodec` | ✅ | README:96 | +| `createSerovalStorage` | ✅ | README quick-start:30, README:77 | + +### `@stainless-code/persist/idb` + +| Export | Status | Where | +| ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `idbStateStorage` | 🟡 | README:93, 131, 136 — appears in recipes but its async/cross-tab caveats are buried; `skill:116` mentions BroadcastChannel bridge. Never given a standalone "this is the raw backend" explainer. | +| `createIdbStorage` | ✅ | README:79, 139 | + +### `@stainless-code/persist/tanstack-store` + +| Export | Status | Where | +| -------------- | ------ | ------------------------- | +| `persistStore` | ✅ | README quick-start, skill | +| `persistAtom` | ✅ | README:111, skill:52–66 | + +### `@stainless-code/persist/react` + +| Export | Status | Where | +| ------------------- | ------ | --------------------------------------------- | +| `useHydrated` | ✅ | README quick-start:43, skill:81 | +| `UseHydratedResult` | 🟡 | Type only; `hydrated` field shown in example. | + +### Options on `PersistOptions` (the real capability surface) + +Documented in skill:163 list, but in the **README** only a subset is shown in prose/recipes: + +| Option | README | Skill | +| ----------------------------------------------------- | ---------------------------------- | --------------- | +| `name`, `storage`, `version`, `migrate` | ✅ | ✅ | +| `partialize`, `merge`, `onRehydrateStorage` | ❌ in README | ✅ | +| `skipHydration`, `skipPersist` | 🟡 (`skipHydration` README:183) | ✅ | +| `crossTab`, `crossTabEventTarget`, `onCrossTabRemove` | ✅ README:142–149 | ✅ | +| `maxAge`, `buster` | 🟡 (mentioned README:183) | ✅ | +| `throttleMs` | 🟡 (README:183) | ✅ | +| `retryWrite` | 🟡 (README:183, JSDoc has example) | ❌ not in skill | +| `onError` | 🟡 (README:183) | ❌ not in skill | +| `registry` | ❌ | 🟡 | + +**Biggest hidden capabilities:** + +- **`createPersistRegistry` + `registry` clear-all-on-logout** — fully undocumented in README; the only "logout wipes everything" path is invisible to a new reader. +- **`alwaysHydratedSignal`** — exported, undocumented in prose. +- **`partialize` / `merge` / `onRehydrateStorage`** — core projection/merge hooks, absent from the README entirely (only in skill). +- **`retryWrite`** — has a great JSDoc example (`persist-core.ts:290–299`) but no README recipe; the quota-shrink story is a selling point and is buried. +- **`setOptions` / `getOptions` / `clearStorage`** on `PersistApi` — never enumerated in README. +- **The generated API site** (`docs/api/`) — built, validated, but **never linked from the README** and is git-ignored so consumers can't browse it in-repo; they must run `bun run docs:api` or read hovers. + +## C.3 Onboarding path quality (5-minute test) + +A brand-new user landing on `README.md`: + +1. **What is this?** README:1–3 — one sentence. Crisp but jargon-dense ("storage × codec seams", "structural `PersistableSource`"). A newcomer who doesn't know "seam" or "hydration-aware" is lost on line 3. +2. **Why use it?** README:46–48 — the comparison paragraph. Good, but dense; "hydration lifecycle" is asserted, not explained. +3. **Install?** ✅ README:5–24 — clear, optional-peer table is excellent. +4. **Wire it up (TanStack Store + localStorage + seroval + React)?** ✅ README:26–44 quick start does exactly this. **But** the quick start uses `createSerovalStorage(() => localStorage)` + `useHydrated` — it does **not** show IndexedDB, and IndexedDB is where the hydration gate actually matters (async). The headline demo skips the case that justifies the library's "hydration-aware" name. +5. **Where they get stuck:** + - **No "what is hydration?" explainer.** The word "hydration" appears 20+ times in the README; it is never defined for a reader who only knows it from the Next.js/SSR sense (where it means something different). README:152 says "gate UI on `useHydrated`" but never says _what flashes_ or _why_. + - **No IndexedDB end-to-end example.** A user wanting IDB (the second-most-common backend) must assemble it from recipes README:131–139, which show `createStorage(() => idbStateStorage(), encryptedCodec, …)` and `createIdbStorage()` but never a complete `persistStore(store, { storage: createIdbStorage() })` + `useHydrated` gate + `destroy()` on unmount chain. + - **No React component context.** The quick start ends with `// in a component:` and one line. No full component, no `` fallback, no `useEffect(() => { const persist = persistStore(...); return () => persist.destroy() }, [])` pattern in the README (it's in skill:96–101 but a README reader won't find it). + - **`useHydrated` import path is shown but `useSelector` is never mentioned** in the README — a TanStack Store user needs to know state reads go through `useSelector`, only the skill says so (skill:33 area via JSDoc example). + - **No SSR/Next.js example** despite the library shipping an SSR policy (`alwaysTrue` server snapshot, `null` signal = hydrated). README:156 mentions "render `hydrated: true` on the server" in the adapter-author section, not the consumer section. + - **`bun add` only.** No npm/pnpm/yarn equivalent. Minor, but `bun`-only install alienates non-Bun users (the engines field supports Node 20.19+/22.12+). + - **The generated API site is invisible** — a user wanting reference has only hovers or `docs/api/` which they must build themselves and is not linked. + +**Biggest onboarding gaps, ranked:** + +1. No "what is hydration / why does it flash" explainer — the library's namesake concept is undefined. +2. No complete IndexedDB + React + `useHydrated` + `destroy()` walkthrough. +3. No full React component example in the README. +4. `createPersistRegistry` / clear-all invisible. +5. Generated API reference not linked. + +## C.4 Examples in docs + +Counting copy-pasteable code blocks across README + skill: + +| Adapter combination | Example? | Where | +| ------------------------------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------- | +| TanStack Store + localStorage + seroval + React `useHydrated` | ✅ | README:28–44 | +| TanStack Store + localStorage + seroval (no React) | ✅ | skill:38–48 | +| TanStack Atom + (default JSON localStorage) | ✅ | skill:59–66 | +| TanStack Store + localStorage + jsonCodec (default) | 🟡 implied | quick start uses seroval; no plain-JSON example | +| TanStack Store + IndexedDB (`createIdbStorage`) + React | ❌ | never shown end-to-end | +| TanStack Store + IndexedDB + `identityCodec` | ❌ | `createIdbStorage()` shown standalone README:79, never wired to `persistStore` | +| TanStack Store + IndexedDB + encrypted codec | 🟡 | README:131–133 shows `createStorage(...)` but not the `persistStore(store, { storage })` wiring | +| TanStack Store + IndexedDB + seroval (legacy string payloads) | 🟡 | README:136, not wired | +| TanStack Store + sessionStorage | 🟡 | README:78 shows `createSerovalStorage(() => sessionStorage)` standalone | +| TanStack Store + cross-tab (localStorage) | ✅ | README:142–147, skill:107–114 | +| TanStack Store + cross-tab (IDB via BroadcastChannel) | ❌ | only prose README:149, skill:116 — no code | +| `persistSource` + zustand/Redux/hand-rolled | ❌ | README:117 shows the call shape; no real zustand/Redux example | +| React Native (`AsyncStorage`) | 🟡 | README:80 shows `createJSONStorage(() => AsyncStorage)` standalone, not wired | +| `partialize` | ❌ | no example anywhere | +| `merge` custom | ❌ | no example | +| `migrate` | ✅ | skill:122–129 | +| `retryWrite` | ✅ JSDoc only | `persist-core.ts:290–299` — not in README/skill | +| `registry` / `clearAll` | ❌ | no example | +| `skipPersist` | ✅ | skill (via persistStore JSDoc `persist-tanstack.ts:18`), README mentions | +| `throttleMs` | ❌ | no example, only prose | +| `maxAge` / `buster` | ❌ | no example, only prose | +| Svelte / Solid / Vue adapter | 🟡 | README:161–178 Svelte sketch only | +| Custom codec (superjson / encrypted) | ✅ | README:99–103 | + +**Rough count:** ~12 distinct code blocks in README, ~8 in skill. **No example** for: IDB+React end-to-end, IDB+identity wired to a store, IDB cross-tab BroadcastChannel, zustand/Redux via `persistSource`, `partialize`, `merge`, `registry`/clearAll, `throttleMs`, `maxAge`, `buster`, `retryWrite` (in prose docs), SSR/Next.js. + +**No `examples/` directory exists** in the repo — no runnable demo apps at all. + +## C.5 Missing doc types + +- **Docs site (VitePress / MkDocs / Astro Starlight).** Currently the consumer surface is one giant README. A multi-page site with sidebar nav would fix the "wall of text" problem and let the generated `docs/api/` be hosted and linked. +- **"Getting Started" guide** — progressive (install → 30-sec localStorage → IDB + hydration gate → SSR), distinct from the reference-dense README. +- **"Adapters" catalog page** — one page per entry (`./seroval`, `./idb`, `./tanstack-store`, `./react`) with install, API, and a complete example each. +- **"Choose your storage" decision matrix** — localStorage vs sessionStorage vs IndexedDB vs AsyncStorage vs custom: sync/async, cross-tab support, structured-clone, size limits, when to gate UI. The skill has a 4-row version (skill:150–155); a fuller one belongs in consumer docs. +- **"Choose your codec" matrix** — jsonCodec vs serovalCodec vs identityCodec vs custom: Set/Map/Date support, wire type, backend compatibility, perf notes. Currently scattered across README:95–103. +- **Recipes section** (separate page) — encryption-at-rest, cross-tab IDB via BroadcastChannel, partialize+merge, retryWrite quota-shrink, registry clear-all-on-logout, SSR/Next.js, React Native AsyncStorage, throttled high-frequency writes, schema migration chains. Most of these have no example today. +- **Migration guide** — for users coming from zustand-persist / redux-persist / @tanstack/query-persist-client / pinia-persist: option-name mapping, conceptual diffs, copy-paste port. The README:46–48 paragraph compares positioning but gives no port guide. +- **Comparison page** — vs zustand-persist, redux-persist, `@tanstack/query-persist-client`, pinia-persist. README:46–48 does this in one paragraph; a table (store-agnostic? hydration signal? codec seam? cross-tab? retryWrite? migrate? throttle?) would land harder. +- **Playground / StackBlitz / CodeSandbox** — none. A live editable example is the fastest on-ramp. +- **Interactive REPL** — none. +- **API reference caveats** — `docs/api/` is generated and git-ignored; **the README never links to it** and never tells a user how to read it (`bun run docs:api` is in CONTRIBUTING, not README). A "Reference" section pointing at the generated site (hosted on GitHub Pages via the `.nojekyll` already present in `docs/api/`) is missing. +- **A "Common mistakes" page** — skill:141–146 has 4; lift to consumer docs. +- **An "Examples" repo / `examples/` dir** — none; runnable TanStack+IDB+React and Next.js SSR apps would close the biggest onboarding gap. + +## C.6 Tone & positioning + +- **Value proposition:** Crisp at the seam/structure level ("every 'can it do X?' is a one-line composition instead of a feature request", README:3). But it leads with mechanism (seams, `PersistableSource`) before outcome (your prefs survive reload, no UI flash, works with any store). A new user learns _how it's built_ before _what it does for them_. The headline should answer "what do I get?" first. +- **"Hydration-aware":** **Not explained.** The word appears in the title (`README.md:1`), in `package.json` description, and ~20 times in the README, but is never defined. A reader who knows "hydration" only from React/Next.js SSR will be confused — here it means _"has the persisted state finished loading from storage yet,"_ a completely different concept. README:152 says "gate UI on `useHydrated`" but never shows _what_ flashes (the default state rendering briefly before stored state lands). **This is the single biggest tone gap.** +- **TanStack intent:** Clear in `skills/tanstack-store/SKILL.md` and `docs/plans/upstream-tanstack-pitch.md`, but **opaque in the README**. The README never says "this is meant to become TanStack Persist" or that `./tanstack-store` is the primary adapter. A reader sees five equal-weight subpaths and doesn't know `@tanstack/store` is the blessed path. The "Relationship to TanStack Persist / zustand persist" section (README:46) frames it as a _competitor/alternative_ rather than a _collaboration target_, which undersells the TanStack intent. +- **Density vs audience mismatch:** The README is one document trying to serve "give me 5 minutes" (quick start) and "I want the full seam theory" (Extensibility guide) and "I'm writing a Svelte adapter" (adapter section). All three audiences get one scroll. Progressive disclosure (a Getting Started page → Extensibility guide → Adapter authoring) would serve each without overwhelming the others. +- **Voice:** Confident and opinionated (good — "deliberate divergence", "no barrel", "prefs shouldn't silently expire"). This is a strength; preserve it in any restructure. +- **`bun`-centrism:** README assumes Bun (`bun add`). Engines field supports Node ≥20.19. The install command should show npm/pnpm/yarn too, or a note that any package manager works. + +### Bottom line + +The **skill file** (`skills/tanstack-store/SKILL.md`) is the real consumer doc and is excellent; the **README** is a dense reference that doubles as a landing page and underserves the brand-new user. The library's headline concept ("hydration-aware") is never defined; the headline use case that justifies the library (IndexedDB + async hydration gate) has no end-to-end example; the clear-all registry, `partialize`/`merge`, `retryWrite`, and `alwaysHydratedSignal` are effectively hidden; and the generated API site is built but unlinked. Sufficient for an experienced TanStack Store user willing to read JSDoc; **not** sufficient for a 5-minute cold onboarding. + +--- + +# Appendix D — Build / tooling (subagent report) + +> The build/CI agent returned a high-level summary rather than a full structured report (it refused to expand twice on resume). The summary is reproduced verbatim below; every point it raised is captured in the synthesis `Build & tooling` section and the ROI tiers. If deeper file:line detail is needed for this lane, resume [the build/CI agent](d0f5c223-e7eb-4495-b33d-2a55f0d1a43c). + +**Verbatim summary returned by the build/CI agent:** + +> Re-output the complete 6-section build/tooling/test/release audit of `@stainless-code/persist` with file:line citations: zero-dep core with optional peers and ESM-only output; bun-unit + vitest/jsdom DOM split with no coverage/matrix/real-browser/SSR-framework tests; three CI workflows (ci/release/check-skills) with single-env matrix and gaps in preview deploy, pkg-size diff, and semver/export pack-validation; changesets publish flow lacking npm provenance/signing and id-token permission; consumer DX gaps in attw/knip/exportslint, bundle badge, packageManager pin, TS range, compatibility table, FAQ; and no examples/playground infra. + +**Cross-checked against the synthesis `Build & tooling` section:** all six points (zero-dep core + optional peers + ESM-only; bun/vitest-jsdom split with no coverage/matrix/real-browser/SSR-framework tests; three CI workflows with single-env matrix + gaps; changesets lacking provenance/signing/id-token; consumer DX gaps; no examples/playground) are present. No information lost from this lane. From 8bc581bc10aa50dd819214ab7905856333cbb482 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sat, 4 Jul 2026 18:48:45 +0300 Subject: [PATCH 02/77] 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) --- README.md | 200 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) diff --git a/README.md b/README.md index 682678d..9fd9815 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,21 @@ Hydration-aware persistence middleware for any reactive store — storage × codec seams, TanStack Store adapters, and a React hydration hook. Store-agnostic via a structural `PersistableSource`; every "can it do X?" is a one-line composition instead of a feature request. +Jump to what you need — + +- [Install](#install) +- [Quick start](#quick-start) +- [What does "hydration-aware" mean?](#what-does-hydration-aware-mean) +- [IndexedDB + React, end to end](#indexeddb--react-end-to-end) +- [Relationship to TanStack Persist / zustand persist](#relationship-to-tanstack-persist--zustand-persist) +- Extensibility guide + - [Entry points (one subpath = one optional peer)](#entry-points-one-subpath--one-optional-peer) + - [The three seams](#the-three-seams) + - [Recipes](#recipes) + - [Writing a framework adapter](#writing-a-framework-adapter) + - [Lifecycle in one paragraph](#lifecycle-in-one-paragraph) +- [API reference](#api-reference) + ## Install ```bash @@ -23,6 +38,14 @@ Each subpath owns its dependency as an **optional peer** — import only the ent bun add seroval idb-keyval @tanstack/store react ``` +Any package manager works — engines require Node ≥20.19 (or Bun ≥1). + +```bash +npm install @stainless-code/persist # pnpm add / yarn add — same pattern +# optional peers, only for subpaths you import: +npm install seroval idb-keyval @tanstack/store react +``` + ## Quick start ```ts @@ -43,6 +66,78 @@ export const prefsHydration = toHydrationSignal(persist); const { hydrated } = useHydrated(prefsHydration); ``` +## What does "hydration-aware" mean? + +**Hydration-aware** means the library tracks whether persisted state has **finished loading from storage** — not React SSR hydration, not rehydrating the DOM from HTML. It answers one question: _has the stored snapshot landed in the store yet?_ + +That gap matters on async backends. IndexedDB reads are Promise-backed; they cannot settle before first paint. Between mount and the read completing, the store still holds its constructor default — theme `"light"` when storage says `"dark"`, filters `[]` when storage has three. Render persisted-dependent UI in that window and you get a **hydrate flash**: wrong state, then a snap to the real one. `useHydrated` gates on that lifecycle; it does not change how you read state. + +Sync backends (`localStorage`, `sessionStorage`) settle before first render when the store is created at module load — `hydrated` is `true` immediately, no gate required. IndexedDB makes the gate mandatory rather than optional. + +```tsx +const { hydrated } = useHydrated(prefsHydration); +if (!hydrated) return ; +// persisted-dependent UI below +``` + +## IndexedDB + React, end to end + +The headline path: async storage, TanStack Store, hydration gate. npm/pnpm/yarn work too. + +```bash +bun add @stainless-code/persist @tanstack/store react idb-keyval +``` + +**Store module** — `createIdbStorage` runs structured-clone mode: `Set` / `Map` / `Date` round-trip natively, no codec. The persist + hydration signal live at module scope for an app-lifetime singleton store. + +```ts +// prefs-store.ts +import { Store } from "@tanstack/store"; +import { toHydrationSignal } from "@stainless-code/persist"; +import { createIdbStorage } from "@stainless-code/persist/idb"; +import { persistStore } from "@stainless-code/persist/tanstack-store"; + +export type Prefs = { theme: "light" | "dark"; recent: string[] }; + +export const prefsStore = new Store({ theme: "light", recent: [] }); + +const persist = persistStore(prefsStore, { + name: "app:prefs:v1", + storage: createIdbStorage(), +}); +export const prefsHydration = toHydrationSignal(persist); +``` + +**React component** — `useHydrated` returns only `{ hydrated }`; state reads stay on `useSelector`. Server snapshot is always `true` (nothing to gate server-side). + +```tsx +// PrefsPanel.tsx +import { useSelector } from "@tanstack/store"; +import { useHydrated } from "@stainless-code/persist/react"; +import { prefsStore, prefsHydration } from "./prefs-store"; + +export function PrefsPanel() { + const { hydrated } = useHydrated(prefsHydration); + const theme = useSelector(prefsStore, (s) => s.theme); + if (!hydrated) return ; + return ; +} +``` + +**Non-singleton stores** (a per-route filter store, a per-instance draft editor) must create the persist in `useEffect` and tear it down on unmount — `destroy()` detaches the source subscription, removes the cross-tab listener, unregisters from any registry, and flushes any pending throttled write: + +```tsx +useEffect(() => { + const persist = persistStore(filtersStore, { + name: `app:filters:${id}`, + storage: createIdbStorage(), + }); + return () => persist.destroy(); +}, [id]); +``` + +IndexedDB fires no `storage` events — `crossTab: true` alone does nothing on this backend. Cross-tab sync needs a `BroadcastChannel` bridge wired through `crossTabEventTarget` (see Recipes). + ## Relationship to TanStack Persist / zustand persist Both TanStack Persist and zustand persist wire a single store library to a single storage with a flat options bag. `@stainless-code/persist` is a **middleware model with a first-class hydration lifecycle**: persistence is bound to a structural `PersistableSource` (`getState`/`setState`/`subscribe`) rather than a specific store, so the same middleware persists TanStack Store, zustand, Redux, or a hand-rolled atom. Three seams — backend (`StateStorage`), codec (`StorageCodec`), source (`PersistableSource`) — make every backend × codec cell a one-line composition. The hydration lifecycle (`onHydrate` / `onFinishHydration` / `hasHydrated`, surfaced via `HydrationSignal` and `useHydrated`) gates UI flash without coupling to the store's read path, versioned `migrate` handles schema evolution, `crossTab` + `onCrossTabRemove` syncs tabs, and `retryWrite` shrinks-or-gives-up on quota errors with a write-generation guard so stale retries never clobber newer state. @@ -149,6 +244,107 @@ persistStore(store, { // Cross-tab over IDB: no storage events — bridge a BroadcastChannel via crossTabEventTarget ``` +### Clear-all on logout + +```ts +import { createPersistRegistry } from "@stainless-code/persist"; +import { createSerovalStorage } from "@stainless-code/persist/seroval"; +import { persistStore } from "@stainless-code/persist/tanstack-store"; + +// One registry for every persisted store — clearAll() at logout wipes all keys +// (allSettled; first rejection rethrows; destroy() unregisters each store) +const registry = createPersistRegistry(); +const storage = createSerovalStorage(() => localStorage); + +persistStore(prefsStore, { name: "app:prefs", storage, registry }); +persistStore(cartStore, { name: "app:cart", storage, registry }); +persistStore(sessionStore, { name: "app:session", storage, registry }); + +async function logout() { + await registry.clearAll(); +} +``` + +### Partialize + +```ts +// Persist only prefs — ephemeral fields (scroll, modal) changing alone never write +persistStore(store, { + name: "app:state", + storage, + partialize: (state) => state.prefs, +}); +``` + +### Merge + +```ts +// Deep-merge nested settings on hydrate — default is shallow spread (persisted over current) +persistStore(store, { + name: "app:settings", + storage, + merge: (persisted, current) => ({ + ...current, + settings: { + ...current.settings, + ...(persisted as typeof current).settings, + }, + }), +}); +``` + +### retryWrite — shrink-or-give-up on quota + +```ts +// Quota exceeded: shrink state to retry, return undefined to give up. +// errorCount is the aggressiveness dial; stale retries never clobber newer state. +persistStore(store, { + name: "app:history", + storage, + retryWrite: ({ state, errorCount }) => { + if (errorCount === 1) + return { ...state, history: state.history.slice(-50) }; + if (errorCount === 2) return { ...state, history: [] }; + return; // give up — last error goes to onError + }, +}); +``` + +### throttleMs — trailing throttle + +```ts +// Coalesce a write burst into one trailing write with flush-time state; destroy() flushes pending +persistStore(store, { + name: "app:canvas", + storage, + throttleMs: 250, +}); +``` + +### maxAge — payload expiry + +```ts +// Discard payloads older than 7 days (by timestamp); missing timestamp = expired; key removed before migrate runs +const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000; + +persistStore(store, { + name: "app:draft", + storage, + maxAge: SEVEN_DAYS, +}); +``` + +### buster — cache-busting + +```ts +// Format changed completely — bust stale keys instead of migrating wrong values (checked before migrate) +persistStore(store, { + name: "app:layout", + storage, + buster: "grid-v2", +}); +``` + Caveats that matter per backend: async backends (IDB) can't settle hydration before first paint → gate UI on `useHydrated` (`@stainless-code/persist/react`); `sessionStorage` is per-tab (crossTab is meaningless); `identityCodec` never with string-only backends. ## Writing a framework adapter @@ -181,3 +377,7 @@ export function hydratedRune(signal: HydrationSignal | null) { ## Lifecycle in one paragraph `persistSource` hydrates on create (skip with `skipHydration`; `rehydrate()` is awaitable), subscribe-writes on every `setState` (gated until hydrated; optional trailing `throttleMs`), and tears down completely via `destroy()` — required for non-singleton stores. Failures route to `onError` with a phase (`write`/`hydrate`/`migrate`/`crossTab`); the console fallback is dev-only. Payloads carry `version` (→ `migrate`), `timestamp` (→ `maxAge`), and `buster`; `retryWrite` implements shrink-or-give-up on quota errors with a write-generation guard so stale retries never clobber newer state. + +## API reference + +Full type-level reference is generated by TypeDoc — not hosted yet; build locally: `bun run docs:api`, then open `docs/api/index.html`. The authoritative contract for each entry is its JSDoc (hover in your editor). From deba747e05d0c7cdf4edab6a779d46e7f478b7d6 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sat, 4 Jul 2026 18:52:30 +0300 Subject: [PATCH 03/77] feat(crosstab): add BroadcastChannel cross-tab bridge adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/crosstab-adapter.md | 5 + README.md | 20 ++++ package.json | 4 + src/persist-crosstab.test.ts | 209 +++++++++++++++++++++++++++++++++ src/persist-crosstab.ts | 114 ++++++++++++++++++ tsdown.config.ts | 1 + 6 files changed, 353 insertions(+) create mode 100644 .changeset/crosstab-adapter.md create mode 100644 src/persist-crosstab.test.ts create mode 100644 src/persist-crosstab.ts diff --git a/.changeset/crosstab-adapter.md b/.changeset/crosstab-adapter.md new file mode 100644 index 0000000..18e501e --- /dev/null +++ b/.changeset/crosstab-adapter.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/persist": minor +--- + +Add `./crosstab` subpath — `createBroadcastCrossTab`, a zero-dep `BroadcastChannel` bridge for cross-tab sync over backends that fire no `storage` events (IndexedDB). Returns `{ crossTabEventTarget, wrap, close }`: pass the target as `crossTabEventTarget` and `wrap(storage)` as `storage` so writes/removes broadcast to other tabs. Posts `storageArea: null` on every event so key-only matching applies in every tab (each tab owns its own backend instance — reference equality on `raw` would fail across tabs). Guards `BroadcastChannel` availability (SSR, Node <18) and posts after the write settles so receivers rehydrate into committed state. diff --git a/README.md b/README.md index 9fd9815..b3c7512 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Each subpath owns its dependency as an **optional peer** — import only the ent | `@stainless-code/persist/idb` | `idb-keyval` | | `@stainless-code/persist/tanstack-store` | `@tanstack/store` | | `@stainless-code/persist/react` | `react` | +| `@stainless-code/persist/crosstab` | none (web global) | ```bash # only when you use the matching entry @@ -157,6 +158,7 @@ Persistence middleware for any `getState`/`setState`/`subscribe` store (TanStack | `@stainless-code/persist/idb` | `idbStateStorage`, `createIdbStorage` (structured-clone mode) | `idb-keyval` | | `@stainless-code/persist/tanstack-store` | `persistStore`, `persistAtom` | `@tanstack/store` (types only) | | `@stainless-code/persist/react` | `useHydrated` React hook | `react` | +| `@stainless-code/persist/crosstab` | `createBroadcastCrossTab` | none (web global) | No barrel — importing a subpath is the dependency opt-in. @@ -345,6 +347,24 @@ persistStore(store, { }); ``` +### Cross-tab over IndexedDB + +```ts +import { createBroadcastCrossTab } from "@stainless-code/persist/crosstab"; +import { createIdbStorage } from "@stainless-code/persist/idb"; + +// IDB fires no storage events — bridge a BroadcastChannel as the transport. +// storageArea: null in every posted event → key-only matching in every tab. +const bridge = createBroadcastCrossTab({ channelName: "app:prefs" })!; +persistStore(store, { + name: "app:prefs:v1", + storage: bridge.wrap(createIdbStorage()!), + crossTab: true, + crossTabEventTarget: bridge.crossTabEventTarget, +}); +// teardown: persist.destroy(); bridge.close(); +``` + Caveats that matter per backend: async backends (IDB) can't settle hydration before first paint → gate UI on `useHydrated` (`@stainless-code/persist/react`); `sessionStorage` is per-tab (crossTab is meaningless); `identityCodec` never with string-only backends. ## Writing a framework adapter diff --git a/package.json b/package.json index b1a30c2..8afc34e 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,10 @@ "./react": { "types": "./dist/use-hydrated.d.mts", "import": "./dist/use-hydrated.mjs" + }, + "./crosstab": { + "types": "./dist/persist-crosstab.d.mts", + "import": "./dist/persist-crosstab.mjs" } }, "publishConfig": { diff --git a/src/persist-crosstab.test.ts b/src/persist-crosstab.test.ts new file mode 100644 index 0000000..fa881b3 --- /dev/null +++ b/src/persist-crosstab.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "bun:test"; + +import { persistSource } from "./persist-core"; +import type { + PersistableSource, + PersistStorage, + StorageValue, +} from "./persist-core"; +import { createBroadcastCrossTab } from "./persist-crosstab"; + +function createSharedAsyncStorage( + shared: Map>, +): PersistStorage { + return { + raw: shared, + getItem: (name) => Promise.resolve(shared.get(name) ?? null), + setItem: (name, value) => { + shared.set(name, value); + return Promise.resolve(); + }, + removeItem: (name) => { + shared.delete(name); + return Promise.resolve(); + }, + }; +} + +function createMockSource(initial: T): PersistableSource & { state: T } { + let state = initial; + const listeners = new Set<() => void>(); + + return { + get state() { + return state; + }, + getState: () => state, + setState: (updater) => { + state = updater(state); + listeners.forEach((listener) => listener()); + }, + subscribe: (listener) => { + listeners.add(listener); + return { + unsubscribe: () => listeners.delete(listener), + }; + }, + }; +} + +function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { + return new Promise((resolve, reject) => { + let ticks = 0; + const tick = () => { + if (hasHydrated()) { + resolve(); + return; + } + if (++ticks > maxTicks) { + reject(new Error("waitForHydration: never hydrated")); + return; + } + queueMicrotask(tick); + }; + tick(); + }); +} + +function waitForState( + getState: () => T, + expected: T, + equals: (a: T, b: T) => boolean = (a, b) => + JSON.stringify(a) === JSON.stringify(b), + maxTicks = 10_000, +) { + return new Promise((resolve, reject) => { + let ticks = 0; + const tick = () => { + if (equals(getState(), expected)) { + resolve(); + return; + } + if (++ticks > maxTicks) { + reject(new Error("waitForState: state never matched")); + return; + } + queueMicrotask(tick); + }; + tick(); + }); +} + +describe("createBroadcastCrossTab", () => { + it("returns undefined when BroadcastChannel is unavailable", () => { + const original = globalThis.BroadcastChannel; + try { + // @ts-expect-error — simulating SSR / Node <18 + delete globalThis.BroadcastChannel; + expect( + createBroadcastCrossTab({ channelName: "missing" }), + ).toBeUndefined(); + } finally { + globalThis.BroadcastChannel = original; + } + }); + + it("two tabs sync via BroadcastChannel", async () => { + const shared = new Map>(); + const channelName = `sync-${Math.random()}`; + + const bridgeA = createBroadcastCrossTab<{ count: number }>({ + channelName, + })!; + const bridgeB = createBroadcastCrossTab<{ count: number }>({ + channelName, + })!; + + const sourceA = createMockSource({ count: 0 }); + const sourceB = createMockSource({ count: 0 }); + + const persistA = persistSource(sourceA, { + name: "sync-key", + storage: bridgeA.wrap(createSharedAsyncStorage(shared)), + crossTab: true, + crossTabEventTarget: bridgeA.crossTabEventTarget, + }); + + const persistB = persistSource(sourceB, { + name: "sync-key", + storage: bridgeB.wrap(createSharedAsyncStorage(shared)), + skipHydration: true, + crossTab: true, + crossTabEventTarget: bridgeB.crossTabEventTarget, + }); + + await waitForHydration(persistA.hasHydrated); + + sourceA.setState(() => ({ count: 42 })); + await new Promise((resolve) => setTimeout(resolve, 0)); + + await waitForState(() => sourceB.state, { count: 42 }); + + persistA.destroy(); + persistB.destroy(); + bridgeA.close(); + bridgeB.close(); + }); + + it("wrap preserves raw", () => { + const sentinel = {}; + const storage: PersistStorage<{ x: number }> = { + raw: sentinel, + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }; + + const bridge = createBroadcastCrossTab<{ x: number }>({ + channelName: "raw-test", + })!; + const wrapped = bridge.wrap(storage); + + expect(wrapped.raw).toBe(sentinel); + bridge.close(); + }); + + it("close stops delivery", async () => { + const channelName = `close-${Math.random()}`; + const bridge = createBroadcastCrossTab({ channelName })!; + + let fired = false; + const listener = () => { + fired = true; + }; + bridge.crossTabEventTarget.addEventListener("storage", listener); + + bridge.close(); + + const poster = new BroadcastChannel(channelName); + poster.postMessage({ + key: "k", + newValue: 1, + storageArea: null, + }); + poster.close(); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(fired).toBe(false); + }); + + it("no sibling entry IMPORTS persist-crosstab (dependency isolation)", async () => { + for (const sibling of [ + "persist-core.ts", + "persist-seroval.ts", + "persist-idb.ts", + "persist-tanstack.ts", + "hydration.ts", + "use-hydrated.ts", + "index.ts", + ]) { + const source = await Bun.file( + new URL(`./${sibling}`, import.meta.url), + ).text(); + const offendingImports = source.match( + /(?:from\s+|import\s*\(\s*)["']\.\/persist-crosstab["']/g, + ); + expect(offendingImports).toBeNull(); + } + }); +}); diff --git a/src/persist-crosstab.ts b/src/persist-crosstab.ts new file mode 100644 index 0000000..14d5959 --- /dev/null +++ b/src/persist-crosstab.ts @@ -0,0 +1,114 @@ +// BroadcastChannel cross-tab entry — owns NO peer dep (`BroadcastChannel` is a +// web global, available in browsers + Node 18+). Ships as its own subpath so +// consumers not using cross-tab don't pull it. For async backends that fire no +// `storage` events (IndexedDB). +import type { + CrossTabEventTarget, + CrossTabStorageEvent, + PersistStorage, +} from "./persist-core"; + +export interface CreateBroadcastCrossTabOptions { + /** BroadcastChannel name — all tabs sharing this name sync. */ + channelName: string; +} + +export interface BroadcastCrossTab { + /** Pass as `crossTabEventTarget`. Listens for `message` events and dispatches + * storage-shaped events (storageArea: null → key-only matching in every tab). */ + crossTabEventTarget: CrossTabEventTarget; + /** Wrap a base `PersistStorage` so writes/removes broadcast. Pass the result + * as `storage`. Preserves `raw`. */ + wrap: (storage: PersistStorage) => PersistStorage; + /** Close the underlying channel — call from the persist `destroy()` path. */ + close: () => void; +} + +/** + * Bridge a `BroadcastChannel` as the cross-tab transport for backends that + * fire no `storage` events (IndexedDB). Posts `storageArea: null` on every + * event so key-only matching applies in every tab (each tab owns its own + * backend instance — reference equality on `raw` would fail across tabs). + * + * @example + * ```ts + * const bridge = createBroadcastCrossTab({ channelName: "app:prefs" })!; + * const persist = persistStore(store, { + * name: "app:prefs:v1", + * storage: bridge.wrap(createIdbStorage()!), + * crossTab: true, + * crossTabEventTarget: bridge.crossTabEventTarget, + * }); + * // on teardown: persist.destroy(); bridge.close(); + * ``` + */ +export function createBroadcastCrossTab( + options: CreateBroadcastCrossTabOptions, +): BroadcastCrossTab | undefined { + if (typeof BroadcastChannel === "undefined") { + return undefined; + } + + const channel = new BroadcastChannel(options.channelName); + const handlerMap = new Map< + (event: CrossTabStorageEvent) => void, + (event: MessageEvent) => void + >(); + + const crossTabEventTarget: CrossTabEventTarget = { + addEventListener(_type, listener) { + const handler = (event: MessageEvent) => { + listener(event.data as CrossTabStorageEvent); + }; + handlerMap.set(listener, handler); + channel.addEventListener("message", handler); + }, + removeEventListener(_type, listener) { + const handler = handlerMap.get(listener); + if (handler) { + channel.removeEventListener("message", handler); + handlerMap.delete(listener); + } + }, + }; + + function postMessage(message: CrossTabStorageEvent) { + try { + channel.postMessage(message); + } catch { + // closed channel — swallow so a settled write microtask doesn't throw + } + } + + return { + crossTabEventTarget, + wrap(storage) { + return { + getItem: (name) => storage.getItem(name), + raw: storage.raw, + setItem(name, value) { + const result = storage.setItem(name, value); + Promise.resolve(result).then(() => { + postMessage({ + key: name, + newValue: 1 as unknown as string, + storageArea: null, + }); + }); + return result; + }, + removeItem(name) { + const result = storage.removeItem(name); + Promise.resolve(result).then(() => { + postMessage({ key: name, newValue: null, storageArea: null }); + }); + return result; + }, + }; + }, + close() { + channel.close(); + handlerMap.clear(); + }, + }; +} diff --git a/tsdown.config.ts b/tsdown.config.ts index f3ab17d..927dfcc 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "src/index.ts", "src/persist-seroval.ts", "src/persist-idb.ts", + "src/persist-crosstab.ts", "src/persist-tanstack.ts", "src/use-hydrated.ts", ], From f667bfe313c298a1d95d5d55818c9a6ea7930158 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sat, 4 Jul 2026 18:55:04 +0300 Subject: [PATCH 04/77] feat(zod): add zod-validated codec adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .changeset/zod-codec.md | 5 ++ README.md | 7 ++ bun.lock | 3 + package.json | 13 ++- src/persist-zod.test.ts | 183 ++++++++++++++++++++++++++++++++++++++++ src/persist-zod.ts | 57 +++++++++++++ tsdown.config.ts | 3 +- 7 files changed, 268 insertions(+), 3 deletions(-) create mode 100644 .changeset/zod-codec.md create mode 100644 src/persist-zod.test.ts create mode 100644 src/persist-zod.ts diff --git a/.changeset/zod-codec.md b/.changeset/zod-codec.md new file mode 100644 index 0000000..daf2e27 --- /dev/null +++ b/.changeset/zod-codec.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/persist": minor +--- + +Add `./zod` subpath — `zodCodec` / `createZodStorage`, a schema-gated codec over the `StorageCodec` seam. `encode` validates `state` against a `ZodType` before serializing the envelope (invalid state never persists; the throw surfaces via `onError` phase `"write"`). `decode` parses + validates the stored `state`; a validation failure throws into persist-core's corrupt-payload path → returns `null`, or with `clearCorruptOnFailure` removes the key. Validates `state` only — `version` / `timestamp` / `buster` stay the envelope's concern. `zod` is an optional peer (`>=3.20.0`, stable across v3/v4 via `ZodType` + `.parse`). diff --git a/README.md b/README.md index b3c7512..b091928 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Each subpath owns its dependency as an **optional peer** — import only the ent | `@stainless-code/persist/tanstack-store` | `@tanstack/store` | | `@stainless-code/persist/react` | `react` | | `@stainless-code/persist/crosstab` | none (web global) | +| `@stainless-code/persist/zod` | `zod` | ```bash # only when you use the matching entry @@ -159,6 +160,7 @@ Persistence middleware for any `getState`/`setState`/`subscribe` store (TanStack | `@stainless-code/persist/tanstack-store` | `persistStore`, `persistAtom` | `@tanstack/store` (types only) | | `@stainless-code/persist/react` | `useHydrated` React hook | `react` | | `@stainless-code/persist/crosstab` | `createBroadcastCrossTab` | none (web global) | +| `@stainless-code/persist/zod` | `zodCodec`, `createZodStorage` | `zod` | No barrel — importing a subpath is the dependency opt-in. @@ -187,10 +189,15 @@ import { createStorage, } from "@stainless-code/persist"; import { serovalCodec } from "@stainless-code/persist/seroval"; +import { zodCodec } from "@stainless-code/persist/zod"; import { idbStateStorage } from "@stainless-code/persist/idb"; +import { z } from "zod"; + +const PrefsSchema = z.object({ theme: z.enum(["light", "dark"]) }); jsonCodec(); // core default — plain JSON serovalCodec(); // Set / Map / Date / cycles, inert JSON-shaped output +zodCodec(PrefsSchema); // schema-gated persistence — invalid state never writes; corrupt reads discard identityCodec(); // structured-clone backends only — zero serialization // custom — any pair of pure functions: const superjsonCodec = { encode: superjson.stringify, decode: superjson.parse }; // class instances via registerCustom diff --git a/bun.lock b/bun.lock index 52b39d2..94560c2 100644 --- a/bun.lock +++ b/bun.lock @@ -29,6 +29,7 @@ "typedoc": "0.28.19", "typescript": "6.0.3", "vitest": "4.1.9", + "zod": "^4.4.3", }, "peerDependencies": { "@tanstack/store": ">=0.10.0", @@ -751,6 +752,8 @@ "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], diff --git a/package.json b/package.json index 8afc34e..1681b70 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,10 @@ "./crosstab": { "types": "./dist/persist-crosstab.d.mts", "import": "./dist/persist-crosstab.mjs" + }, + "./zod": { + "types": "./dist/persist-zod.d.mts", + "import": "./dist/persist-zod.mjs" } }, "publishConfig": { @@ -109,13 +113,15 @@ "tsdown": "0.22.3", "typedoc": "0.28.19", "typescript": "6.0.3", - "vitest": "4.1.9" + "vitest": "4.1.9", + "zod": "^4.4.3" }, "peerDependencies": { "@tanstack/store": ">=0.10.0", "idb-keyval": ">=4.0.0", "react": "^18.0.0 || ^19.0.0", - "seroval": ">=1.0.0" + "seroval": ">=1.0.0", + "zod": ">=3.20.0" }, "peerDependenciesMeta": { "seroval": { @@ -129,6 +135,9 @@ }, "react": { "optional": true + }, + "zod": { + "optional": true } }, "engines": { diff --git a/src/persist-zod.test.ts b/src/persist-zod.test.ts new file mode 100644 index 0000000..d47889d --- /dev/null +++ b/src/persist-zod.test.ts @@ -0,0 +1,183 @@ +import { beforeEach, describe, expect, it } from "bun:test"; + +import { z } from "zod"; + +import { createStorage, persistSource } from "./persist-core"; +import type { PersistableSource, StateStorage } from "./persist-core"; +import { createZodStorage, zodCodec } from "./persist-zod"; + +class MemoryStorage implements StateStorage { + private store = new Map(); + + clear() { + this.store.clear(); + } + + getItem(key: string) { + return this.store.get(key) ?? null; + } + + removeItem(key: string) { + this.store.delete(key); + } + + setItem(key: string, value: string) { + this.store.set(key, value); + } +} + +function createMockSource(initial: T): PersistableSource & { state: T } { + let state = initial; + const listeners = new Set<() => void>(); + + return { + get state() { + return state; + }, + getState: () => state, + setState: (updater) => { + state = updater(state); + listeners.forEach((listener) => listener()); + }, + subscribe: (listener) => { + listeners.add(listener); + return { + unsubscribe: () => listeners.delete(listener), + }; + }, + }; +} + +function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { + return new Promise((resolve, reject) => { + let ticks = 0; + const tick = () => { + if (hasHydrated()) { + resolve(); + return; + } + // Bounded: a hydration regression fails loudly here instead of hanging + // the suite until the runner's opaque timeout. + if (++ticks > maxTicks) { + reject(new Error("waitForHydration: never hydrated")); + return; + } + queueMicrotask(tick); + }; + tick(); + }); +} + +describe("zodCodec", () => { + let memory: MemoryStorage; + + beforeEach(() => { + memory = new MemoryStorage(); + }); + + it("round-trips typed state through a zod schema", async () => { + const schema = z.object({ count: z.number() }); + const storage = createZodStorage<{ count: number }>(() => memory, schema)!; + + await storage.setItem("test", { + state: { count: 42 }, + version: 1, + }); + + const stored = await storage.getItem("test"); + expect(stored?.state.count).toBe(42); + expect(stored?.version).toBe(1); + }); + + it("decode of an invalid payload returns null", async () => { + memory.setItem("bad", JSON.stringify({ state: { count: "not-a-number" } })); + + const schema = z.object({ count: z.number() }); + const storage = createZodStorage<{ count: number }>(() => memory, schema)!; + expect(await storage.getItem("bad")).toBeNull(); + }); + + it("clearCorruptOnFailure removes the key on an invalid payload", async () => { + memory.setItem("corrupt", JSON.stringify({ state: { count: "bad" } })); + + const schema = z.object({ count: z.number() }); + const storage = createZodStorage<{ count: number }>(() => memory, schema, { + clearCorruptOnFailure: true, + })!; + + expect(await storage.getItem("corrupt")).toBeNull(); + expect(memory.getItem("corrupt")).toBeNull(); + }); + + it("encode of an invalid state throws and abandons the write", async () => { + const schema = z.object({ count: z.number() }); + const errors: Array<{ phase: string }> = []; + const source = createMockSource({ count: 0 }); + const persist = persistSource(source, { + name: "invalid-write", + storage: createZodStorage<{ count: number }>(() => memory, schema)!, + onError: (_error, context) => errors.push({ phase: context.phase }), + }); + + await waitForHydration(persist.hasHydrated); + + source.setState( + () => ({ count: "not-a-number" }) as unknown as { count: number }, + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(memory.getItem("invalid-write")).toBeNull(); + expect(errors.some((entry) => entry.phase === "write")).toBe(true); + + persist.destroy(); + }); +}); + +describe("zodCodec direct seam", () => { + let memory: MemoryStorage; + + beforeEach(() => { + memory = new MemoryStorage(); + }); + + it("zodCodec plugs into createStorage directly (frozen-API symmetry with serovalCodec)", async () => { + const schema = z.object({ count: z.number() }); + const storage = createStorage<{ count: number }>( + () => memory, + zodCodec(schema), + )!; + + await storage.setItem("direct-zod", { + state: { count: 7 }, + version: 0, + }); + const stored = await storage.getItem("direct-zod"); + expect(stored?.state.count).toBe(7); + }); +}); + +describe("persist-zod dependency isolation", () => { + it("no sibling entry IMPORTS persist-zod (dependency isolation)", async () => { + // Each entry owns its dependency; importing persist-zod is the zod + // opt-in. Core/seroval/idb/tanstack/hydration must never pull it in (doc + // comments may mention it — only import lines count). + for (const sibling of [ + "persist-core.ts", + "persist-seroval.ts", + "persist-idb.ts", + "persist-crosstab.ts", + "persist-tanstack.ts", + "hydration.ts", + "use-hydrated.ts", + "index.ts", + ]) { + const source = await Bun.file( + new URL(`./${sibling}`, import.meta.url), + ).text(); + const offendingImports = source.match( + /(?:from\s+|import\s*\(\s*)["']\.\/persist-zod["']/g, + ); + expect(offendingImports).toBeNull(); + } + }); +}); diff --git a/src/persist-zod.ts b/src/persist-zod.ts new file mode 100644 index 0000000..9a2e77b --- /dev/null +++ b/src/persist-zod.ts @@ -0,0 +1,57 @@ +// Zod codec entry — owns the `zod` dependency so the core stays +// zero-dep. Ships as its own subpath entry with zod as an optional peer. +import { ZodType } from "zod"; + +import type { + CreateStorageOptions, + PersistStorage, + StateStorage, + StorageCodec, + StorageValue, +} from "./persist-core"; +import { createStorage } from "./persist-core"; + +/** Same options as `createStorage` (`clearCorruptOnFailure`). */ +export type ZodStorageOptions = CreateStorageOptions; + +/** + * zod-validated codec — `encode` validates the state before serializing the + * envelope (invalid state never persists; throws surface via `onError` phase + * "write"), `decode` parses + validates the stored state (a validation + * failure throws → persist-core's corrupt-payload path returns null, or with + * `clearCorruptOnFailure` removes the key). Validates `state` only; version / + * timestamp / buster are the envelope's concern, not the schema's. + */ +export function zodCodec(schema: ZodType): StorageCodec { + return { + encode: (value) => { + schema.parse(value.state); + return JSON.stringify(value); + }, + decode: (raw) => { + const envelope = JSON.parse(raw) as StorageValue; + return { ...envelope, state: schema.parse(envelope.state) }; + }, + }; +} + +/** + * Build a zod-validated `PersistStorage`. Any string-keyed `StateStorage` works + * (localStorage, sessionStorage, custom). + * + * @example + * ```ts + * import { z } from "zod"; + * const Prefs = z.object({ theme: z.enum(["light", "dark"]) }); + * const storage = createZodStorage>(() => localStorage, Prefs, { + * clearCorruptOnFailure: true, + * }); + * ``` + */ +export function createZodStorage( + getStorage: () => StateStorage, + schema: ZodType, + options?: ZodStorageOptions, +): PersistStorage | undefined { + return createStorage(getStorage, zodCodec(schema), options); +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 927dfcc..68b0757 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ "src/persist-seroval.ts", "src/persist-idb.ts", "src/persist-crosstab.ts", + "src/persist-zod.ts", "src/persist-tanstack.ts", "src/use-hydrated.ts", ], @@ -19,7 +20,7 @@ export default defineConfig({ dts: true, // Each subpath owns its peer dep — never bundle them into dist. deps: { - neverBundle: ["seroval", "idb-keyval", "@tanstack/store", "react"], + neverBundle: ["seroval", "idb-keyval", "@tanstack/store", "react", "zod"], }, clean: true, }); From 82c8dd209c024e8d16e49da204c1465e86a98069 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sat, 4 Jul 2026 19:06:06 +0300 Subject: [PATCH 05/77] 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 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 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). --- .changeset/solid-vue-hydration.md | 10 +++ README.md | 6 +- bun.lock | 46 ++++++++++++ package.json | 18 +++++ src/persist-solid.test.ts | 115 ++++++++++++++++++++++++++++++ src/persist-solid.ts | 42 +++++++++++ src/persist-vue.test.ts | 94 ++++++++++++++++++++++++ src/persist-vue.ts | 41 +++++++++++ tsdown.config.ts | 12 +++- 9 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 .changeset/solid-vue-hydration.md create mode 100644 src/persist-solid.test.ts create mode 100644 src/persist-solid.ts create mode 100644 src/persist-vue.test.ts create mode 100644 src/persist-vue.ts diff --git a/.changeset/solid-vue-hydration.md b/.changeset/solid-vue-hydration.md new file mode 100644 index 0000000..1c79a14 --- /dev/null +++ b/.changeset/solid-vue-hydration.md @@ -0,0 +1,10 @@ +--- +"@stainless-code/persist": minor +--- + +Add `./solid` and `./vue` hydration subpaths — `useHydrated(signal)` over the `HydrationSignal` seam, mirroring the React adapter (`src/use-hydrated.ts`). + +- `./solid` (peer `solid-js >=1.6.0`): returns a Solid `Accessor` via `from`; the subscription is owned by the reactive scope and cleaned up on scope dispose. Uses the `from(producer, initialValue)` overload so the accessor is `Accessor` (not `boolean | undefined`); reads `isHydrated()` for the initial value (pull-model signal — no initial notification). +- `./vue` (peer `vue >=3.3.0`): returns a Vue `Ref` via `shallowRef`; subscription cleaned up via `onScopeDispose` — call inside `setup()` or an `effectScope()`. + +Both render `true` on the server (the no-op `PersistApi` is always-hydrated, so the signal is `true` server-side) — matching the `HydrationSignal` adapter contract. Each ships as its own subpath with the peer as optional, no cross-entry value imports (isolation test included). diff --git a/README.md b/README.md index b091928..d88bcc1 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ Each subpath owns its dependency as an **optional peer** — import only the ent | `@stainless-code/persist/react` | `react` | | `@stainless-code/persist/crosstab` | none (web global) | | `@stainless-code/persist/zod` | `zod` | +| `@stainless-code/persist/solid` | `solid-js` | +| `@stainless-code/persist/vue` | `vue` | ```bash # only when you use the matching entry @@ -161,6 +163,8 @@ Persistence middleware for any `getState`/`setState`/`subscribe` store (TanStack | `@stainless-code/persist/react` | `useHydrated` React hook | `react` | | `@stainless-code/persist/crosstab` | `createBroadcastCrossTab` | none (web global) | | `@stainless-code/persist/zod` | `zodCodec`, `createZodStorage` | `zod` | +| `@stainless-code/persist/solid` | `useHydrated` (Solid `Accessor`) | `solid-js` | +| `@stainless-code/persist/vue` | `useHydrated` (Vue `Ref`) | `vue` | No barrel — importing a subpath is the dependency opt-in. @@ -376,7 +380,7 @@ Caveats that matter per backend: async backends (IDB) can't settle hydration bef ## Writing a framework adapter -The React hook (`@stainless-code/persist/react`) is ~20 lines over `HydrationSignal` — every adapter is the same shape. The contract (full version on `HydrationSignal`'s JSDoc): subscribe returns an idempotent unsubscribe; each subscribe call is an independent subscription; **no initial notification and no payload** — pull `isHydrated()` after attach and on every notification; transitions while detached aren't replayed (the snapshot re-read recovers); **render `hydrated: true` on the server** (no storage server-side); `null` signal = no persistence = hydrated. +The React hook (`@stainless-code/persist/react`) is ~20 lines over `HydrationSignal` — every adapter is the same shape. Solid (`@stainless-code/persist/solid`, `Accessor` via `from`) and Vue (`@stainless-code/persist/vue`, `Ref` via `shallowRef` + `onScopeDispose`) ship the same way. The contract (full version on `HydrationSignal`'s JSDoc): subscribe returns an idempotent unsubscribe; each subscribe call is an independent subscription; **no initial notification and no payload** — pull `isHydrated()` after attach and on every notification; transitions while detached aren't replayed (the snapshot re-read recovers); **render `hydrated: true` on the server** (no storage server-side); `null` signal = no persistence = hydrated. ```ts import type { HydrationSignal } from "@stainless-code/persist"; diff --git a/bun.lock b/bun.lock index 94560c2..024d447 100644 --- a/bun.lock +++ b/bun.lock @@ -25,10 +25,12 @@ "react": "19.2.7", "react-dom": "19.2.7", "seroval": "1.5.4", + "solid-js": "^1.9.14", "tsdown": "0.22.3", "typedoc": "0.28.19", "typescript": "6.0.3", "vitest": "4.1.9", + "vue": "^3.5.39", "zod": "^4.4.3", }, "peerDependencies": { @@ -36,12 +38,14 @@ "idb-keyval": ">=4.0.0", "react": "^18.0.0 || ^19.0.0", "seroval": ">=1.0.0", + "zod": ">=3.20.0", }, "optionalPeers": [ "@tanstack/store", "idb-keyval", "react", "seroval", + "zod", ], }, }, @@ -338,6 +342,24 @@ "@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + "@vue/compiler-core": ["@vue/compiler-core@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.39", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw=="], + + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.39", "", { "dependencies": { "@vue/compiler-core": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg=="], + + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/compiler-core": "3.5.39", "@vue/compiler-dom": "3.5.39", "@vue/compiler-ssr": "3.5.39", "@vue/shared": "3.5.39", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.15", "source-map-js": "^1.2.1" } }, "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg=="], + + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.39", "", { "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw=="], + + "@vue/reactivity": ["@vue/reactivity@3.5.39", "", { "dependencies": { "@vue/shared": "3.5.39" } }, "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog=="], + + "@vue/runtime-core": ["@vue/runtime-core@3.5.39", "", { "dependencies": { "@vue/reactivity": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw=="], + + "@vue/runtime-dom": ["@vue/runtime-dom@3.5.39", "", { "dependencies": { "@vue/reactivity": "3.5.39", "@vue/runtime-core": "3.5.39", "@vue/shared": "3.5.39", "csstype": "^3.2.3" } }, "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww=="], + + "@vue/server-renderer": ["@vue/server-renderer@3.5.39", "", { "dependencies": { "@vue/compiler-ssr": "3.5.39", "@vue/shared": "3.5.39" }, "peerDependencies": { "vue": "3.5.39" } }, "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw=="], + + "@vue/shared": ["@vue/shared@3.5.39", "", {}, "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], @@ -654,6 +676,8 @@ "seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="], + "seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -666,6 +690,8 @@ "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], + "solid-js": ["solid-js@1.9.14", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="], @@ -732,6 +758,8 @@ "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "vue": ["vue@3.5.39", "", { "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/compiler-sfc": "3.5.39", "@vue/runtime-dom": "3.5.39", "@vue/server-renderer": "3.5.39", "@vue/shared": "3.5.39" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], @@ -766,6 +794,16 @@ "@quansync/fs/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], + "@vue/compiler-core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "@vue/compiler-sfc/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], "log-update/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -794,6 +832,10 @@ "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "@vue/compiler-core/@babel/parser/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@vue/compiler-sfc/@babel/parser/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "log-update/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -811,5 +853,9 @@ "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@vue/compiler-sfc/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], } } diff --git a/package.json b/package.json index 1681b70..f43ad8d 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,14 @@ "./zod": { "types": "./dist/persist-zod.d.mts", "import": "./dist/persist-zod.mjs" + }, + "./solid": { + "types": "./dist/persist-solid.d.mts", + "import": "./dist/persist-solid.mjs" + }, + "./vue": { + "types": "./dist/persist-vue.d.mts", + "import": "./dist/persist-vue.mjs" } }, "publishConfig": { @@ -110,10 +118,12 @@ "react": "19.2.7", "react-dom": "19.2.7", "seroval": "1.5.4", + "solid-js": "^1.9.14", "tsdown": "0.22.3", "typedoc": "0.28.19", "typescript": "6.0.3", "vitest": "4.1.9", + "vue": "^3.5.39", "zod": "^4.4.3" }, "peerDependencies": { @@ -121,6 +131,8 @@ "idb-keyval": ">=4.0.0", "react": "^18.0.0 || ^19.0.0", "seroval": ">=1.0.0", + "solid-js": ">=1.6.0", + "vue": ">=3.3.0", "zod": ">=3.20.0" }, "peerDependenciesMeta": { @@ -136,6 +148,12 @@ "react": { "optional": true }, + "solid-js": { + "optional": true + }, + "vue": { + "optional": true + }, "zod": { "optional": true } diff --git a/src/persist-solid.test.ts b/src/persist-solid.test.ts new file mode 100644 index 0000000..aa2df49 --- /dev/null +++ b/src/persist-solid.test.ts @@ -0,0 +1,115 @@ +import { beforeAll, describe, expect, it, mock } from "bun:test"; + +// Bun resolves `solid-js` to the SSR build (`createEffect` is a no-op). Point +// both the test and persist-solid at the client build for reactive coverage. +mock.module( + "solid-js", + // @ts-expect-error client bundle — no `.d.ts` subpath; runtime-only for tests. + () => import("solid-js/dist/solid.js"), +); + +let createEffect: typeof import("solid-js").createEffect; +let createRoot: typeof import("solid-js").createRoot; +let useHydrated: typeof import("./persist-solid").useHydrated; + +beforeAll(async () => { + ({ createEffect, createRoot } = await import("solid-js")); + ({ useHydrated } = await import("./persist-solid")); +}); + +function createFakeSignal() { + let hydrated = false; + const listeners = new Set<() => void>(); + return { + subscribeHydrated: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + isHydrated: () => hydrated, + set: (value: boolean) => { + hydrated = value; + listeners.forEach((l) => l()); + }, + listenerCount: () => listeners.size, + }; +} + +/** Solid schedules effects as microtasks — flush before asserting on `last`. */ +async function flushEffects() { + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => queueMicrotask(resolve)); + } +} + +describe("useHydrated", () => { + it("returns always-true accessor for null signal", () => { + const h = useHydrated(null); + expect(h()).toBe(true); + expect(h()).toBe(true); + }); + + it("reflects isHydrated and updates reactively", async () => { + const signal = createFakeSignal(); + await createRoot(async (dispose: () => void) => { + const h = useHydrated(signal); + let last: boolean | undefined; + createEffect(() => { + last = h(); + }); + await flushEffects(); + expect(last).toBe(false); + signal.set(true); + await flushEffects(); + expect(last).toBe(true); + signal.set(false); + await flushEffects(); + expect(last).toBe(false); + dispose(); + }); + }); + + it("cleans up subscription on scope dispose", async () => { + const signal = createFakeSignal(); + await createRoot(async (dispose: () => void) => { + const h = useHydrated(signal); + let last: boolean | undefined; + createEffect(() => { + last = h(); + }); + await flushEffects(); + expect(last).toBe(false); + expect(signal.listenerCount()).toBe(1); + dispose(); + await flushEffects(); + expect(signal.listenerCount()).toBe(0); + expect(() => signal.set(true)).not.toThrow(); + await flushEffects(); + expect(last).toBe(false); + }); + }); +}); + +describe("persist-solid entry isolation", () => { + it("no sibling entry IMPORTS persist-solid (dependency isolation)", async () => { + for (const sibling of [ + "persist-core.ts", + "persist-seroval.ts", + "persist-idb.ts", + "persist-crosstab.ts", + "persist-zod.ts", + "persist-tanstack.ts", + "persist-vue.ts", + "hydration.ts", + "use-hydrated.ts", + "index.ts", + ]) { + const url = new URL(`./${sibling}`, import.meta.url); + if (!(await Bun.file(url).exists())) continue; + const source = await Bun.file(url).text(); + const offendingImports = source.match( + /(?:from\s+|import\s*\(\s*)["']\.\/persist-solid["']/g, + ); + expect(offendingImports).toBeNull(); + } + }); +}); diff --git a/src/persist-solid.ts b/src/persist-solid.ts new file mode 100644 index 0000000..793b541 --- /dev/null +++ b/src/persist-solid.ts @@ -0,0 +1,42 @@ +// Solid hydration entry — owns the `solid-js` peer dep so the core stays +// zero-dep. Ships as its own subpath entry with solid-js as an optional peer; +// no barrel re-exports it (importing it IS the dep opt-in, enforced by an +// isolation test). +import { from } from "solid-js"; +import type { Accessor } from "solid-js"; + +import type { HydrationSignal } from "./hydration"; + +const alwaysTrue: Accessor = () => true; + +/** + * Mount a `HydrationSignal` into Solid's reactivity via `from`. Returns a + * Solid `Accessor` — read it inside a reactive scope (`createEffect`, + * a component, JSX) to track the hydration gate. Null/undefined signal → + * always `true` (store stays the same with or without persistence). The + * subscription is owned by the reactive scope that creates the accessor and + * is cleaned up automatically on scope dispose — no manual teardown. + * + * The signal is always-hydrated on the server (no storage → no-op + * `PersistApi`), so this accessor renders `true` during SSR without + * special-casing — matching the `HydrationSignal` adapter contract. + * + * @example + * ```ts + * import { createEffect } from "solid-js"; + * const hydrated = useHydrated(prefsHydration); + * createEffect(() => { if (hydrated()) renderPrefs(); }); + * ``` + */ +export function useHydrated( + signal: HydrationSignal | null | undefined, +): Accessor { + if (!signal) return alwaysTrue; + return from((set) => { + const unsubscribe = signal.subscribeHydrated(() => { + set(signal.isHydrated()); + }); + set(signal.isHydrated()); // pull-model: no initial notification, so read it ourselves + return unsubscribe; + }, false); +} diff --git a/src/persist-vue.test.ts b/src/persist-vue.test.ts new file mode 100644 index 0000000..72ccc05 --- /dev/null +++ b/src/persist-vue.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "bun:test"; + +import { effect, effectScope } from "vue"; + +import { useHydrated } from "./persist-vue"; + +function createFakeSignal() { + let hydrated = false; + const listeners = new Set<() => void>(); + return { + subscribeHydrated: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + isHydrated: () => hydrated, + set: (value: boolean) => { + hydrated = value; + listeners.forEach((l) => l()); + }, + listenerCount: () => listeners.size, + }; +} + +describe("useHydrated (vue)", () => { + it("returns a ref that stays true when signal is null", () => { + const hydrated = useHydrated(null); + expect(hydrated.value).toBe(true); + }); + + it("reflects isHydrated() and updates reactively inside an effectScope", () => { + const signal = createFakeSignal(); + const scope = effectScope(); + scope.run(() => { + const h = useHydrated(signal); + let last: boolean | undefined; + effect(() => { + last = h.value; + }); + expect(last).toBe(false); + signal.set(true); + expect(last).toBe(true); + }); + scope.stop(); + }); + + it("cleans up subscription on scope.stop() and stops updating the ref", () => { + const signal = createFakeSignal(); + const scope = effectScope(); + let hydratedRef: ReturnType | undefined; + scope.run(() => { + hydratedRef = useHydrated(signal); + effect(() => { + hydratedRef!.value; + }); + }); + expect(signal.listenerCount()).toBe(1); + scope.stop(); + expect(signal.listenerCount()).toBe(0); + const lastValue = hydratedRef!.value; + signal.set(true); + expect(hydratedRef!.value).toBe(lastValue); + }); + + it("no sibling entry IMPORTS persist-vue (dependency isolation)", async () => { + // Each entry owns its dependency; importing persist-vue is the vue opt-in. + // Core/seroval/tanstack/hydration must never pull it in (doc comments may + // mention it — only import lines count). + for (const sibling of [ + "persist-core.ts", + "persist-seroval.ts", + "persist-idb.ts", + "persist-crosstab.ts", + "persist-zod.ts", + "persist-tanstack.ts", + "persist-solid.ts", + "hydration.ts", + "use-hydrated.ts", + "index.ts", + ]) { + const url = new URL(`./${sibling}`, import.meta.url); + if (!(await Bun.file(url).exists())) continue; + const source = await Bun.file(url).text(); + // Declaration-level matching (not per-line): a formatter can wrap an + // import across lines, which a `^import` line filter would miss. Any + // `from "./persist-vue"` clause or dynamic `import("./persist-vue")` + // is an import regardless of layout; doc-comment mentions never carry + // the from/import() syntax. + const offendingImports = source.match( + /(?:from\s+|import\s*\(\s*)["']\.\/persist-vue["']/g, + ); + expect(offendingImports).toBeNull(); + } + }); +}); diff --git a/src/persist-vue.ts b/src/persist-vue.ts new file mode 100644 index 0000000..9b17189 --- /dev/null +++ b/src/persist-vue.ts @@ -0,0 +1,41 @@ +// Vue hydration entry — owns the `vue` peer dep so the core stays zero-dep. +// Ships as its own subpath entry with vue as an optional peer; no barrel +// re-exports it (importing it IS the dep opt-in, enforced by an isolation +// test). +import { onScopeDispose, shallowRef } from "vue"; +import type { Ref } from "vue"; + +import type { HydrationSignal } from "./hydration"; + +/** + * Mount a `HydrationSignal` into Vue's reactivity. Returns a `Ref` + * — read it in a template or `effect`/`computed` to track the hydration gate. + * Null/undefined signal → a ref that stays `true` (store stays the same with + * or without persistence). The subscription is cleaned up via + * `onScopeDispose`, so call this inside `setup()` or an `effectScope()` — + * the scope owns the teardown, no manual `destroy()` needed. + * + * The signal is always-hydrated on the server (no storage → no-op + * `PersistApi`), so this ref renders `true` during SSR without + * special-casing — matching the `HydrationSignal` adapter contract. + * + * @example + * ```ts + * // inside setup() — onScopeDispose is active + * const hydrated = useHydrated(prefsHydration); + * // template: + * ``` + */ +export function useHydrated( + signal: HydrationSignal | null | undefined, +): Ref { + const hydrated = shallowRef(true); + if (signal) { + hydrated.value = signal.isHydrated(); // pull-model: read the initial value ourselves + const unsubscribe = signal.subscribeHydrated(() => { + hydrated.value = signal.isHydrated(); + }); + onScopeDispose(unsubscribe); + } + return hydrated; +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 68b0757..2c9fa5d 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -12,6 +12,8 @@ export default defineConfig({ "src/persist-idb.ts", "src/persist-crosstab.ts", "src/persist-zod.ts", + "src/persist-solid.ts", + "src/persist-vue.ts", "src/persist-tanstack.ts", "src/use-hydrated.ts", ], @@ -20,7 +22,15 @@ export default defineConfig({ dts: true, // Each subpath owns its peer dep — never bundle them into dist. deps: { - neverBundle: ["seroval", "idb-keyval", "@tanstack/store", "react", "zod"], + neverBundle: [ + "seroval", + "idb-keyval", + "@tanstack/store", + "react", + "zod", + "solid-js", + "vue", + ], }, clean: true, }); From 273315b64b33680e29212df7db946da3a760dfa5 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sat, 4 Jul 2026 19:11:38 +0300 Subject: [PATCH 06/77] feat(rn): add AsyncStorage, MMKV, expo-secure-store adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .changeset/rn-storage-adapters.md | 11 + README.md | 55 +- bun.lock | 911 +++++++++++++++++++++++++++++- package.json | 27 + src/persist-asyncstorage.test.ts | 125 ++++ src/persist-asyncstorage.ts | 47 ++ src/persist-mmkv.test.ts | 139 +++++ src/persist-mmkv.ts | 61 ++ src/persist-securestore.test.ts | 122 ++++ src/persist-securestore.ts | 45 ++ tsdown.config.ts | 6 + 11 files changed, 1514 insertions(+), 35 deletions(-) create mode 100644 .changeset/rn-storage-adapters.md create mode 100644 src/persist-asyncstorage.test.ts create mode 100644 src/persist-asyncstorage.ts create mode 100644 src/persist-mmkv.test.ts create mode 100644 src/persist-mmkv.ts create mode 100644 src/persist-securestore.test.ts create mode 100644 src/persist-securestore.ts diff --git a/.changeset/rn-storage-adapters.md b/.changeset/rn-storage-adapters.md new file mode 100644 index 0000000..2fb7ada --- /dev/null +++ b/.changeset/rn-storage-adapters.md @@ -0,0 +1,11 @@ +--- +"@stainless-code/persist": minor +--- + +Add 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): + +- `./async-storage` (peer `@react-native-async-storage/async-storage >=1.0.0`) — `asyncStorageStateStorage` / `createAsyncStorage`. Fully 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. Pair `encryptionKey` for secrets-at-rest. +- `./secure-store` (peer `expo-secure-store >=12.0.0`) — `secureStoreStateStorage` / `createSecureStoreStorage`. OS keychain/keystore, async, **~2KB value limit per key** — for small secrets (auth tokens), not large state; pair `partialize` to persist a tiny slice. + +All three compose via `createJSONStorage` (jsonCodec default); swap codecs with `createStorage(backend, codec)`. Tests use `mock.module` to fake each peer (Map-backed) — validates shape, not the real RN runtime. diff --git a/README.md b/README.md index d88bcc1..f58456f 100644 --- a/README.md +++ b/README.md @@ -25,17 +25,20 @@ bun add @stainless-code/persist Each subpath owns its dependency as an **optional peer** — import only the entries you use, install the matching peer only when you do: -| Subpath | Optional peer | -| ---------------------------------------- | -------------------- | -| `@stainless-code/persist` | none (zero-dep core) | -| `@stainless-code/persist/seroval` | `seroval` | -| `@stainless-code/persist/idb` | `idb-keyval` | -| `@stainless-code/persist/tanstack-store` | `@tanstack/store` | -| `@stainless-code/persist/react` | `react` | -| `@stainless-code/persist/crosstab` | none (web global) | -| `@stainless-code/persist/zod` | `zod` | -| `@stainless-code/persist/solid` | `solid-js` | -| `@stainless-code/persist/vue` | `vue` | +| Subpath | Optional peer | +| ---------------------------------------- | ------------------------------------------- | +| `@stainless-code/persist` | none (zero-dep core) | +| `@stainless-code/persist/seroval` | `seroval` | +| `@stainless-code/persist/idb` | `idb-keyval` | +| `@stainless-code/persist/tanstack-store` | `@tanstack/store` | +| `@stainless-code/persist/react` | `react` | +| `@stainless-code/persist/crosstab` | none (web global) | +| `@stainless-code/persist/zod` | `zod` | +| `@stainless-code/persist/solid` | `solid-js` | +| `@stainless-code/persist/vue` | `vue` | +| `@stainless-code/persist/async-storage` | `@react-native-async-storage/async-storage` | +| `@stainless-code/persist/mmkv` | `react-native-mmkv` | +| `@stainless-code/persist/secure-store` | `expo-secure-store` | ```bash # only when you use the matching entry @@ -154,17 +157,20 @@ Persistence middleware for any `getState`/`setState`/`subscribe` store (TanStack ## Entry points (one subpath = one optional peer) -| Subpath | Symbols | Optional peer | -| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------ | -| `@stainless-code/persist` | `persistSource`, `PersistApi`, `createStorage`, `jsonCodec`, `identityCodec`, registry, `HydrationSignal` (`hydration`) | none | -| `@stainless-code/persist/seroval` | `serovalCodec`, `createSerovalStorage` | `seroval` | -| `@stainless-code/persist/idb` | `idbStateStorage`, `createIdbStorage` (structured-clone mode) | `idb-keyval` | -| `@stainless-code/persist/tanstack-store` | `persistStore`, `persistAtom` | `@tanstack/store` (types only) | -| `@stainless-code/persist/react` | `useHydrated` React hook | `react` | -| `@stainless-code/persist/crosstab` | `createBroadcastCrossTab` | none (web global) | -| `@stainless-code/persist/zod` | `zodCodec`, `createZodStorage` | `zod` | -| `@stainless-code/persist/solid` | `useHydrated` (Solid `Accessor`) | `solid-js` | -| `@stainless-code/persist/vue` | `useHydrated` (Vue `Ref`) | `vue` | +| Subpath | Symbols | Optional peer | +| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| `@stainless-code/persist` | `persistSource`, `PersistApi`, `createStorage`, `jsonCodec`, `identityCodec`, registry, `HydrationSignal` (`hydration`) | none | +| `@stainless-code/persist/seroval` | `serovalCodec`, `createSerovalStorage` | `seroval` | +| `@stainless-code/persist/idb` | `idbStateStorage`, `createIdbStorage` (structured-clone mode) | `idb-keyval` | +| `@stainless-code/persist/tanstack-store` | `persistStore`, `persistAtom` | `@tanstack/store` (types only) | +| `@stainless-code/persist/react` | `useHydrated` React hook | `react` | +| `@stainless-code/persist/crosstab` | `createBroadcastCrossTab` | none (web global) | +| `@stainless-code/persist/zod` | `zodCodec`, `createZodStorage` | `zod` | +| `@stainless-code/persist/solid` | `useHydrated` (Solid `Accessor`) | `solid-js` | +| `@stainless-code/persist/vue` | `useHydrated` (Vue `Ref`) | `vue` | +| `@stainless-code/persist/async-storage` | `asyncStorageStateStorage`, `createAsyncStorage` | `@react-native-async-storage/async-storage` | +| `@stainless-code/persist/mmkv` | `mmkvStateStorage`, `createMmkvStorage` | `react-native-mmkv` | +| `@stainless-code/persist/secure-store` | `secureStoreStateStorage`, `createSecureStoreStorage` | `expo-secure-store` | No barrel — importing a subpath is the dependency opt-in. @@ -180,7 +186,10 @@ import { createJSONStorage } from "@stainless-code/persist"; createSerovalStorage(() => localStorage); // durable prefs createSerovalStorage(() => sessionStorage); // per-visit state (dies with the tab) createIdbStorage(); // IndexedDB, structured-clone mode -createJSONStorage(() => AsyncStorage); // React Native — satisfies StateStorage as-is +createJSONStorage(() => AsyncStorage); // React Native — or use ./async-storage for the typed adapter +createAsyncStorage(); // React Native AsyncStorage — async, useHydrated gating +createMmkvStorage({ id: "app-prefs" }); // React Native MMKV — sync, no gate needed +createSecureStoreStorage(); // expo-secure-store — OS keychain, ~2KB/key, for secrets // custom: in-memory for tests, remote KV, encrypted wrapper — implement 3 methods ``` diff --git a/bun.lock b/bun.lock index 024d447..92e86cb 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "devDependencies": { "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.31.0", + "@react-native-async-storage/async-storage": "^3.1.1", "@tanstack/intent": "0.3.4", "@tanstack/store": "0.11.0", "@testing-library/dom": "10.4.1", @@ -16,6 +17,7 @@ "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@typescript/native-preview": "7.0.0-dev.20260628.1", + "expo-secure-store": "^57.0.0", "husky": "9.1.7", "idb-keyval": "6.2.6", "jsdom": "29.1.1", @@ -24,6 +26,7 @@ "oxlint": "1.71.0", "react": "19.2.7", "react-dom": "19.2.7", + "react-native-mmkv": "^4.3.2", "seroval": "1.5.4", "solid-js": "^1.9.14", "tsdown": "0.22.3", @@ -38,6 +41,8 @@ "idb-keyval": ">=4.0.0", "react": "^18.0.0 || ^19.0.0", "seroval": ">=1.0.0", + "solid-js": ">=1.6.0", + "vue": ">=3.3.0", "zod": ">=3.20.0", }, "optionalPeers": [ @@ -45,6 +50,8 @@ "idb-keyval", "react", "seroval", + "solid-js", + "vue", "zod", ], }, @@ -60,17 +67,135 @@ "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + "@babel/generator": ["@babel/generator@8.0.0", "", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="], + + "@babel/helper-create-regexp-features-plugin": ["@babel/helper-create-regexp-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg=="], + + "@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.8", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "debug": "^4.4.3", "lodash.debounce": "^4.0.8", "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-remap-async-to-generator": ["@babel/helper-remap-async-to-generator@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-wrap-function": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helper-wrap-function": ["@babel/helper-wrap-function@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + "@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="], + "@babel/plugin-proposal-decorators": ["@babel/plugin-proposal-decorators@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-decorators": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg=="], + + "@babel/plugin-proposal-export-default-from": ["@babel/plugin-proposal-export-default-from@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ=="], + + "@babel/plugin-syntax-decorators": ["@babel/plugin-syntax-decorators@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg=="], + + "@babel/plugin-syntax-dynamic-import": ["@babel/plugin-syntax-dynamic-import@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ=="], + + "@babel/plugin-syntax-export-default-from": ["@babel/plugin-syntax-export-default-from@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw=="], + + "@babel/plugin-syntax-flow": ["@babel/plugin-syntax-flow@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], + + "@babel/plugin-syntax-nullish-coalescing-operator": ["@babel/plugin-syntax-nullish-coalescing-operator@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ=="], + + "@babel/plugin-syntax-optional-chaining": ["@babel/plugin-syntax-optional-chaining@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="], + + "@babel/plugin-transform-async-generator-functions": ["@babel/plugin-transform-async-generator-functions@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-remap-async-to-generator": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA=="], + + "@babel/plugin-transform-async-to-generator": ["@babel/plugin-transform-async-to-generator@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-remap-async-to-generator": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w=="], + + "@babel/plugin-transform-block-scoping": ["@babel/plugin-transform-block-scoping@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ=="], + + "@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA=="], + + "@babel/plugin-transform-class-static-block": ["@babel/plugin-transform-class-static-block@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.12.0" } }, "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A=="], + + "@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g=="], + + "@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg=="], + + "@babel/plugin-transform-export-namespace-from": ["@babel/plugin-transform-export-namespace-from@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA=="], + + "@babel/plugin-transform-flow-strip-types": ["@babel/plugin-transform-flow-strip-types@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-flow": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ=="], + + "@babel/plugin-transform-for-of": ["@babel/plugin-transform-for-of@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ=="], + + "@babel/plugin-transform-logical-assignment-operators": ["@babel/plugin-transform-logical-assignment-operators@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="], + + "@babel/plugin-transform-named-capturing-groups-regex": ["@babel/plugin-transform-named-capturing-groups-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ=="], + + "@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg=="], + + "@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.29.7", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7", "@babel/plugin-transform-parameters": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A=="], + + "@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng=="], + + "@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ=="], + + "@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g=="], + + "@babel/plugin-transform-private-methods": ["@babel/plugin-transform-private-methods@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug=="], + + "@babel/plugin-transform-private-property-in-object": ["@babel/plugin-transform-private-property-in-object@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA=="], + + "@babel/plugin-transform-react-display-name": ["@babel/plugin-transform-react-display-name@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q=="], + + "@babel/plugin-transform-react-jsx": ["@babel/plugin-transform-react-jsx@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/types": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A=="], + + "@babel/plugin-transform-react-jsx-development": ["@babel/plugin-transform-react-jsx-development@7.29.7", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g=="], + + "@babel/plugin-transform-react-pure-annotations": ["@babel/plugin-transform-react-pure-annotations@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA=="], + + "@babel/plugin-transform-runtime": ["@babel/plugin-transform-runtime@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "babel-plugin-polyfill-corejs2": "^0.4.14", "babel-plugin-polyfill-corejs3": "^0.13.0", "babel-plugin-polyfill-regenerator": "^0.6.5", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q=="], + + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="], + + "@babel/plugin-transform-unicode-regex": ["@babel/plugin-transform-unicode-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@babel/plugin-transform-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ=="], + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], - "@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], @@ -132,14 +257,86 @@ "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], + "@expo/cli": ["@expo/cli@57.0.4", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~57.0.2", "@expo/config-plugins": "~57.0.2", "@expo/devcert": "^1.2.1", "@expo/env": "~2.4.1", "@expo/image-utils": "^0.11.1", "@expo/inline-modules": "^0.1.1", "@expo/json-file": "^11.0.0", "@expo/log-box": "^57.0.0", "@expo/metro": "~56.0.0", "@expo/metro-config": "~57.0.3", "@expo/metro-file-map": "^57.0.0", "@expo/osascript": "^2.7.0", "@expo/package-manager": "^1.13.0", "@expo/plist": "^0.8.0", "@expo/prebuild-config": "^57.0.4", "@expo/require-utils": "^57.0.1", "@expo/router-server": "^57.0.1", "@expo/schema-utils": "^57.0.1", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.86.0", "accepts": "^1.3.8", "agent-cli-detector": "^0.1.2", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^57.0.0", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-7d+YW9PdGqgNI4dh9FTv+ZNE2xu1jV8xREDgl/7jiKNcOKdgby6ZAXufZX7iRotyxyu8fwjzETKMg7MkmYLJ8A=="], + + "@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.6", "", { "dependencies": { "node-forge": "^1.3.3" } }, "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w=="], + + "@expo/config": ["@expo/config@57.0.2", "", { "dependencies": { "@expo/config-plugins": "~57.0.2", "@expo/config-types": "^57.0.1", "@expo/json-file": "^11.0.0", "@expo/require-utils": "^57.0.1", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4" } }, "sha512-J/K4fBPs/wGMrK445Mz5fCuFhsOZjSEq+u7F0yCboCwQu+uPlkT/ZCi6/q5pOunFOTICFtLB5wfCKqf9Iz5iKA=="], + + "@expo/config-plugins": ["@expo/config-plugins@57.0.2", "", { "dependencies": { "@expo/config-types": "^57.0.1", "@expo/json-file": "~11.0.0", "@expo/plist": "^0.8.0", "@expo/require-utils": "^57.0.1", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "semver": "^7.5.4", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-85wEk9NKzdT25+UWEaSgF7gp9uR/GmtvrvQtGqZmyhFgVRNL2H3GdJOL4/50vyZh4JK69Hl7fWKGtARTN3B8Ag=="], + + "@expo/config-types": ["@expo/config-types@57.0.1", "", {}, "sha512-fo7d/Ym28uwGzdTV2leEvpsb9t+7i8YHjy471m31+cmDt4BRd/l7e94JHyrXAq4SWOBVus16S0JLqm89SRCCdw=="], + + "@expo/devcert": ["@expo/devcert@1.2.1", "", { "dependencies": { "@expo/sudo-prompt": "^9.3.1", "debug": "^3.1.0" } }, "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA=="], + + "@expo/devtools": ["@expo/devtools@57.0.0", "", { "dependencies": { "chalk": "^4.1.2" }, "peerDependencies": { "react": "*", "react-native": "*" }, "optionalPeers": ["react", "react-native"] }, "sha512-i8ITQmf/wB0JNQ2gASFOKvqo1zRWCl/VfPlFRXdz6UkHen4s31NmMy0zmumS+pMpwn0+gA6oU4FF4zRDRG488Q=="], + + "@expo/dom-webview": ["@expo/dom-webview@57.0.0", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-zBZw52KnaHf9zGVp1eJk8kNfc+5ArGf+KvTL3SeMsr03K6tPJtLbgycVLjrAiLMfhzqGVjPD2G+rbNwpbLUxcg=="], + + "@expo/env": ["@expo/env@2.4.1", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "getenv": "^2.0.0" } }, "sha512-3c9Mg9x0HmGPEsVrGAGyEDJsNUOZ55cZvZ47/HLmXh7MHV9Zv7My73wThklKrObaBBoMfE4YqpKjYKDRzojpjQ=="], + + "@expo/expo-modules-macros-plugin": ["@expo/expo-modules-macros-plugin@0.3.0", "", {}, "sha512-2tRq8kiIZTVZcI5uggh86HefQ7s++Zk5WkFFomNp4aUqyN5ownHHvj1jPEP9jWXaXjPDmWuf5SUZTGD5G6AKkg=="], + + "@expo/fingerprint": ["@expo/fingerprint@0.20.2", "", { "dependencies": { "@expo/env": "^2.4.1", "@expo/spawn-async": "^1.8.0", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "minimatch": "^10.2.2", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-8emNA6sG7UVEOhbvtsHHe4e/G7Xv/80Xt3Xhdqcg0ulRChTEDoXra7G1GqZeO0UXfXCzdpQl8RNKmWDOaB7gMA=="], + + "@expo/image-utils": ["@expo/image-utils@0.11.1", "", { "dependencies": { "@expo/require-utils": "^57.0.1", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "semver": "^7.6.0" } }, "sha512-0JueH4vdgAZQmhlSYFvTQzt4b4NO5cnByDuApw7bMUIjhwLRnT46Ki3ritMrzJMQaO2lLK2flInZbsZbOuy8nw=="], + + "@expo/inline-modules": ["@expo/inline-modules@0.1.1", "", { "dependencies": { "@expo/config-plugins": "~57.0.1" } }, "sha512-Jbp1d6LSOS2RZCrNeK6JehcRGcYGb+1xNsHUfjggMWMlz/J2SP3HHKg8cbTkVj7KKPn33AkyFBaSqlMsvrp7ow=="], + + "@expo/json-file": ["@expo/json-file@11.0.0", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "json5": "^2.2.3" } }, "sha512-pHJCETqFL5x5BzNV6cEPwjwuECgGmnl0bNmfHIJ6LM1tlh2eVXi5HEdit3zby/JO/B8Otk5cgcqtJXgvvUat3A=="], + + "@expo/local-build-cache-provider": ["@expo/local-build-cache-provider@57.0.2", "", { "dependencies": { "@expo/config": "~57.0.2", "chalk": "^4.1.2" } }, "sha512-/8/FoYrEBJZPG4wR8kO6REI4VRCPAStD+7wS/Ds6XXddxhmyMP3pvUJz/c3icyAoqpbqeZZkR6E1ptT5Gi5iXg=="], + + "@expo/log-box": ["@expo/log-box@57.0.0", "", { "dependencies": { "@expo/dom-webview": "^57.0.0", "anser": "^1.4.9", "stacktrace-parser": "^0.1.10" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-tt9x76IEE8hp2FWTLPfEArrTyD7GCoUe3TpohESy+SPZ/OqkUnSXz40xnUuE0XbE132z8c5CRCfXQLM9DTEknQ=="], + + "@expo/metro": ["@expo/metro@56.0.0", "", { "dependencies": { "metro": "0.84.4", "metro-babel-transformer": "0.84.4", "metro-cache": "0.84.4", "metro-cache-key": "0.84.4", "metro-config": "0.84.4", "metro-core": "0.84.4", "metro-file-map": "0.84.4", "metro-minify-terser": "0.84.4", "metro-resolver": "0.84.4", "metro-runtime": "0.84.4", "metro-source-map": "0.84.4", "metro-symbolicate": "0.84.4", "metro-transform-plugins": "0.84.4", "metro-transform-worker": "0.84.4" } }, "sha512-5gIgQHtEpjjvsjKfVtIv23a98LLRV0/y07PDShEwYSytAMlE3FSF8RHXqtHc1sUJL6dn7hnuIBpIbrLXXuVi0A=="], + + "@expo/metro-config": ["@expo/metro-config@57.0.3", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@expo/config": "~57.0.2", "@expo/env": "~2.4.1", "@expo/json-file": "~11.0.0", "@expo/metro": "~56.0.0", "@expo/require-utils": "^57.0.1", "@expo/spawn-async": "^1.8.0", "@jridgewell/gen-mapping": "^0.3.13", "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.5.5", "browserslist": "^4.25.0", "chalk": "^4.1.0", "debug": "^4.3.2", "getenv": "^2.0.0", "glob": "^13.0.0", "hermes-parser": "^0.36.0", "jsc-safe-url": "^0.2.4", "lightningcss": "^1.30.1", "picomatch": "^4.0.4", "postcss": "^8.5.14", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*" }, "optionalPeers": ["expo"] }, "sha512-k7qcjTe1xDrUW15Ue86WAsJSL0ubiSluBXVNRFoe9snAWhMzBGzdZfl9XiDf4TNUV43T0L1ch+n4GmDqamvfEg=="], + + "@expo/metro-file-map": ["@expo/metro-file-map@57.0.0", "", { "dependencies": { "debug": "^4.3.4", "fb-watchman": "^2.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "walker": "^1.0.8" } }, "sha512-/sxwKQmwpYFYjFT6VYFpgldTwSv53OCx4/UCpLCv9iqQLVQ30hFEJsxGA9Jif8n9pacigK4P5/0ZJ2zMHm6arA=="], + + "@expo/osascript": ["@expo/osascript@2.7.0", "", { "dependencies": { "@expo/spawn-async": "^1.8.0" } }, "sha512-wKIXL8UtbuX4KwavPasIW3CUcgTbYfjzLcgUhjyKUAYDEqMaf6gmU1bqz3ffBPTokmX+G8/vFG1ZuI9etQWukA=="], + + "@expo/package-manager": ["@expo/package-manager@1.13.0", "", { "dependencies": { "@expo/json-file": "^11.0.0", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "resolve-workspace-root": "^2.0.0" } }, "sha512-s3W3eZafJDEyVL7W/jxj2Nz3eONKxSCU604S5xj8ijrVaRz83x0DnZznLf/UXQEI1w+FyibH68nHeQyk767b1A=="], + + "@expo/plist": ["@expo/plist@0.8.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-24JlUJI4PwHN4PLydlzFEzCdiqybfaV5t04QBkOg8em3AjvHKbMgBGlKreiuOoc0rNa3DZ21ZqL+xLGMBLQNKQ=="], + + "@expo/prebuild-config": ["@expo/prebuild-config@57.0.4", "", { "dependencies": { "@expo/config": "~57.0.2", "@expo/config-plugins": "~57.0.2", "@expo/config-types": "^57.0.1", "@expo/image-utils": "^0.11.1", "@expo/json-file": "^11.0.0", "@react-native/normalize-colors": "0.86.0", "debug": "^4.3.1", "expo-modules-autolinking": "~57.0.4", "resolve-from": "^5.0.0", "semver": "^7.6.0" } }, "sha512-wdyu9aOvU0/vW3U2/HgekQKrxh9v8Rqq74LNUXMsMFv+LeJz3MJfnexJNAL243/TwqEO396QRGqCe7ac11x/mw=="], + + "@expo/require-utils": ["@expo/require-utils@57.0.1", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8" }, "peerDependencies": { "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-uXen5/4x7j60I5slShgZr5QEtJDBK8homFiNLDnDrNrxZhrRHXASo0H6JArs3/1PDzw1wahzhGWg2WKuYyZd0A=="], + + "@expo/router-server": ["@expo/router-server@57.0.1", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "@expo/metro-runtime": "^57.0.2", "expo": "*", "expo-constants": "^57.0.2", "expo-font": "^57.0.0", "expo-router": "*", "expo-server": "^57.0.0", "react": "*", "react-dom": "*", "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" }, "optionalPeers": ["@expo/metro-runtime", "expo-router", "react-dom", "react-server-dom-webpack"] }, "sha512-jZ+jHG34rRa8HyurTQq5r558dMcb77F3Mt0HMKotAJ8Wm5bJLULJsL3KVLkEVcYP254xI9zzeJVcN79Fck/nLQ=="], + + "@expo/schema-utils": ["@expo/schema-utils@57.0.1", "", {}, "sha512-IVdaqtP3+iHFRE/y22mKijQtZanLcB18q1zi2kfN6RINTCryyzM7qHGsWHc5LBJbOuj1G4avCOECs97hOJm0GQ=="], + + "@expo/sdk-runtime-versions": ["@expo/sdk-runtime-versions@1.0.0", "", {}, "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ=="], + + "@expo/spawn-async": ["@expo/spawn-async@1.8.0", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw=="], + + "@expo/sudo-prompt": ["@expo/sudo-prompt@9.3.2", "", {}, "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw=="], + + "@expo/ws-tunnel": ["@expo/ws-tunnel@2.0.0", "", { "peerDependencies": { "ws": "^8.0.0" } }, "sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw=="], + + "@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="], + "@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.23.0", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.23.0", "@shikijs/langs": "^3.23.0", "@shikijs/themes": "^3.23.0", "@shikijs/types": "^3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg=="], "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], + "@isaacs/ttlcache": ["@isaacs/ttlcache@1.4.1", "", {}, "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA=="], + + "@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + + "@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], @@ -236,6 +433,30 @@ "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], + "@react-native-async-storage/async-storage": ["@react-native-async-storage/async-storage@3.1.1", "", { "dependencies": { "idb": "8.0.3" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-z+PnLz1n6ECKhgoHZHkfc+dijXZEyZnNFSajbtE0NEbsJhmX8x9GlOeiMQIKX2E4DUqPSgfIh4FYBv1M49KgPQ=="], + + "@react-native/assets-registry": ["@react-native/assets-registry@0.86.0", "", {}, "sha512-nIaXbm2jX1OTYp0qbviJ3O6KZivoE8z3BnhUQ2LsqfZSWRoOK/n1qsiAr6oALiNKWnXY3j2KPwtYORnZzp8xew=="], + + "@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.86.0", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@react-native/codegen": "0.86.0" } }, "sha512-qdsABWNW7uTll90l4Vh03gjeyu3WVDi2CyiiyvYGMRDcoYbjbQi6df3BMAm9lQI2yslZ1T14LlDDAsgTwNxplA=="], + + "@react-native/codegen": ["@react-native/codegen@0.86.0", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.29.0", "hermes-parser": "0.36.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "tinyglobby": "^0.2.15", "yargs": "^17.6.2" } }, "sha512-uTs9DBo3+/lUqinsGZK0FKJRBVClrwMXoZToaDxE1Q2SL2e55vs2GwyZfIKzPl5uJnbu4PfFMIp0/mLXLWUMuA=="], + + "@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.86.0", "", { "dependencies": { "@react-native/dev-middleware": "0.86.0", "debug": "^4.4.0", "invariant": "^2.2.4", "metro": "^0.84.3", "metro-config": "^0.84.3", "metro-core": "^0.84.3", "semver": "^7.1.3" }, "peerDependencies": { "@react-native-community/cli": "*", "@react-native/metro-config": "0.86.0" }, "optionalPeers": ["@react-native-community/cli", "@react-native/metro-config"] }, "sha512-Jv8p1ebEPfTzs8gmrjsdT2XMXFfeAg45Pman+XPLFGaSeGAZkutRFRyX9Cs9aGTSOyIA9YPJ6vDNb1ayTf1FKQ=="], + + "@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.86.0", "", {}, "sha512-7Mb3nDfyJeys+ELF75Ageu7VKERlnIMoO+aNPoXqTXvz+b41L6l2CqMyLpDHxkBSlenij6gEepPNgaIyWHbJZw=="], + + "@react-native/debugger-shell": ["@react-native/debugger-shell@0.86.0", "", { "dependencies": { "cross-spawn": "^7.0.6", "debug": "^4.4.0", "fb-dotslash": "0.5.8" } }, "sha512-Y0zEkZzLz8ou6o/VLml1A31X/rMgc6DRjwxwzPMa94qRTMY070WeBCNTITQo4kKTBAUgbxh07oXPQqp0Tpja8w=="], + + "@react-native/dev-middleware": ["@react-native/dev-middleware@0.86.0", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.86.0", "@react-native/debugger-shell": "0.86.0", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.3.0", "connect": "^3.6.5", "debug": "^4.4.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^7.5.10" } }, "sha512-20pTO6yTybmvXvro520H6C7jydIQnLKOl5qFtVEcHSdFrY63r3OGei+Rx9bILgSRmH6jgnfEcijcMx7pwWuQtw=="], + + "@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.86.0", "", {}, "sha512-a1RcfaEDqWExCGfCwadIxt4l8FvKYgFqeMf2uzeKyAOnb+vTGNIeCvifFL2MqvgaeYxlER437HbMIajGcuJ1pQ=="], + + "@react-native/js-polyfills": ["@react-native/js-polyfills@0.86.0", "", {}, "sha512-zYy/Cjd1VTnZ2iCNaG9bDF9C3l2ntESiPRscjIlI5FKugu6aeTwsDSv1aI8Bc4Kp3vEdoVg+UQhLAhE4svREaQ=="], + + "@react-native/normalize-colors": ["@react-native/normalize-colors@0.86.0", "", {}, "sha512-kG0wfCGghUKlfxkJyyHCDVutWVYWK7/DG58ojA/4v9EfulgF+osuSQmlbNb3rcKX58qutm7JcldSeVLgGFha9g=="], + + "@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.86.0", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.2.0", "react": "*", "react-native": "0.86.0" }, "optionalPeers": ["@types/react"] }, "sha512-4/ZLXdf/OSpPDVO0AsQ1SJdRIzt5t9BNQ46QwGgxvX7/cirYR5k8KXctNGGgW8lQo2gZChEfY2zFCZg9nM/jiw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.4", "", { "os": "android", "cpu": "arm64" }, "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ=="], @@ -278,6 +499,8 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@tanstack/intent": ["@tanstack/intent@0.3.4", "", { "dependencies": { "cac": "^6.7.14", "jsonc-parser": "^3.3.1", "semver": "^7.8.4", "std-env": "^4.1.0", "yaml": "2.9.0" }, "bin": { "intent": "dist/cli.mjs" } }, "sha512-yK1VRa118Xdcj+YmuVPVVkSLtyabYnr7Wcn6sBMdHhFEfav/QHIP0G7NdNOA7qSHgbrXiKmLMyYJCQpJ8OLgfQ=="], @@ -302,6 +525,12 @@ "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], + + "@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="], + + "@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="], + "@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="], "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], @@ -312,6 +541,10 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], + + "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260628.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260628.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260628.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260628.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260628.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260628.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260628.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260628.1" }, "bin": { "tsgo": "bin/tsgo" } }, "sha512-359WmBk3vA/bJxfeWyLbFeeejmky7Wssc8HMu0Iabu490WJLj/FqkDC51V65yuDp+anMAEkgeKO43fj6pMb/ZA=="], "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260628.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eHHDHAZjbZ681sHyW87tg8mH4+xIs+Q7cHKIlrdafqeny1KYWorj3O9Qfnjvcl2Yd2Eq+IzJxffF6Tepy13L4Q=="], @@ -328,6 +561,8 @@ "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260628.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rJMZ+YaRv9XybOZBYAsJt7x/K2IWmX9bgRatHobl0wwkKmfKd3giNnRXcDwOqYeCaWzunCbUhAirUtuUprRcnQ=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.2", "", {}, "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA=="], + "@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], "@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], @@ -360,6 +595,20 @@ "@vue/shared": ["@vue/shared@3.5.39", "", {}, "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "agent-cli-detector": ["agent-cli-detector@0.1.2", "", { "bin": { "agent-cli-detector": "dist/cli.js" } }, "sha512-qdZ/9JFORtTKJNhT/IczMeEfEUbUU0K5umYeiIQHX+AjHs+Y9SXVzSgaYlpZeyNMrvuh2HpZiOTpvS57iPfBkQ=="], + + "anser": ["anser@1.4.10", "", {}, "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], @@ -370,42 +619,112 @@ "ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="], + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], + "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "ast-kit": ["ast-kit@3.0.0", "", { "dependencies": { "@babel/parser": "^8.0.0", "estree-walker": "^3.0.3", "pathe": "^2.0.3" } }, "sha512-8OG92q3R35qjC/4i6BLBMg8IB+fClWu/1PEwg2Z9Rn+BuNaiEgJzpzn+pxWOdHJWDCAwu2JP0wCDTozAM4QirQ=="], + "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="], + + "babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.13.0", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5", "core-js-compat": "^3.43.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A=="], + + "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.8", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg=="], + + "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], + + "babel-plugin-react-native-web": ["babel-plugin-react-native-web@0.21.2", "", {}, "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA=="], + + "babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.36.0", "", { "dependencies": { "hermes-parser": "0.36.0" } }, "sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q=="], + + "babel-plugin-transform-flow-enums": ["babel-plugin-transform-flow-enums@0.0.2", "", { "dependencies": { "@babel/plugin-syntax-flow": "^7.12.1" } }, "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ=="], + + "babel-preset-expo": ["babel-preset-expo@57.0.1", "", { "dependencies": { "@babel/generator": "^7.20.5", "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.28.6", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@babel/plugin-transform-react-pure-annotations": "^7.27.1", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-plugin-codegen": "0.86.0", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.36.0", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "expo-widgets": "^57.0.1", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo", "expo-widgets"] }, "sha512-ClW79dx27GJVwKf/YMKCrR18uer6jlREZtKVB00HQMIoyeM5Qxh9axKTT0GUNFkFq596wi216ovp1zcGMUhE8g=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.42", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q=="], + "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], + "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], + "bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="], + + "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], + "brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="], + + "bser": ["bser@2.1.1", "", { "dependencies": { "node-int64": "^0.4.0" } }, "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001800", "", {}, "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chardet": ["chardet@2.2.0", "", {}, "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA=="], + "chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="], + + "chromium-edge-launcher": ["chromium-edge-launcher@0.3.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^1.0.4" } }, "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA=="], + + "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], + + "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], + + "connect": ["connect@3.7.0", "", { "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", "parseurl": "~1.3.3", "utils-merge": "1.0.1" } }, "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "core-js-compat": ["core-js-compat@3.49.0", "", { "dependencies": { "browserslist": "^4.28.1" } }, "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], @@ -416,89 +735,197 @@ "dataloader": ["dataloader@1.4.0", "", {}, "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], + "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], + "dnssd-advertise": ["dnssd-advertise@1.1.6", "", {}, "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], "dotenv": ["dotenv@8.6.0", "", {}, "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g=="], "dts-resolver": ["dts-resolver@3.0.0", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q=="], - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.387", "", {}, "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.3.0", "", {}, "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "expo": ["expo@57.0.2", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "^57.0.4", "@expo/config": "~57.0.2", "@expo/config-plugins": "~57.0.2", "@expo/devtools": "~57.0.0", "@expo/dom-webview": "~57.0.0", "@expo/fingerprint": "^0.20.2", "@expo/local-build-cache-provider": "^57.0.2", "@expo/log-box": "^57.0.0", "@expo/metro": "~56.0.0", "@expo/metro-config": "~57.0.3", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~57.0.1", "expo-asset": "~57.0.3", "expo-constants": "~57.0.3", "expo-file-system": "~57.0.0", "expo-font": "~57.0.0", "expo-keep-awake": "~57.0.0", "expo-modules-autolinking": "~57.0.4", "expo-modules-core": "~57.0.2", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" }, "peerDependencies": { "@expo/metro-runtime": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-web": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/metro-runtime", "react-dom", "react-native-web", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-QmyNQJNFJb/I6bQYpxl39jqyhCSlFXtiwBCyCFl3a7a18NZ8pHsVHTvLdRIXFI/bNXdCm/g7JMXoJB4eFKLBmg=="], + + "expo-asset": ["expo-asset@57.0.3", "", { "dependencies": { "@expo/image-utils": "^0.11.1", "expo-constants": "~57.0.3" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-6E08JDuYrktY5pKNn2WiFBPBfj6bvFb++ccuBTijK8BUk/afSoy6n35h583LmC2YHgHTepWHHWNfr61nYcMd8Q=="], + + "expo-constants": ["expo-constants@57.0.3", "", { "dependencies": { "@expo/env": "~2.4.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-BghbZlzwFnA22BG0CWv6W29zx8w19FozYPfSeZ3HjMitoy4aAmF1FnFbbjYSmZz6HHXXTWOfqwobOEIaglfzxg=="], + + "expo-file-system": ["expo-file-system@57.0.0", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-G+eytNuNGMiS7dbdj1j0nAYMowXnNr1vpO+jss6nQvBlRxshcGBFMtk8xfc67ynz0MUVkzod7WwKO7Vf1iOaJw=="], + + "expo-font": ["expo-font@57.0.0", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-zF+J7WrNFjqyAADwdvDgkFEoIQv9DqcjJ57HVstNEH7/7Tx1ThPEhKraodEQOwSjMYWirP+4BYsVbdb+/Zr4QQ=="], + + "expo-keep-awake": ["expo-keep-awake@57.0.0", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-WqEoyDNSmUeAI9Gu7UaWKDjOhfaV+jGcms7N0hh/EAr7sqJrI2s0HpLSC3P9cWfXFUZCL1zZjd22m/NbsJYCKg=="], + + "expo-modules-autolinking": ["expo-modules-autolinking@57.0.4", "", { "dependencies": { "@expo/require-utils": "^57.0.1", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-O0G2WRw4xtPpuhwufo2HQ6fyVQhNxtUVmO+tJD3+dB3bIslQCz3pM+s8ouhkf7ZU97g/wZOrd33umuHbbZjM3g=="], + + "expo-modules-core": ["expo-modules-core@57.0.2", "", { "dependencies": { "@expo/expo-modules-macros-plugin": "0.3.0", "expo-modules-jsi": "~57.0.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0" }, "optionalPeers": ["react-native-worklets"] }, "sha512-gs1Ng2Ci1C/CwN1xRZp2RR74C9iWByf9AHaovYEtOlkly9AolitQGAt9+iLT0CoCb6xw128NcDQ00OJl/Bmv9Q=="], + + "expo-modules-jsi": ["expo-modules-jsi@57.0.0", "", { "peerDependencies": { "react-native": "*" } }, "sha512-lNcA2XLKpbG/Qr3CZ6yCgzlK8oT+zwuD19QKYoRfN5ZurkVhnSA3QdTR5K32n9AxohcENYtZRtnHr2pZoG7W4w=="], + + "expo-secure-store": ["expo-secure-store@57.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-vkP16rhW7b4bljW5BC4kKXBpNxQ0O1E9SpI5NIfh2biZnszLTpI/gUF4oBsvOY2nvkh7oXS2ERuUoA8cuS8FWQ=="], + + "expo-server": ["expo-server@57.0.0", "", {}, "sha512-18byjwFbHVFrGzI4IH1o2MZiiYwvO/y3d+JL3sq50Lg3Id1titwIRMh1fjbHbpM+wP6x14KJY0Ers1UYsjGq8Q=="], + + "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + "extendable-error": ["extendable-error@0.1.7", "", {}, "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fb-dotslash": ["fb-dotslash@0.5.8", "", { "bin": { "dotslash": "bin/dotslash" } }, "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA=="], + + "fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fetch-nodeshim": ["fetch-nodeshim@0.4.10", "", {}, "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "finalhandler": ["finalhandler@1.1.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "on-finished": "~2.3.0", "parseurl": "~1.3.3", "statuses": "~1.5.0", "unpipe": "~1.0.0" } }, "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA=="], + "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "flow-enums-runtime": ["flow-enums-runtime@0.0.6", "", {}, "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw=="], + + "fontfaceobserver": ["fontfaceobserver@2.3.0", "", {}, "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg=="], + + "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + "fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], "get-tsconfig": ["get-tsconfig@5.0.0-beta.5", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ=="], + "getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], + + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hermes-compiler": ["hermes-compiler@250829098.0.14", "", {}, "sha512-5meXwsZxgiqFaJjNzwjzI9IyUkuGGBisu+z9BvQWmGVpjH6nz11hgqkyxe4dl8UAdyIV4lTbz91+Dlnjz0VxqA=="], + + "hermes-estree": ["hermes-estree@0.36.0", "", {}, "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w=="], + + "hermes-parser": ["hermes-parser@0.36.0", "", { "dependencies": { "hermes-estree": "0.36.0" } }, "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w=="], + "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], + "hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "human-id": ["human-id@4.2.0", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA=="], "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="], + "idb-keyval": ["idb-keyval@6.2.6", "", {}, "sha512-FY64UEhw+5liMzMQ1R9Mw6AF0+wyBrg1CIA1z4CjI/EvT5ty/SvQcWZgd8s9sgaNhX10Y8UzScTh89tEAls5nA=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], + "import-without-cache": ["import-without-cache@0.4.0", "", {}, "sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], + + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + + "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], @@ -510,20 +937,44 @@ "is-windows": ["is-windows@1.0.2", "", {}, "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA=="], + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jest-get-type": ["jest-get-type@29.6.3", "", {}, "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw=="], + + "jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="], + + "jest-validate": ["jest-validate@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", "chalk": "^4.0.0", "jest-get-type": "^29.6.3", "leven": "^3.1.0", "pretty-format": "^29.7.0" } }, "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw=="], + + "jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], + + "jimp-compact": ["jimp-compact@0.16.1", "", {}, "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "jsc-safe-url": ["jsc-safe-url@0.2.4", "", {}, "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q=="], + "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + + "lan-network": ["lan-network@0.2.1", "", { "bin": { "lan-network": "dist/lan-network-cli.js" } }, "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A=="], + + "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], + + "lighthouse-logger": ["lighthouse-logger@1.4.2", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -556,10 +1007,18 @@ "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + "lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="], + "lodash.throttle": ["lodash.throttle@4.1.1", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="], + + "log-symbols": ["log-symbols@2.2.0", "", { "dependencies": { "chalk": "^2.0.1" } }, "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg=="], + "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], "lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="], @@ -568,30 +1027,104 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="], + "markdown-it": ["markdown-it@14.3.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.5.0", "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw=="], + "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], + "memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="], + + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "metro": ["metro@0.84.4", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "accepts": "^2.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.35.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.84.4", "metro-cache": "0.84.4", "metro-cache-key": "0.84.4", "metro-config": "0.84.4", "metro-core": "0.84.4", "metro-file-map": "0.84.4", "metro-resolver": "0.84.4", "metro-runtime": "0.84.4", "metro-source-map": "0.84.4", "metro-symbolicate": "0.84.4", "metro-transform-plugins": "0.84.4", "metro-transform-worker": "0.84.4", "mime-types": "^3.0.1", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA=="], + + "metro-babel-transformer": ["metro-babel-transformer@0.84.4", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.35.0", "metro-cache-key": "0.84.4", "nullthrows": "^1.1.1" } }, "sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g=="], + + "metro-cache": ["metro-cache@0.84.4", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.84.4" } }, "sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg=="], + + "metro-cache-key": ["metro-cache-key@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw=="], + + "metro-config": ["metro-config@0.84.4", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.84.4", "metro-cache": "0.84.4", "metro-core": "0.84.4", "metro-runtime": "0.84.4", "yaml": "^2.6.1" } }, "sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q=="], + + "metro-core": ["metro-core@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.84.4" } }, "sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ=="], + + "metro-file-map": ["metro-file-map@0.84.4", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ=="], + + "metro-minify-terser": ["metro-minify-terser@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ=="], + + "metro-resolver": ["metro-resolver@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A=="], + + "metro-runtime": ["metro-runtime@0.84.4", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q=="], + + "metro-source-map": ["metro-source-map@0.84.4", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.84.4", "nullthrows": "^1.1.1", "ob1": "0.84.4", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g=="], + + "metro-symbolicate": ["metro-symbolicate@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.84.4", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA=="], + + "metro-transform-plugins": ["metro-transform-plugins@0.84.4", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow=="], + + "metro-transform-worker": ["metro-transform-worker@0.84.4", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "metro": "0.84.4", "metro-babel-transformer": "0.84.4", "metro-cache": "0.84.4", "metro-cache-key": "0.84.4", "metro-minify-terser": "0.84.4", "metro-source-map": "0.84.4", "metro-transform-plugins": "0.84.4", "nullthrows": "^1.1.1" } }, "sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg=="], + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="], + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "multitars": ["multitars@1.0.0", "", {}, "sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg=="], + "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], + + "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], + + "node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="], + + "npm-package-arg": ["npm-package-arg@11.0.3", "", { "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^5.0.0" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="], + + "nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="], + + "ob1": ["ob1@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA=="], + "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="], + "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], + + "ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], + "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], "oxfmt": ["oxfmt@0.56.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.56.0", "@oxfmt/binding-android-arm64": "0.56.0", "@oxfmt/binding-darwin-arm64": "0.56.0", "@oxfmt/binding-darwin-x64": "0.56.0", "@oxfmt/binding-freebsd-x64": "0.56.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.56.0", "@oxfmt/binding-linux-arm-musleabihf": "0.56.0", "@oxfmt/binding-linux-arm64-gnu": "0.56.0", "@oxfmt/binding-linux-arm64-musl": "0.56.0", "@oxfmt/binding-linux-ppc64-gnu": "0.56.0", "@oxfmt/binding-linux-riscv64-gnu": "0.56.0", "@oxfmt/binding-linux-riscv64-musl": "0.56.0", "@oxfmt/binding-linux-s390x-gnu": "0.56.0", "@oxfmt/binding-linux-x64-gnu": "0.56.0", "@oxfmt/binding-linux-x64-musl": "0.56.0", "@oxfmt/binding-openharmony-arm64": "0.56.0", "@oxfmt/binding-win32-arm64-msvc": "0.56.0", "@oxfmt/binding-win32-ia32-msvc": "0.56.0", "@oxfmt/binding-win32-x64-msvc": "0.56.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-9Dv0wV3zKiyvhjD7bRKaInKmHQ1sPx3RGOjQkGFJbbdQ16576yf8qhMSO9Q9cvHcs+1NpBsRTkuDDYFFPTJ6gw=="], @@ -610,12 +1143,20 @@ "package-manager-detector": ["package-manager-detector@0.2.11", "", { "dependencies": { "quansync": "^0.2.7" } }, "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ=="], + "parse-png": ["parse-png@2.1.0", "", { "dependencies": { "pngjs": "^3.3.0" } }, "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ=="], + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -626,34 +1167,78 @@ "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], + "plist": ["plist@3.1.1", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA=="], + + "pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], + "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="], + + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + + "promise": ["promise@8.3.0", "", { "dependencies": { "asap": "~2.0.6" } }, "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg=="], + + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], + "queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="], + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-native": ["react-native@0.86.0", "", { "dependencies": { "@react-native/assets-registry": "0.86.0", "@react-native/codegen": "0.86.0", "@react-native/community-cli-plugin": "0.86.0", "@react-native/gradle-plugin": "0.86.0", "@react-native/js-polyfills": "0.86.0", "@react-native/normalize-colors": "0.86.0", "@react-native/virtualized-lists": "0.86.0", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-plugin-syntax-hermes-parser": "0.36.0", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "hermes-compiler": "250829098.0.14", "invariant": "^2.2.4", "memoize-one": "^5.0.0", "metro-runtime": "^0.84.3", "metro-source-map": "^0.84.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "tinyglobby": "^0.2.15", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@react-native/jest-preset": "0.86.0", "@types/react": "^19.1.1", "react": "^19.2.3" }, "optionalPeers": ["@react-native/jest-preset", "@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-17ALh/dd6AO4pgOVmOO5Axll5PbErEo3XFyLokyzW6usyi+OShIEPwUW26wLPlhVifgSOIfECCH0WN+0IqtJ1w=="], + + "react-native-mmkv": ["react-native-mmkv@4.3.2", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-49OAyfkg0/TMWiWELZN6VuVQPZPhizwL4DTmp8b7B1md3dB/s3LH3mGfC3T+lp9W0y/rqxZMEnotLFTIbOAenQ=="], + + "react-native-nitro-modules": ["react-native-nitro-modules@0.36.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-kBv/VvKqAmkXAvP1DxJMC9b/fRhh7JdSO4EUnPP46hJjrIFeFR8AwKm8mYaKZEuF014M/TVdv2vomVUW0umsQQ=="], + + "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], + "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="], + "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], + + "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="], + + "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], + + "regexpu-core": ["regexpu-core@6.4.0", "", { "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" } }, "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA=="], + + "regjsgen": ["regjsgen@0.8.0", "", {}, "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q=="], + + "regjsparser": ["regjsparser@0.13.2", "", { "dependencies": { "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "resolve-workspace-root": ["resolve-workspace-root@2.0.1", "", {}, "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w=="], + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], @@ -666,54 +1251,100 @@ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], + + "serialize-error": ["serialize-error@2.1.0", "", {}, "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw=="], + "seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="], "seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="], + "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shell-quote": ["shell-quote@1.9.0", "", {}, "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "simple-plist": ["simple-plist@1.3.1", "", { "dependencies": { "bplist-creator": "0.1.0", "bplist-parser": "0.3.1", "plist": "^3.0.5" } }, "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], + "slugify": ["slugify@1.6.9", "", {}, "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg=="], + "solid-js": ["solid-js@1.9.14", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ=="], + "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="], "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="], + + "stacktrace-parser": ["stacktrace-parser@0.1.11", "", { "dependencies": { "type-fest": "^0.7.1" } }, "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + "stream-buffers": ["stream-buffers@2.2.0", "", {}, "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg=="], + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], - "string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + "structured-headers": ["structured-headers@0.4.1", "", {}, "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-hyperlinks": ["supports-hyperlinks@2.3.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], "term-size": ["term-size@2.2.1", "", {}, "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg=="], + "terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="], + + "terser": ["terser@5.48.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q=="], + + "throat": ["throat@5.0.0", "", {}, "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], @@ -728,8 +1359,14 @@ "tldts-core": ["tldts-core@7.4.6", "", {}, "sha512-TkQNGJIhlEphpHCjKodMTSe23egUZr/g+flI2qkLgiJ/maAzSgXypSLRTNH3nCmqgayEmtcJBiLcfODSAr1xoA=="], + "tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "toqr": ["toqr@0.1.1", "", {}, "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA=="], + "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], @@ -740,6 +1377,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="], + "typedoc": ["typedoc@0.28.19", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.1.1", "minimatch": "^10.2.5", "yaml": "^2.8.3" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], @@ -752,37 +1391,121 @@ "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], + + "unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="], + + "unicode-match-property-value-ecmascript": ["unicode-match-property-value-ecmascript@2.2.1", "", {}, "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg=="], + + "unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="], + "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], + + "uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="], + + "validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vite": ["vite@8.1.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.16", "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA=="], "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "vlq": ["vlq@1.0.1", "", {}, "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w=="], + "vue": ["vue@3.5.39", "", { "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/compiler-sfc": "3.5.39", "@vue/runtime-dom": "3.5.39", "@vue/server-renderer": "3.5.39", "@vue/shared": "3.5.39" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA=="], "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="], + + "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], + "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], + "whatwg-url-minimum": ["whatwg-url-minimum@0.1.2", "", {}, "sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], + "ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], + + "xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + "xml2js": ["xml2js@0.6.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="], + + "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], + "@babel/core/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/generator/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], + + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-regexp-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/parser/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], + + "@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/template/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/traverse/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/traverse/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@expo/cli/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + + "@expo/cli/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "@expo/cli/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "@expo/cli/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "@expo/metro-config/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@expo/ws-tunnel/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], @@ -794,6 +1517,8 @@ "@quansync/fs/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], + "@react-native/codegen/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@vue/compiler-core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], @@ -804,6 +1529,50 @@ "@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "babel-preset-expo/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "cli-truncate/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], + + "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "compressible/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "compression/negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], + + "connect/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "expo/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + + "expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "finalhandler/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], + + "finalhandler/on-finished": ["on-finished@2.3.0", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww=="], + + "finalhandler/statuses": ["statuses@1.5.0", "", {}, "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA=="], + + "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "jest-validate/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + + "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "log-symbols/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], + "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], "log-update/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -812,17 +1581,55 @@ "markdown-it/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "metro/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "metro/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "metro/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], + + "metro/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="], + + "metro-babel-transformer/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="], + + "metro-transform-plugins/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "metro-transform-worker/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "metro-transform-worker/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], + + "ora/cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="], + + "ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], + + "plist/@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], + + "react-native/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "read-yaml-file/js-yaml": ["js-yaml@3.15.0", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog=="], "rolldown-plugin-dts/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], + "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], + "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "terminal-link/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], + + "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "tsdown/cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], @@ -830,32 +1637,112 @@ "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "wrap-ansi/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], + "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "@vue/compiler-core/@babel/parser/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + + "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], + + "@babel/generator/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], + + "@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], + + "@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], + + "@expo/cli/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "@expo/cli/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "@expo/metro-config/@babel/generator/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "babel-preset-expo/@babel/generator/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "@vue/compiler-sfc/@babel/parser/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "expo/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "jest-validate/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "log-symbols/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], + + "log-symbols/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + + "log-symbols/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], "log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "log-update/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "log-update/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="], + + "metro-transform-plugins/@babel/generator/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "metro/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "metro/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="], + "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], + + "ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + + "ora/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + + "ora/cli-cursor/restore-cursor": ["restore-cursor@2.0.0", "", { "dependencies": { "onetime": "^2.0.0", "signal-exit": "^3.0.2" } }, "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q=="], + + "ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], + + "react-native/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "terminal-link/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + "cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "log-symbols/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], + + "log-symbols/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + + "log-update/wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "ora/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], + + "ora/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + + "ora/cli-cursor/restore-cursor/onetime": ["onetime@2.0.1", "", { "dependencies": { "mimic-fn": "^1.0.0" } }, "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ=="], + + "ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "log-symbols/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], - "@vue/compiler-sfc/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + "ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], } } diff --git a/package.json b/package.json index f43ad8d..8f4e80f 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,18 @@ "./vue": { "types": "./dist/persist-vue.d.mts", "import": "./dist/persist-vue.mjs" + }, + "./async-storage": { + "types": "./dist/persist-asyncstorage.d.mts", + "import": "./dist/persist-asyncstorage.mjs" + }, + "./mmkv": { + "types": "./dist/persist-mmkv.d.mts", + "import": "./dist/persist-mmkv.mjs" + }, + "./secure-store": { + "types": "./dist/persist-securestore.d.mts", + "import": "./dist/persist-securestore.mjs" } }, "publishConfig": { @@ -100,6 +112,7 @@ "devDependencies": { "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.31.0", + "@react-native-async-storage/async-storage": "^3.1.1", "@tanstack/intent": "0.3.4", "@tanstack/store": "0.11.0", "@testing-library/dom": "10.4.1", @@ -109,6 +122,7 @@ "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@typescript/native-preview": "7.0.0-dev.20260628.1", + "expo-secure-store": "^57.0.0", "husky": "9.1.7", "idb-keyval": "6.2.6", "jsdom": "29.1.1", @@ -117,6 +131,7 @@ "oxlint": "1.71.0", "react": "19.2.7", "react-dom": "19.2.7", + "react-native-mmkv": "^4.3.2", "seroval": "1.5.4", "solid-js": "^1.9.14", "tsdown": "0.22.3", @@ -127,9 +142,12 @@ "zod": "^4.4.3" }, "peerDependencies": { + "@react-native-async-storage/async-storage": ">=1.0.0", "@tanstack/store": ">=0.10.0", + "expo-secure-store": ">=12.0.0", "idb-keyval": ">=4.0.0", "react": "^18.0.0 || ^19.0.0", + "react-native-mmkv": ">=4.0.0", "seroval": ">=1.0.0", "solid-js": ">=1.6.0", "vue": ">=3.3.0", @@ -156,6 +174,15 @@ }, "zod": { "optional": true + }, + "@react-native-async-storage/async-storage": { + "optional": true + }, + "react-native-mmkv": { + "optional": true + }, + "expo-secure-store": { + "optional": true } }, "engines": { diff --git a/src/persist-asyncstorage.test.ts b/src/persist-asyncstorage.test.ts new file mode 100644 index 0000000..c70c37f --- /dev/null +++ b/src/persist-asyncstorage.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +// bun has no React Native runtime — fake AsyncStorage with a Map-backed +// implementation. The module under test only maps shapes; real RN behavior is +// AsyncStorage's concern. +const store = new Map(); + +mock.module("@react-native-async-storage/async-storage", () => ({ + default: { + getItem: (key: string) => Promise.resolve(store.get(key) ?? null), + setItem: (key: string, value: string) => { + store.set(key, value); + return Promise.resolve(); + }, + removeItem: (key: string) => { + store.delete(key); + return Promise.resolve(); + }, + }, +})); + +const { asyncStorageStateStorage, createAsyncStorage } = + await import("./persist-asyncstorage"); +const { persistSource } = await import("./persist-core"); + +function createMockSource(initial: T) { + let state = initial; + const listeners = new Set<() => void>(); + return { + get state() { + return state; + }, + getState: () => state, + setState: (updater: (prev: T) => T) => { + state = updater(state); + listeners.forEach((listener) => listener()); + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + return { unsubscribe: () => listeners.delete(listener) }; + }, + }; +} + +function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { + return new Promise((resolve, reject) => { + let ticks = 0; + const tick = () => { + if (hasHydrated()) { + resolve(); + return; + } + if (++ticks > maxTicks) { + reject(new Error("waitForHydration: never hydrated")); + return; + } + queueMicrotask(tick); + }; + tick(); + }); +} + +describe("persist-asyncstorage", () => { + beforeEach(() => store.clear()); + + it("asyncStorageStateStorage maps the async backend", async () => { + const storage = asyncStorageStateStorage(); + expect(await storage.getItem("missing")).toBeNull(); + + await storage.setItem("k", "v"); + expect(await storage.getItem("k")).toBe("v"); + + await storage.removeItem("k"); + expect(await storage.getItem("k")).toBeNull(); + }); + + it("createAsyncStorage round-trips through persistSource", async () => { + const storage = createAsyncStorage<{ count: number }>()!; + + const source = createMockSource({ count: 0 }); + const persist = persistSource(source, { name: "async-json", storage }); + await waitForHydration(persist.hasHydrated); + + source.setState(() => ({ count: 7 })); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect((await storage.getItem("async-json"))?.state.count).toBe(7); + + const source2 = createMockSource({ count: 0 }); + const persist2 = persistSource(source2, { + name: "async-json", + storage, + skipHydration: true, + }); + await persist2.rehydrate(); + expect(source2.state.count).toBe(7); + + persist.destroy(); + persist2.destroy(); + }); + + it("no sibling entry IMPORTS persist-asyncstorage (dependency isolation)", async () => { + for (const sibling of [ + "persist-core.ts", + "persist-seroval.ts", + "persist-idb.ts", + "persist-crosstab.ts", + "persist-zod.ts", + "persist-tanstack.ts", + "persist-solid.ts", + "persist-vue.ts", + "hydration.ts", + "use-hydrated.ts", + "index.ts", + ]) { + const url = new URL(`./${sibling}`, import.meta.url); + if (!(await Bun.file(url).exists())) continue; + + const source = await Bun.file(url).text(); + const offendingImports = source.match( + /(?:from\s+|import\s*\(\s*)["']\.\/persist-asyncstorage["']/g, + ); + expect(offendingImports).toBeNull(); + } + }); +}); diff --git a/src/persist-asyncstorage.ts b/src/persist-asyncstorage.ts new file mode 100644 index 0000000..797b142 --- /dev/null +++ b/src/persist-asyncstorage.ts @@ -0,0 +1,47 @@ +// React Native AsyncStorage entry — owns the +// `@react-native-async-storage/async-storage` dependency so the core stays +// zero-dep. Ships as its own subpath entry with that peer optional. No barrel +// re-exports this module: importing it directly IS the dependency opt-in +// (enforced by an isolation test). +import AsyncStorage from "@react-native-async-storage/async-storage"; +import type { AsyncStorage as AsyncStorageInstance } from "@react-native-async-storage/async-storage"; + +import type { StateStorage } from "./persist-core"; +import { createJSONStorage } from "./persist-core"; + +/** + * `StateStorage` over React Native `AsyncStorage` — fully async, string-wire. + * Pass a custom instance (e.g. `getLegacyStorage()`, or `createAsyncStorage(name)`) + * to namespace away from the default; defaults to the module singleton. + * + * @example + * ```ts + * const storage = asyncStorageStateStorage(); + * ``` + */ +export function asyncStorageStateStorage( + storage: AsyncStorageInstance = AsyncStorage, +): StateStorage { + return { + getItem: (name) => storage.getItem(name), + setItem: (name, value) => storage.setItem(name, value), + removeItem: (name) => storage.removeItem(name), + }; +} + +/** + * Build a JSON-encoded `PersistStorage` over React Native `AsyncStorage`. + * Async backend → hydration can't settle before first render, so `useHydrated` + * gating is mandatory (the `./react` / `./solid` / `./vue` adapters). + * + * @example + * ```ts + * const storage = createAsyncStorage()!; + * persistStore(store, { name: "app:prefs:v1", storage }); + * ``` + */ +export function createAsyncStorage( + storage: AsyncStorageInstance = AsyncStorage, +) { + return createJSONStorage(() => asyncStorageStateStorage(storage)); +} diff --git a/src/persist-mmkv.test.ts b/src/persist-mmkv.test.ts new file mode 100644 index 0000000..5463e60 --- /dev/null +++ b/src/persist-mmkv.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +import type { MMKV } from "react-native-mmkv"; + +// Fake MMKV instance backed by a Map; createMMKV returns one per id. Cast to +// `MMKV` — the adapter only calls `getString`/`set`/`remove`; the HybridObject +// base props (`name`/`equals`/`dispose`/…) are unused in tests. +const instances = new Map>(); +function fakeInstance(id: string): MMKV { + let map = instances.get(id); + if (!map) { + map = new Map(); + instances.set(id, map); + } + return { + id, + set: (key: string, value: boolean | string | number | ArrayBuffer) => { + map!.set(key, String(value)); + }, + getString: (key: string) => map!.get(key) as string | undefined, + remove: (key: string) => map!.delete(key), + contains: (key: string) => map!.has(key), + getAllKeys: () => [...map!.keys()], + clearAll: () => map!.clear(), + } as unknown as MMKV; +} + +mock.module("react-native-mmkv", () => ({ + createMMKV: (config: { id: string }) => fakeInstance(config.id), +})); + +const { mmkvStateStorage, createMmkvStorage } = await import("./persist-mmkv"); +const { persistSource } = await import("./persist-core"); + +function createMockSource(initial: T) { + let state = initial; + const listeners = new Set<() => void>(); + return { + get state() { + return state; + }, + getState: () => state, + setState: (updater: (prev: T) => T) => { + state = updater(state); + listeners.forEach((listener) => listener()); + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + return { unsubscribe: () => listeners.delete(listener) }; + }, + }; +} + +function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { + return new Promise((resolve, reject) => { + let ticks = 0; + const tick = () => { + if (hasHydrated()) { + resolve(); + return; + } + // Bounded: a hydration regression fails loudly here instead of hanging + // the suite until the runner's opaque timeout. + if (++ticks > maxTicks) { + reject(new Error("waitForHydration: never hydrated")); + return; + } + queueMicrotask(tick); + }; + tick(); + }); +} + +describe("persist-mmkv", () => { + beforeEach(() => instances.clear()); + + it("mmkvStateStorage round-trips synchronously", () => { + const instance = fakeInstance("state"); + const storage = mmkvStateStorage(instance); + + expect(storage.getItem("missing")).toBeNull(); + + storage.setItem("k", "v"); + expect(storage.getItem("k")).toBe("v"); + + storage.removeItem("k"); + expect(storage.getItem("k")).toBeNull(); + }); + + it("createMmkvStorage round-trips through persistSource", async () => { + const storage = createMmkvStorage<{ count: number }>({ id: "test" })!; + + const source = createMockSource({ count: 0 }); + const persist = persistSource(source, { name: "mmkv-json", storage }); + await waitForHydration(persist.hasHydrated); + + source.setState(() => ({ count: 7 })); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect((await storage.getItem("mmkv-json"))?.state.count).toBe(7); + + const source2 = createMockSource({ count: 0 }); + const persist2 = persistSource(source2, { + name: "mmkv-json", + storage, + skipHydration: true, + }); + await persist2.rehydrate(); + expect(source2.state.count).toBe(7); + + persist.destroy(); + persist2.destroy(); + }); + + it("no sibling entry IMPORTS persist-mmkv (dependency isolation)", async () => { + for (const sibling of [ + "persist-core.ts", + "persist-seroval.ts", + "persist-idb.ts", + "persist-crosstab.ts", + "persist-zod.ts", + "persist-tanstack.ts", + "persist-solid.ts", + "persist-vue.ts", + "persist-asyncstorage.ts", + "hydration.ts", + "use-hydrated.ts", + "index.ts", + ]) { + const url = new URL(`./${sibling}`, import.meta.url); + if (!(await Bun.file(url).exists())) continue; + + const source = await Bun.file(url).text(); + const offendingImports = source.match( + /(?:from\s+|import\s*\(\s*)["']\.\/persist-mmkv["']/g, + ); + expect(offendingImports).toBeNull(); + } + }); +}); diff --git a/src/persist-mmkv.ts b/src/persist-mmkv.ts new file mode 100644 index 0000000..1b2d6c2 --- /dev/null +++ b/src/persist-mmkv.ts @@ -0,0 +1,61 @@ +// React Native MMKV entry — owns the `react-native-mmkv` dependency so the +// core stays zero-dep. Ships as its own subpath entry with that peer optional. +// No barrel re-exports this module: importing it directly IS the dependency +// opt-in (enforced by an isolation test). +import { createMMKV } from "react-native-mmkv"; +import type { MMKV } from "react-native-mmkv"; + +import type { StateStorage } from "./persist-core"; +import { createJSONStorage } from "./persist-core"; + +/** + * `StateStorage` over a `react-native-mmkv` `MMKV` instance — synchronous, + * string-wire. Pass an instance from `createMMKV({ id, encryptionKey?, path? })`. + * + * @example + * ```ts + * const instance = createMMKV({ id: "app-prefs" }); + * const storage = mmkvStateStorage(instance); + * ``` + */ +export function mmkvStateStorage(instance: MMKV): StateStorage { + return { + getItem: (name) => instance.getString(name) ?? null, + setItem: (name, value) => { + instance.set(name, value); + }, + removeItem: (name) => { + instance.remove(name); + }, + }; +} + +export interface MmkvStorageOptions { + /** MMKV instance id — namespaces persisted state into its own file. */ + id: string; + /** Custom MMKV root path. */ + path?: string; + /** Encryption key (max 16 bytes) — MMKV encrypts the file at rest. */ + encryptionKey?: string; +} + +/** + * Build a JSON-encoded `PersistStorage` over a fresh `react-native-mmkv` + * instance. Synchronous backend → hydration settles before first render → + * no `useHydrated` gate required (unlike IndexedDB / AsyncStorage). MMKV is + * the fastest RN KV store; pair `encryptionKey` for secrets-at-rest. + * + * @example + * ```ts + * const storage = createMmkvStorage({ id: "app-prefs" })!; + * persistStore(store, { name: "app:prefs:v1", storage }); + * ``` + */ +export function createMmkvStorage(options: MmkvStorageOptions) { + const instance = createMMKV({ + id: options.id, + path: options.path, + encryptionKey: options.encryptionKey, + }); + return createJSONStorage(() => mmkvStateStorage(instance)); +} diff --git a/src/persist-securestore.test.ts b/src/persist-securestore.test.ts new file mode 100644 index 0000000..12a931a --- /dev/null +++ b/src/persist-securestore.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +const store = new Map(); + +mock.module("expo-secure-store", () => ({ + getItemAsync: (key: string) => Promise.resolve(store.get(key) ?? null), + setItemAsync: (key: string, value: string) => { + store.set(key, value); + return Promise.resolve(); + }, + deleteItemAsync: (key: string) => { + store.delete(key); + return Promise.resolve(); + }, +})); + +const { secureStoreStateStorage, createSecureStoreStorage } = + await import("./persist-securestore"); +const { persistSource } = await import("./persist-core"); + +function createMockSource(initial: T) { + let state = initial; + const listeners = new Set<() => void>(); + return { + get state() { + return state; + }, + getState: () => state, + setState: (updater: (prev: T) => T) => { + state = updater(state); + listeners.forEach((listener) => listener()); + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + return { unsubscribe: () => listeners.delete(listener) }; + }, + }; +} + +function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { + return new Promise((resolve, reject) => { + let ticks = 0; + const tick = () => { + if (hasHydrated()) { + resolve(); + return; + } + if (++ticks > maxTicks) { + reject(new Error("waitForHydration: never hydrated")); + return; + } + queueMicrotask(tick); + }; + tick(); + }); +} + +describe("persist-securestore", () => { + beforeEach(() => store.clear()); + + it("secureStoreStateStorage maps the async backend", async () => { + const storage = secureStoreStateStorage(); + expect(await storage.getItem("missing")).toBeNull(); + + await storage.setItem("k", "v"); + expect(await storage.getItem("k")).toBe("v"); + + await storage.removeItem("k"); + expect(await storage.getItem("k")).toBeNull(); + }); + + it("createSecureStoreStorage round-trips through persistSource", async () => { + const storage = createSecureStoreStorage<{ count: number }>()!; + + const source = createMockSource({ count: 0 }); + const persist = persistSource(source, { name: "secure-json", storage }); + await waitForHydration(persist.hasHydrated); + + source.setState(() => ({ count: 7 })); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect((await storage.getItem("secure-json"))?.state.count).toBe(7); + + const source2 = createMockSource({ count: 0 }); + const persist2 = persistSource(source2, { + name: "secure-json", + storage, + skipHydration: true, + }); + await persist2.rehydrate(); + expect(source2.state.count).toBe(7); + + persist.destroy(); + persist2.destroy(); + }); + + it("no sibling entry IMPORTS persist-securestore (dependency isolation)", async () => { + for (const sibling of [ + "persist-core.ts", + "persist-seroval.ts", + "persist-idb.ts", + "persist-crosstab.ts", + "persist-zod.ts", + "persist-tanstack.ts", + "persist-solid.ts", + "persist-vue.ts", + "persist-asyncstorage.ts", + "persist-mmkv.ts", + "hydration.ts", + "use-hydrated.ts", + "index.ts", + ]) { + const url = new URL(`./${sibling}`, import.meta.url); + if (!(await Bun.file(url).exists())) continue; + + const source = await Bun.file(url).text(); + const offendingImports = source.match( + /(?:from\s+|import\s*\(\s*)["']\.\/persist-securestore["']/g, + ); + expect(offendingImports).toBeNull(); + } + }); +}); diff --git a/src/persist-securestore.ts b/src/persist-securestore.ts new file mode 100644 index 0000000..ba65f02 --- /dev/null +++ b/src/persist-securestore.ts @@ -0,0 +1,45 @@ +// expo-secure-store entry — owns the `expo-secure-store` dependency so the +// core stays zero-dep. Ships as its own subpath entry with that peer optional. +// No barrel re-exports this module: importing it directly IS the dependency +// opt-in (enforced by an isolation test). +import * as SecureStore from "expo-secure-store"; + +import type { StateStorage } from "./persist-core"; +import { createJSONStorage } from "./persist-core"; + +/** + * `StateStorage` over `expo-secure-store` — fully async, string-wire, backed + * by the platform keychain/keystore. Values are encrypted at rest by the OS. + * + * **~2KB value limit per key** (platform-imposed) — use this for small secrets + * (auth tokens, short prefs), not large state. Oversized writes reject; pair + * `partialize` to persist only a small slice. + * + * @example + * ```ts + * const storage = secureStoreStateStorage(); + * ``` + */ +export function secureStoreStateStorage(): StateStorage { + return { + getItem: (name) => SecureStore.getItemAsync(name), + setItem: (name, value) => SecureStore.setItemAsync(name, value), + removeItem: (name) => SecureStore.deleteItemAsync(name), + }; +} + +/** + * Build a JSON-encoded `PersistStorage` over `expo-secure-store`. Async backend + * → `useHydrated` gating is mandatory. **~2KB value limit per key** — keep + * persisted state small; use `partialize` to project a tiny slice (e.g. an auth + * token) and a different backend (IndexedDB, MMKV) for larger state. + * + * @example + * ```ts + * const storage = createSecureStoreStorage()!; + * persistStore(store, { name: "auth:token:v1", storage, partialize: (s) => s.token }); + * ``` + */ +export function createSecureStoreStorage() { + return createJSONStorage(() => secureStoreStateStorage()); +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 2c9fa5d..af1a7aa 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -14,6 +14,9 @@ export default defineConfig({ "src/persist-zod.ts", "src/persist-solid.ts", "src/persist-vue.ts", + "src/persist-asyncstorage.ts", + "src/persist-mmkv.ts", + "src/persist-securestore.ts", "src/persist-tanstack.ts", "src/use-hydrated.ts", ], @@ -30,6 +33,9 @@ export default defineConfig({ "zod", "solid-js", "vue", + "@react-native-async-storage/async-storage", + "react-native-mmkv", + "expo-secure-store", ], }, clean: true, From 94b2fd65d72daf0362054ab06df6555d1960df18 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sat, 4 Jul 2026 19:35:31 +0300 Subject: [PATCH 07/77] refactor(src): refold into core/ + adapters//; drop persist- prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure for pristine scalable contributions: src/ splits at the dependency-direction boundary into core/ (zero-dep engine + the `.` entry) and adapters// (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/.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). --- docs/architecture.md | 39 ++++++++++++---- package.json | 44 +++++++++---------- .../backends/async-storage.test.ts} | 35 +++++---------- .../backends/async-storage.ts} | 4 +- .../backends/idb.test.ts} | 37 +++++----------- .../backends/idb.ts} | 6 +-- .../backends/mmkv.test.ts} | 34 ++++---------- .../backends/mmkv.ts} | 4 +- .../backends/secure-store.test.ts} | 37 +++++----------- .../backends/secure-store.ts} | 4 +- .../codecs/seroval.test.ts} | 20 +++++++-- .../codecs/seroval.ts} | 4 +- .../codecs/zod.test.ts} | 36 +++++---------- .../codecs/zod.ts} | 4 +- .../frameworks/react.test.ts} | 22 ++++++++-- .../frameworks/react.ts} | 2 +- .../frameworks/solid.test.ts} | 35 +++++---------- .../frameworks/solid.ts} | 2 +- .../frameworks/vue.test.ts} | 37 ++++------------ .../frameworks/vue.ts} | 2 +- .../sources/tanstack-store.test.ts} | 20 +++++++-- .../sources/tanstack-store.ts} | 4 +- .../transport/crosstab.test.ts} | 32 +++++--------- .../transport/crosstab.ts} | 2 +- src/{ => core}/hydration.test.ts | 0 src/{ => core}/hydration.ts | 0 src/core/index.ts | 6 +++ src/{ => core}/persist-core.test.ts | 4 +- src/{ => core}/persist-core.ts | 0 src/index.ts | 6 --- .../{use-hydrated.test.tsx => react.test.tsx} | 10 ++--- tsdown.config.ts | 36 ++++++++------- typedoc.json | 17 ++++--- 33 files changed, 252 insertions(+), 293 deletions(-) rename src/{persist-asyncstorage.test.ts => adapters/backends/async-storage.test.ts} (77%) rename src/{persist-asyncstorage.ts => adapters/backends/async-storage.ts} (93%) rename src/{persist-idb.test.ts => adapters/backends/idb.test.ts} (81%) rename src/{persist-idb.ts => adapters/backends/idb.ts} (94%) rename src/{persist-mmkv.test.ts => adapters/backends/mmkv.test.ts} (80%) rename src/{persist-mmkv.ts => adapters/backends/mmkv.ts} (94%) rename src/{persist-securestore.test.ts => adapters/backends/secure-store.test.ts} (74%) rename src/{persist-securestore.ts => adapters/backends/secure-store.ts} (93%) rename src/{persist-seroval.test.ts => adapters/codecs/seroval.test.ts} (88%) rename src/{persist-seroval.ts => adapters/codecs/seroval.ts} (93%) rename src/{persist-zod.test.ts => adapters/codecs/zod.test.ts} (80%) rename src/{persist-zod.ts => adapters/codecs/zod.ts} (95%) rename src/{use-hydrated.test.ts => adapters/frameworks/react.test.ts} (88%) rename src/{use-hydrated.ts => adapters/frameworks/react.ts} (96%) rename src/{persist-solid.test.ts => adapters/frameworks/solid.test.ts} (75%) rename src/{persist-solid.ts => adapters/frameworks/solid.ts} (96%) rename src/{persist-vue.test.ts => adapters/frameworks/vue.test.ts} (57%) rename src/{persist-vue.ts => adapters/frameworks/vue.ts} (96%) rename src/{persist-tanstack.test.ts => adapters/sources/tanstack-store.test.ts} (82%) rename src/{persist-tanstack.ts => adapters/sources/tanstack-store.ts} (95%) rename src/{persist-crosstab.test.ts => adapters/transport/crosstab.test.ts} (87%) rename src/{persist-crosstab.ts => adapters/transport/crosstab.ts} (99%) rename src/{ => core}/hydration.test.ts (100%) rename src/{ => core}/hydration.ts (100%) create mode 100644 src/core/index.ts rename src/{ => core}/persist-core.test.ts (99%) rename src/{ => core}/persist-core.ts (100%) delete mode 100644 src/index.ts rename tests-dom/{use-hydrated.test.tsx => react.test.tsx} (93%) diff --git a/docs/architecture.md b/docs/architecture.md index 1c09057..46f61f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,19 +16,40 @@ Persistence is bound to a structural `PersistableSource` (`getState` / `setState ## Entry points (one subpath = one optional peer) -| Entry | Module | Optional peer | -| ---------------------------------------- | ---------------------------- | --------------------------------------------- | -| `@stainless-code/persist` | `persist-core` + `hydration` | none (zero-dep core, enforced by a gate test) | -| `@stainless-code/persist/seroval` | `persist-seroval` | `seroval` | -| `@stainless-code/persist/idb` | `persist-idb` | `idb-keyval` | -| `@stainless-code/persist/tanstack-store` | `persist-tanstack` | `@tanstack/store` (types only) | -| `@stainless-code/persist/react` | `use-hydrated` | `react` | +| Entry | Module | Optional peer | +| ---------------------------------------- | ------------------------------------------- | --------------------------------------------- | +| `@stainless-code/persist` | `core/index` (`persist-core` + `hydration`) | none (zero-dep core, enforced by a gate test) | +| `@stainless-code/persist/seroval` | `adapters/codecs/seroval` | `seroval` | +| `@stainless-code/persist/zod` | `adapters/codecs/zod` | `zod` | +| `@stainless-code/persist/idb` | `adapters/backends/idb` | `idb-keyval` | +| `@stainless-code/persist/async-storage` | `adapters/backends/async-storage` | `@react-native-async-storage/async-storage` | +| `@stainless-code/persist/mmkv` | `adapters/backends/mmkv` | `react-native-mmkv` | +| `@stainless-code/persist/secure-store` | `adapters/backends/secure-store` | `expo-secure-store` | +| `@stainless-code/persist/crosstab` | `adapters/transport/crosstab` | none (web global) | +| `@stainless-code/persist/tanstack-store` | `adapters/sources/tanstack-store` | `@tanstack/store` (types only) | +| `@stainless-code/persist/react` | `adapters/frameworks/react` | `react` | +| `@stainless-code/persist/solid` | `adapters/frameworks/solid` | `solid-js` | +| `@stainless-code/persist/vue` | `adapters/frameworks/vue` | `vue` | No barrel — importing a subpath is the dependency opt-in. Each subpath entry owns its peer dep, which stays external in the build (`tsdown.config.ts` `neverBundle`) so consumers tree-shake cleanly. +## Folder layout + +`src/` splits at the dependency-direction boundary: + +- **`core/`** — the zero-dep engine (`persist-core.ts`, `hydration.ts`) plus `index.ts` (the `.` entry that re-exports both). Nothing in `core/` imports an adapter. +- **`adapters//`** — opt-in entries that own an optional peer and import only from `core/`: + - `codecs/` — `StorageCodec` adapters (seroval, zod) + - `backends/` — `StateStorage` adapters (idb, async-storage, mmkv, secure-store) + - `transport/` — `CrossTabEventTarget` adapters (crosstab — BroadcastChannel bridge) + - `sources/` — `PersistableSource` adapters (tanstack-store) + - `frameworks/` — `HydrationSignal` framework adapters (react, solid, vue) + +A per-entry self-check test pins the invariant: every adapter's relative imports resolve into `core/` (no cross-adapter coupling). `dist/` is flat (`dist/.mjs`) via tsdown's record-form `entry`, regardless of src depth. + ## Hydration lifecycle -`persistSource` hydrates on create (skip with `skipHydration`; `rehydrate()` is awaitable), subscribe-writes on every `setState` (gated until hydrated; optional trailing `throttleMs`), and tears down via `destroy()`. The hydration signal (`HydrationSignal` from `hydration`) is observed from outside the store — framework adapters mount it into their external-store mechanism (React `useSyncExternalStore` via `useHydrated`, Svelte `createSubscriber`, Solid `from`, Vue `shallowRef` + watch) without coupling to the store's read path. SSR policy: render `hydrated = true` on the server; `null` signal = no persistence = hydrated. +`persistSource` hydrates on create (skip with `skipHydration`; `rehydrate()` is awaitable), subscribe-writes on every `setState` (gated until hydrated; optional trailing `throttleMs`), and tears down via `destroy()`. The hydration signal (`HydrationSignal` from `hydration`) is observed from outside the store — framework adapters mount it into their external-store mechanism (React `useSyncExternalStore` via `./react`, Solid `from` via `./solid`, Vue `shallowRef` + `onScopeDispose` via `./vue`; a Svelte `createSubscriber` sketch is in the README) without coupling to the store's read path. SSR policy: render `hydrated = true` on the server; `null` signal = no persistence = hydrated. ## Sync vs async @@ -43,7 +64,7 @@ One API. Sync backends (localStorage) settle hydration before first paint; async - **Public surface** — every export from an entry point is the public API and carries JSDoc that reads well in hovers and published typings. `@default` / `@example` tags survive into the shipped `.d.mts` (tsdown dts preserves JSDoc). No `@internal` tags are currently warranted — every export is part of the public surface; `stripInternal: true` is set in `tsconfig.json` as a forward-looking guard so any future `@internal`-marked member is dropped from the dts. - **`PersistStorage.raw`** — stays **public** (semi-public seam): set by `createStorage` and identity-compared by cross-tab rehydrate; hand-rolled `PersistStorage` implementations may omit it. Typed `unknown` deliberately (only identity matters; typing it `StateStorage` would cascade the wire-type generic for no benefit). - **Internal aliases** — non-exported helper types (e.g. the listener alias) are kept out of public signatures: `PersistApi.onHydrate` / `onFinishHydration` inline `(state: TState) => void` so the shipped dts never leaks an unexported type name into a hover. The internal alias is reserved for the implementation's listener Sets. -- **API reference** — `bun run docs:api` runs [TypeDoc](https://typedoc.org) (`typedoc.json`) over the five entry points to a static HTML site under `docs/api/` (git-ignored). `treatWarningsAsErrors` + `validation.invalidLink` gate unresolved `{@link}` targets; all current `{@link}` resolve within the `index` core entry (no cross-entry links). +- **API reference** — `bun run docs:api` runs [TypeDoc](https://typedoc.org) (`typedoc.json`) over the twelve entry points to a static HTML site under `docs/api/` (git-ignored). `treatWarningsAsErrors` + `validation.invalidLink` gate unresolved `{@link}` targets; all current `{@link}` resolve within the `index` core entry (no cross-entry links). ## Test matrix diff --git a/package.json b/package.json index 8f4e80f..e2db661 100644 --- a/package.json +++ b/package.json @@ -35,48 +35,48 @@ "import": "./dist/index.mjs" }, "./seroval": { - "types": "./dist/persist-seroval.d.mts", - "import": "./dist/persist-seroval.mjs" + "types": "./dist/seroval.d.mts", + "import": "./dist/seroval.mjs" }, "./idb": { - "types": "./dist/persist-idb.d.mts", - "import": "./dist/persist-idb.mjs" + "types": "./dist/idb.d.mts", + "import": "./dist/idb.mjs" }, "./tanstack-store": { - "types": "./dist/persist-tanstack.d.mts", - "import": "./dist/persist-tanstack.mjs" + "types": "./dist/tanstack-store.d.mts", + "import": "./dist/tanstack-store.mjs" }, "./react": { - "types": "./dist/use-hydrated.d.mts", - "import": "./dist/use-hydrated.mjs" + "types": "./dist/react.d.mts", + "import": "./dist/react.mjs" }, "./crosstab": { - "types": "./dist/persist-crosstab.d.mts", - "import": "./dist/persist-crosstab.mjs" + "types": "./dist/crosstab.d.mts", + "import": "./dist/crosstab.mjs" }, "./zod": { - "types": "./dist/persist-zod.d.mts", - "import": "./dist/persist-zod.mjs" + "types": "./dist/zod.d.mts", + "import": "./dist/zod.mjs" }, "./solid": { - "types": "./dist/persist-solid.d.mts", - "import": "./dist/persist-solid.mjs" + "types": "./dist/solid.d.mts", + "import": "./dist/solid.mjs" }, "./vue": { - "types": "./dist/persist-vue.d.mts", - "import": "./dist/persist-vue.mjs" + "types": "./dist/vue.d.mts", + "import": "./dist/vue.mjs" }, "./async-storage": { - "types": "./dist/persist-asyncstorage.d.mts", - "import": "./dist/persist-asyncstorage.mjs" + "types": "./dist/async-storage.d.mts", + "import": "./dist/async-storage.mjs" }, "./mmkv": { - "types": "./dist/persist-mmkv.d.mts", - "import": "./dist/persist-mmkv.mjs" + "types": "./dist/mmkv.d.mts", + "import": "./dist/mmkv.mjs" }, "./secure-store": { - "types": "./dist/persist-securestore.d.mts", - "import": "./dist/persist-securestore.mjs" + "types": "./dist/secure-store.d.mts", + "import": "./dist/secure-store.mjs" } }, "publishConfig": { diff --git a/src/persist-asyncstorage.test.ts b/src/adapters/backends/async-storage.test.ts similarity index 77% rename from src/persist-asyncstorage.test.ts rename to src/adapters/backends/async-storage.test.ts index c70c37f..c36f3e6 100644 --- a/src/persist-asyncstorage.test.ts +++ b/src/adapters/backends/async-storage.test.ts @@ -20,8 +20,8 @@ mock.module("@react-native-async-storage/async-storage", () => ({ })); const { asyncStorageStateStorage, createAsyncStorage } = - await import("./persist-asyncstorage"); -const { persistSource } = await import("./persist-core"); + await import("./async-storage"); +const { persistSource } = await import("../../core/persist-core"); function createMockSource(initial: T) { let state = initial; @@ -98,28 +98,15 @@ describe("persist-asyncstorage", () => { persist2.destroy(); }); - it("no sibling entry IMPORTS persist-asyncstorage (dependency isolation)", async () => { - for (const sibling of [ - "persist-core.ts", - "persist-seroval.ts", - "persist-idb.ts", - "persist-crosstab.ts", - "persist-zod.ts", - "persist-tanstack.ts", - "persist-solid.ts", - "persist-vue.ts", - "hydration.ts", - "use-hydrated.ts", - "index.ts", - ]) { - const url = new URL(`./${sibling}`, import.meta.url); - if (!(await Bun.file(url).exists())) continue; - - const source = await Bun.file(url).text(); - const offendingImports = source.match( - /(?:from\s+|import\s*\(\s*)["']\.\/persist-asyncstorage["']/g, - ); - expect(offendingImports).toBeNull(); + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file( + new URL("./async-storage.ts", import.meta.url), + ).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); } }); }); diff --git a/src/persist-asyncstorage.ts b/src/adapters/backends/async-storage.ts similarity index 93% rename from src/persist-asyncstorage.ts rename to src/adapters/backends/async-storage.ts index 797b142..32a0786 100644 --- a/src/persist-asyncstorage.ts +++ b/src/adapters/backends/async-storage.ts @@ -6,8 +6,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import type { AsyncStorage as AsyncStorageInstance } from "@react-native-async-storage/async-storage"; -import type { StateStorage } from "./persist-core"; -import { createJSONStorage } from "./persist-core"; +import type { StateStorage } from "../../core/persist-core"; +import { createJSONStorage } from "../../core/persist-core"; /** * `StateStorage` over React Native `AsyncStorage` — fully async, string-wire. diff --git a/src/persist-idb.test.ts b/src/adapters/backends/idb.test.ts similarity index 81% rename from src/persist-idb.test.ts rename to src/adapters/backends/idb.test.ts index 641fb1a..9ff53d1 100644 --- a/src/persist-idb.test.ts +++ b/src/adapters/backends/idb.test.ts @@ -32,9 +32,10 @@ mock.module("idb-keyval", () => ({ }, })); -const { createIdbStorage, idbStateStorage } = await import("./persist-idb"); -const { createStorage, persistSource } = await import("./persist-core"); -const { serovalCodec } = await import("./persist-seroval"); +const { createIdbStorage, idbStateStorage } = await import("./idb"); +const { createStorage, persistSource } = + await import("../../core/persist-core"); +const { serovalCodec } = await import("../codecs/seroval"); function createMockSource(initial: T) { let state = initial; @@ -172,29 +173,13 @@ describe("persist-idb", () => { expect(defaultStore.has("shared-key")).toBe(false); }); - it("no sibling entry IMPORTS persist-idb (dependency isolation)", async () => { - // Each entry owns its dependency; importing persist-idb is the idb-keyval - // opt-in. Core/seroval/tanstack/hydration must never pull it in (doc - // comments may mention it — only import lines count). - for (const sibling of [ - "persist-core.ts", - "persist-seroval.ts", - "persist-tanstack.ts", - "hydration.ts", - "use-hydrated.ts", - ]) { - const source = await Bun.file( - new URL(`./${sibling}`, import.meta.url), - ).text(); - // Declaration-level matching (not per-line): a formatter can wrap an - // import across lines, which a `^import` line filter would miss. Any - // `from "./persist-idb"` clause or dynamic `import("./persist-idb")` - // is an import regardless of layout; doc-comment mentions never carry - // the from/import() syntax. - const offendingImports = source.match( - /(?:from\s+|import\s*\(\s*)["']\.\/persist-idb["']/g, - ); - expect(offendingImports).toBeNull(); + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file(new URL("./idb.ts", import.meta.url)).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); } }); }); diff --git a/src/persist-idb.ts b/src/adapters/backends/idb.ts similarity index 94% rename from src/persist-idb.ts rename to src/adapters/backends/idb.ts index 357ced8..0c3672b 100644 --- a/src/persist-idb.ts +++ b/src/adapters/backends/idb.ts @@ -10,8 +10,8 @@ import type { PersistStorage, StateStorage, StorageValue, -} from "./persist-core"; -import { createStorage, identityCodec } from "./persist-core"; +} from "../../core/persist-core"; +import { createStorage, identityCodec } from "../../core/persist-core"; export interface IdbStorageOptions extends CreateStorageOptions { /** @@ -66,7 +66,7 @@ export function idbStateStorage( * * @example * ```ts - * import { createIdbStorage } from "./persist-idb"; + * import { createIdbStorage } from "@stainless-code/persist/idb"; * * const storage = createIdbStorage(); // Set/Map/Date just work * ``` diff --git a/src/persist-mmkv.test.ts b/src/adapters/backends/mmkv.test.ts similarity index 80% rename from src/persist-mmkv.test.ts rename to src/adapters/backends/mmkv.test.ts index 5463e60..166a3db 100644 --- a/src/persist-mmkv.test.ts +++ b/src/adapters/backends/mmkv.test.ts @@ -29,8 +29,8 @@ mock.module("react-native-mmkv", () => ({ createMMKV: (config: { id: string }) => fakeInstance(config.id), })); -const { mmkvStateStorage, createMmkvStorage } = await import("./persist-mmkv"); -const { persistSource } = await import("./persist-core"); +const { mmkvStateStorage, createMmkvStorage } = await import("./mmkv"); +const { persistSource } = await import("../../core/persist-core"); function createMockSource(initial: T) { let state = initial; @@ -111,29 +111,13 @@ describe("persist-mmkv", () => { persist2.destroy(); }); - it("no sibling entry IMPORTS persist-mmkv (dependency isolation)", async () => { - for (const sibling of [ - "persist-core.ts", - "persist-seroval.ts", - "persist-idb.ts", - "persist-crosstab.ts", - "persist-zod.ts", - "persist-tanstack.ts", - "persist-solid.ts", - "persist-vue.ts", - "persist-asyncstorage.ts", - "hydration.ts", - "use-hydrated.ts", - "index.ts", - ]) { - const url = new URL(`./${sibling}`, import.meta.url); - if (!(await Bun.file(url).exists())) continue; - - const source = await Bun.file(url).text(); - const offendingImports = source.match( - /(?:from\s+|import\s*\(\s*)["']\.\/persist-mmkv["']/g, - ); - expect(offendingImports).toBeNull(); + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file(new URL("./mmkv.ts", import.meta.url)).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); } }); }); diff --git a/src/persist-mmkv.ts b/src/adapters/backends/mmkv.ts similarity index 94% rename from src/persist-mmkv.ts rename to src/adapters/backends/mmkv.ts index 1b2d6c2..6625341 100644 --- a/src/persist-mmkv.ts +++ b/src/adapters/backends/mmkv.ts @@ -5,8 +5,8 @@ import { createMMKV } from "react-native-mmkv"; import type { MMKV } from "react-native-mmkv"; -import type { StateStorage } from "./persist-core"; -import { createJSONStorage } from "./persist-core"; +import type { StateStorage } from "../../core/persist-core"; +import { createJSONStorage } from "../../core/persist-core"; /** * `StateStorage` over a `react-native-mmkv` `MMKV` instance — synchronous, diff --git a/src/persist-securestore.test.ts b/src/adapters/backends/secure-store.test.ts similarity index 74% rename from src/persist-securestore.test.ts rename to src/adapters/backends/secure-store.test.ts index 12a931a..aeb7481 100644 --- a/src/persist-securestore.test.ts +++ b/src/adapters/backends/secure-store.test.ts @@ -15,8 +15,8 @@ mock.module("expo-secure-store", () => ({ })); const { secureStoreStateStorage, createSecureStoreStorage } = - await import("./persist-securestore"); -const { persistSource } = await import("./persist-core"); + await import("./secure-store"); +const { persistSource } = await import("../../core/persist-core"); function createMockSource(initial: T) { let state = initial; @@ -93,30 +93,15 @@ describe("persist-securestore", () => { persist2.destroy(); }); - it("no sibling entry IMPORTS persist-securestore (dependency isolation)", async () => { - for (const sibling of [ - "persist-core.ts", - "persist-seroval.ts", - "persist-idb.ts", - "persist-crosstab.ts", - "persist-zod.ts", - "persist-tanstack.ts", - "persist-solid.ts", - "persist-vue.ts", - "persist-asyncstorage.ts", - "persist-mmkv.ts", - "hydration.ts", - "use-hydrated.ts", - "index.ts", - ]) { - const url = new URL(`./${sibling}`, import.meta.url); - if (!(await Bun.file(url).exists())) continue; - - const source = await Bun.file(url).text(); - const offendingImports = source.match( - /(?:from\s+|import\s*\(\s*)["']\.\/persist-securestore["']/g, - ); - expect(offendingImports).toBeNull(); + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file( + new URL("./secure-store.ts", import.meta.url), + ).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); } }); }); diff --git a/src/persist-securestore.ts b/src/adapters/backends/secure-store.ts similarity index 93% rename from src/persist-securestore.ts rename to src/adapters/backends/secure-store.ts index ba65f02..a9ea446 100644 --- a/src/persist-securestore.ts +++ b/src/adapters/backends/secure-store.ts @@ -4,8 +4,8 @@ // opt-in (enforced by an isolation test). import * as SecureStore from "expo-secure-store"; -import type { StateStorage } from "./persist-core"; -import { createJSONStorage } from "./persist-core"; +import type { StateStorage } from "../../core/persist-core"; +import { createJSONStorage } from "../../core/persist-core"; /** * `StateStorage` over `expo-secure-store` — fully async, string-wire, backed diff --git a/src/persist-seroval.test.ts b/src/adapters/codecs/seroval.test.ts similarity index 88% rename from src/persist-seroval.test.ts rename to src/adapters/codecs/seroval.test.ts index 021424b..6baa9a7 100644 --- a/src/persist-seroval.test.ts +++ b/src/adapters/codecs/seroval.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it } from "bun:test"; -import { createStorage, persistSource } from "./persist-core"; -import type { PersistableSource, StateStorage } from "./persist-core"; -import { createSerovalStorage, serovalCodec } from "./persist-seroval"; +import { createStorage, persistSource } from "../../core/persist-core"; +import type { PersistableSource, StateStorage } from "../../core/persist-core"; +import { createSerovalStorage, serovalCodec } from "./seroval"; class MemoryStorage implements StateStorage { private store = new Map(); @@ -178,3 +178,17 @@ describe("serovalCodec direct seam", () => { expect(stored?.state.tags.has("a")).toBe(true); }); }); + +describe("seroval dependency isolation", () => { + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file( + new URL("./seroval.ts", import.meta.url), + ).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); + } + }); +}); diff --git a/src/persist-seroval.ts b/src/adapters/codecs/seroval.ts similarity index 93% rename from src/persist-seroval.ts rename to src/adapters/codecs/seroval.ts index b8b861c..95d99d2 100644 --- a/src/persist-seroval.ts +++ b/src/adapters/codecs/seroval.ts @@ -8,8 +8,8 @@ import type { StateStorage, StorageCodec, StorageValue, -} from "./persist-core"; -import { createStorage } from "./persist-core"; +} from "../../core/persist-core"; +import { createStorage } from "../../core/persist-core"; /** Same options as `createStorage` (`clearCorruptOnFailure`). */ export type SerovalStorageOptions = CreateStorageOptions; diff --git a/src/persist-zod.test.ts b/src/adapters/codecs/zod.test.ts similarity index 80% rename from src/persist-zod.test.ts rename to src/adapters/codecs/zod.test.ts index d47889d..a962c8d 100644 --- a/src/persist-zod.test.ts +++ b/src/adapters/codecs/zod.test.ts @@ -2,9 +2,9 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { z } from "zod"; -import { createStorage, persistSource } from "./persist-core"; -import type { PersistableSource, StateStorage } from "./persist-core"; -import { createZodStorage, zodCodec } from "./persist-zod"; +import { createStorage, persistSource } from "../../core/persist-core"; +import type { PersistableSource, StateStorage } from "../../core/persist-core"; +import { createZodStorage, zodCodec } from "./zod"; class MemoryStorage implements StateStorage { private store = new Map(); @@ -156,28 +156,14 @@ describe("zodCodec direct seam", () => { }); }); -describe("persist-zod dependency isolation", () => { - it("no sibling entry IMPORTS persist-zod (dependency isolation)", async () => { - // Each entry owns its dependency; importing persist-zod is the zod - // opt-in. Core/seroval/idb/tanstack/hydration must never pull it in (doc - // comments may mention it — only import lines count). - for (const sibling of [ - "persist-core.ts", - "persist-seroval.ts", - "persist-idb.ts", - "persist-crosstab.ts", - "persist-tanstack.ts", - "hydration.ts", - "use-hydrated.ts", - "index.ts", - ]) { - const source = await Bun.file( - new URL(`./${sibling}`, import.meta.url), - ).text(); - const offendingImports = source.match( - /(?:from\s+|import\s*\(\s*)["']\.\/persist-zod["']/g, - ); - expect(offendingImports).toBeNull(); +describe("zod dependency isolation", () => { + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file(new URL("./zod.ts", import.meta.url)).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); } }); }); diff --git a/src/persist-zod.ts b/src/adapters/codecs/zod.ts similarity index 95% rename from src/persist-zod.ts rename to src/adapters/codecs/zod.ts index 9a2e77b..cfe6035 100644 --- a/src/persist-zod.ts +++ b/src/adapters/codecs/zod.ts @@ -8,8 +8,8 @@ import type { StateStorage, StorageCodec, StorageValue, -} from "./persist-core"; -import { createStorage } from "./persist-core"; +} from "../../core/persist-core"; +import { createStorage } from "../../core/persist-core"; /** Same options as `createStorage` (`clearCorruptOnFailure`). */ export type ZodStorageOptions = CreateStorageOptions; diff --git a/src/use-hydrated.test.ts b/src/adapters/frameworks/react.test.ts similarity index 88% rename from src/use-hydrated.test.ts rename to src/adapters/frameworks/react.test.ts index eade42d..94d8621 100644 --- a/src/use-hydrated.test.ts +++ b/src/adapters/frameworks/react.test.ts @@ -3,10 +3,10 @@ import { describe, expect, it } from "bun:test"; import * as React from "react"; import { renderToString } from "react-dom/server"; -import { alwaysHydratedSignal, toHydrationSignal } from "./hydration"; -import { createJSONStorage, persistSource } from "./persist-core"; -import type { PersistableSource, StateStorage } from "./persist-core"; -import { useHydrated } from "./use-hydrated"; +import { alwaysHydratedSignal, toHydrationSignal } from "../../core/hydration"; +import { createJSONStorage, persistSource } from "../../core/persist-core"; +import type { PersistableSource, StateStorage } from "../../core/persist-core"; +import { useHydrated } from "./react"; /** * `bun test` has no DOM, so we can't drive `useSyncExternalStore` reactivity @@ -169,3 +169,17 @@ describe("useHydrated", () => { } }); }); + +describe("react dependency isolation", () => { + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file( + new URL("./react.ts", import.meta.url), + ).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); + } + }); +}); diff --git a/src/use-hydrated.ts b/src/adapters/frameworks/react.ts similarity index 96% rename from src/use-hydrated.ts rename to src/adapters/frameworks/react.ts index cc0378b..2d40ee3 100644 --- a/src/use-hydrated.ts +++ b/src/adapters/frameworks/react.ts @@ -1,6 +1,6 @@ import { useSyncExternalStore } from "react"; -import type { HydrationSignal } from "./hydration"; +import type { HydrationSignal } from "../../core/hydration"; export interface UseHydratedResult { /** diff --git a/src/persist-solid.test.ts b/src/adapters/frameworks/solid.test.ts similarity index 75% rename from src/persist-solid.test.ts rename to src/adapters/frameworks/solid.test.ts index aa2df49..f17f000 100644 --- a/src/persist-solid.test.ts +++ b/src/adapters/frameworks/solid.test.ts @@ -10,11 +10,11 @@ mock.module( let createEffect: typeof import("solid-js").createEffect; let createRoot: typeof import("solid-js").createRoot; -let useHydrated: typeof import("./persist-solid").useHydrated; +let useHydrated: typeof import("./solid").useHydrated; beforeAll(async () => { ({ createEffect, createRoot } = await import("solid-js")); - ({ useHydrated } = await import("./persist-solid")); + ({ useHydrated } = await import("./solid")); }); function createFakeSignal() { @@ -89,27 +89,16 @@ describe("useHydrated", () => { }); }); -describe("persist-solid entry isolation", () => { - it("no sibling entry IMPORTS persist-solid (dependency isolation)", async () => { - for (const sibling of [ - "persist-core.ts", - "persist-seroval.ts", - "persist-idb.ts", - "persist-crosstab.ts", - "persist-zod.ts", - "persist-tanstack.ts", - "persist-vue.ts", - "hydration.ts", - "use-hydrated.ts", - "index.ts", - ]) { - const url = new URL(`./${sibling}`, import.meta.url); - if (!(await Bun.file(url).exists())) continue; - const source = await Bun.file(url).text(); - const offendingImports = source.match( - /(?:from\s+|import\s*\(\s*)["']\.\/persist-solid["']/g, - ); - expect(offendingImports).toBeNull(); +describe("solid dependency isolation", () => { + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file( + new URL("./solid.ts", import.meta.url), + ).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); } }); }); diff --git a/src/persist-solid.ts b/src/adapters/frameworks/solid.ts similarity index 96% rename from src/persist-solid.ts rename to src/adapters/frameworks/solid.ts index 793b541..0821fbc 100644 --- a/src/persist-solid.ts +++ b/src/adapters/frameworks/solid.ts @@ -5,7 +5,7 @@ import { from } from "solid-js"; import type { Accessor } from "solid-js"; -import type { HydrationSignal } from "./hydration"; +import type { HydrationSignal } from "../../core/hydration"; const alwaysTrue: Accessor = () => true; diff --git a/src/persist-vue.test.ts b/src/adapters/frameworks/vue.test.ts similarity index 57% rename from src/persist-vue.test.ts rename to src/adapters/frameworks/vue.test.ts index 72ccc05..df454a2 100644 --- a/src/persist-vue.test.ts +++ b/src/adapters/frameworks/vue.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"; import { effect, effectScope } from "vue"; -import { useHydrated } from "./persist-vue"; +import { useHydrated } from "./vue"; function createFakeSignal() { let hydrated = false; @@ -61,34 +61,13 @@ describe("useHydrated (vue)", () => { expect(hydratedRef!.value).toBe(lastValue); }); - it("no sibling entry IMPORTS persist-vue (dependency isolation)", async () => { - // Each entry owns its dependency; importing persist-vue is the vue opt-in. - // Core/seroval/tanstack/hydration must never pull it in (doc comments may - // mention it — only import lines count). - for (const sibling of [ - "persist-core.ts", - "persist-seroval.ts", - "persist-idb.ts", - "persist-crosstab.ts", - "persist-zod.ts", - "persist-tanstack.ts", - "persist-solid.ts", - "hydration.ts", - "use-hydrated.ts", - "index.ts", - ]) { - const url = new URL(`./${sibling}`, import.meta.url); - if (!(await Bun.file(url).exists())) continue; - const source = await Bun.file(url).text(); - // Declaration-level matching (not per-line): a formatter can wrap an - // import across lines, which a `^import` line filter would miss. Any - // `from "./persist-vue"` clause or dynamic `import("./persist-vue")` - // is an import regardless of layout; doc-comment mentions never carry - // the from/import() syntax. - const offendingImports = source.match( - /(?:from\s+|import\s*\(\s*)["']\.\/persist-vue["']/g, - ); - expect(offendingImports).toBeNull(); + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file(new URL("./vue.ts", import.meta.url)).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); } }); }); diff --git a/src/persist-vue.ts b/src/adapters/frameworks/vue.ts similarity index 96% rename from src/persist-vue.ts rename to src/adapters/frameworks/vue.ts index 9b17189..016cd93 100644 --- a/src/persist-vue.ts +++ b/src/adapters/frameworks/vue.ts @@ -5,7 +5,7 @@ import { onScopeDispose, shallowRef } from "vue"; import type { Ref } from "vue"; -import type { HydrationSignal } from "./hydration"; +import type { HydrationSignal } from "../../core/hydration"; /** * Mount a `HydrationSignal` into Vue's reactivity. Returns a `Ref` diff --git a/src/persist-tanstack.test.ts b/src/adapters/sources/tanstack-store.test.ts similarity index 82% rename from src/persist-tanstack.test.ts rename to src/adapters/sources/tanstack-store.test.ts index db82f3d..7316efc 100644 --- a/src/persist-tanstack.test.ts +++ b/src/adapters/sources/tanstack-store.test.ts @@ -3,9 +3,9 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { createAtom, Store } from "@tanstack/store"; import type { Atom } from "@tanstack/store"; -import { createJSONStorage } from "./persist-core"; -import type { StateStorage } from "./persist-core"; -import { persistAtom, persistStore } from "./persist-tanstack"; +import { createJSONStorage } from "../../core/persist-core"; +import type { StateStorage } from "../../core/persist-core"; +import { persistAtom, persistStore } from "./tanstack-store"; class MemoryStorage implements StateStorage { private store = new Map(); @@ -116,3 +116,17 @@ describe("persistAtom", () => { ).toThrow("[persistAtom] Cannot persist a readonly atom."); }); }); + +describe("tanstack-store dependency isolation", () => { + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file( + new URL("./tanstack-store.ts", import.meta.url), + ).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); + } + }); +}); diff --git a/src/persist-tanstack.ts b/src/adapters/sources/tanstack-store.ts similarity index 95% rename from src/persist-tanstack.ts rename to src/adapters/sources/tanstack-store.ts index 8e91a5a..bc020fd 100644 --- a/src/persist-tanstack.ts +++ b/src/adapters/sources/tanstack-store.ts @@ -3,8 +3,8 @@ // store-adapter subpath entry with `@tanstack/store` as a peer dep. import type { Atom, Store, StoreActionMap } from "@tanstack/store"; -import type { PersistApi, PersistOptions } from "./persist-core"; -import { persistSource } from "./persist-core"; +import type { PersistApi, PersistOptions } from "../../core/persist-core"; +import { persistSource } from "../../core/persist-core"; /** * Persist a `@tanstack/store` `Store` (action-bearing stores included). diff --git a/src/persist-crosstab.test.ts b/src/adapters/transport/crosstab.test.ts similarity index 87% rename from src/persist-crosstab.test.ts rename to src/adapters/transport/crosstab.test.ts index fa881b3..b7768ef 100644 --- a/src/persist-crosstab.test.ts +++ b/src/adapters/transport/crosstab.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "bun:test"; -import { persistSource } from "./persist-core"; +import { persistSource } from "../../core/persist-core"; import type { PersistableSource, PersistStorage, StorageValue, -} from "./persist-core"; -import { createBroadcastCrossTab } from "./persist-crosstab"; +} from "../../core/persist-core"; +import { createBroadcastCrossTab } from "./crosstab"; function createSharedAsyncStorage( shared: Map>, @@ -187,23 +187,15 @@ describe("createBroadcastCrossTab", () => { expect(fired).toBe(false); }); - it("no sibling entry IMPORTS persist-crosstab (dependency isolation)", async () => { - for (const sibling of [ - "persist-core.ts", - "persist-seroval.ts", - "persist-idb.ts", - "persist-tanstack.ts", - "hydration.ts", - "use-hydrated.ts", - "index.ts", - ]) { - const source = await Bun.file( - new URL(`./${sibling}`, import.meta.url), - ).text(); - const offendingImports = source.match( - /(?:from\s+|import\s*\(\s*)["']\.\/persist-crosstab["']/g, - ); - expect(offendingImports).toBeNull(); + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file( + new URL("./crosstab.ts", import.meta.url), + ).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); } }); }); diff --git a/src/persist-crosstab.ts b/src/adapters/transport/crosstab.ts similarity index 99% rename from src/persist-crosstab.ts rename to src/adapters/transport/crosstab.ts index 14d5959..ee80591 100644 --- a/src/persist-crosstab.ts +++ b/src/adapters/transport/crosstab.ts @@ -6,7 +6,7 @@ import type { CrossTabEventTarget, CrossTabStorageEvent, PersistStorage, -} from "./persist-core"; +} from "../../core/persist-core"; export interface CreateBroadcastCrossTabOptions { /** BroadcastChannel name — all tabs sharing this name sync. */ diff --git a/src/hydration.test.ts b/src/core/hydration.test.ts similarity index 100% rename from src/hydration.test.ts rename to src/core/hydration.test.ts diff --git a/src/hydration.ts b/src/core/hydration.ts similarity index 100% rename from src/hydration.ts rename to src/core/hydration.ts diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 0000000..6609d6d --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,6 @@ +// Package entry point — re-exports the zero-dep core (`persist-core`) plus +// the framework-agnostic hydration signal (`hydration`). Adapter subpaths +// under `../adapters//` own their optional peers; this core entry stays +// dependency-free. +export * from "./persist-core"; +export * from "./hydration"; diff --git a/src/persist-core.test.ts b/src/core/persist-core.test.ts similarity index 99% rename from src/persist-core.test.ts rename to src/core/persist-core.test.ts index 00e5a8d..f678faf 100644 --- a/src/persist-core.test.ts +++ b/src/core/persist-core.test.ts @@ -29,9 +29,9 @@ describe("persist-core zero-dep gate", () => { expect(valueImports).toEqual([]); }); - it("persist-tanstack.ts imports @tanstack/store as types only", async () => { + it("tanstack-store.ts imports @tanstack/store as types only", async () => { const source = await Bun.file( - new URL("./persist-tanstack.ts", import.meta.url), + new URL("../adapters/sources/tanstack-store.ts", import.meta.url), ).text(); const storeValueImports = source .split("\n") diff --git a/src/persist-core.ts b/src/core/persist-core.ts similarity index 100% rename from src/persist-core.ts rename to src/core/persist-core.ts diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 69568ec..0000000 --- a/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Package entry point — re-exports the zero-dep core (`persist-core`) plus -// the framework-agnostic hydration signal (`hydration`). Subpath entries -// (`./seroval`, `./idb`, `./tanstack-store`, `./react`) own their optional -// peers; this core entry stays dependency-free. -export * from "./persist-core"; -export * from "./hydration"; diff --git a/tests-dom/use-hydrated.test.tsx b/tests-dom/react.test.tsx similarity index 93% rename from tests-dom/use-hydrated.test.tsx rename to tests-dom/react.test.tsx index 10eae2a..fe8987d 100644 --- a/tests-dom/use-hydrated.test.tsx +++ b/tests-dom/react.test.tsx @@ -2,11 +2,11 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import * as React from "react"; import { afterEach, describe, expect, it } from "vitest"; -import { toHydrationSignal } from "../src/hydration"; -import type { HydrationSource } from "../src/hydration"; -import { createJSONStorage, persistSource } from "../src/persist-core"; -import type { PersistableSource, StateStorage } from "../src/persist-core"; -import { useHydrated } from "../src/use-hydrated"; +import { useHydrated } from "../src/adapters/frameworks/react"; +import { toHydrationSignal } from "../src/core/hydration"; +import type { HydrationSource } from "../src/core/hydration"; +import { createJSONStorage, persistSource } from "../src/core/persist-core"; +import type { PersistableSource, StateStorage } from "../src/core/persist-core"; /** * Framework-matrix tests for the React `useHydrated` reactivity path — the diff --git a/tsdown.config.ts b/tsdown.config.ts index af1a7aa..1d90819 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -2,24 +2,26 @@ import { defineConfig } from "tsdown"; const outDir = "dist"; -// Five subpath entries — each maps 1:1 to an `exports` entry. The core -// (`index`) re-exports `persist-core` + `hydration`; the others own their -// optional peer deps, which stay external so consumers tree-shake cleanly. +// Twelve subpath entries — each maps 1:1 to an `exports` entry. The core +// (`index`) re-exports `persist-core` + `hydration`; the adapters under +// `adapters//` own their optional peer deps, which stay external so +// consumers tree-shake cleanly. The record form flattens dist output to +// `dist/.mjs` regardless of the src folder depth. export default defineConfig({ - entry: [ - "src/index.ts", - "src/persist-seroval.ts", - "src/persist-idb.ts", - "src/persist-crosstab.ts", - "src/persist-zod.ts", - "src/persist-solid.ts", - "src/persist-vue.ts", - "src/persist-asyncstorage.ts", - "src/persist-mmkv.ts", - "src/persist-securestore.ts", - "src/persist-tanstack.ts", - "src/use-hydrated.ts", - ], + entry: { + index: "src/core/index.ts", + seroval: "src/adapters/codecs/seroval.ts", + zod: "src/adapters/codecs/zod.ts", + idb: "src/adapters/backends/idb.ts", + "async-storage": "src/adapters/backends/async-storage.ts", + mmkv: "src/adapters/backends/mmkv.ts", + "secure-store": "src/adapters/backends/secure-store.ts", + crosstab: "src/adapters/transport/crosstab.ts", + "tanstack-store": "src/adapters/sources/tanstack-store.ts", + react: "src/adapters/frameworks/react.ts", + solid: "src/adapters/frameworks/solid.ts", + vue: "src/adapters/frameworks/vue.ts", + }, outDir, format: "esm", dts: true, diff --git a/typedoc.json b/typedoc.json index b855f65..bb83c14 100644 --- a/typedoc.json +++ b/typedoc.json @@ -1,11 +1,18 @@ { "$schema": "https://typedoc.org/schema.json", "entryPoints": [ - "src/index.ts", - "src/persist-seroval.ts", - "src/persist-idb.ts", - "src/persist-tanstack.ts", - "src/use-hydrated.ts" + "src/core/index.ts", + "src/adapters/codecs/seroval.ts", + "src/adapters/codecs/zod.ts", + "src/adapters/backends/idb.ts", + "src/adapters/backends/async-storage.ts", + "src/adapters/backends/mmkv.ts", + "src/adapters/backends/secure-store.ts", + "src/adapters/transport/crosstab.ts", + "src/adapters/sources/tanstack-store.ts", + "src/adapters/frameworks/react.ts", + "src/adapters/frameworks/solid.ts", + "src/adapters/frameworks/vue.ts" ], "out": "docs/api", "name": "@stainless-code/persist", From 6a1ab2c1a9fb94bcb83cf19446274132e7994961 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sat, 4 Jul 2026 19:42:53 +0300 Subject: [PATCH 08/77] refactor(subpaths)!: mirror public subpaths to the folder structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Categorize the public subpath namespace 1:1 with src/adapters// 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/.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. --- .changeset/crosstab-adapter.md | 2 +- .changeset/rn-storage-adapters.md | 6 +- .changeset/solid-vue-hydration.md | 6 +- .changeset/subpath-mirror-folders.md | 19 +++++ .changeset/zod-codec.md | 2 +- README.md | 103 ++++++++++++++------------- docs/architecture.md | 30 ++++---- docs/glossary.md | 14 ++-- docs/roadmap.md | 10 +-- package.json | 52 +++++++------- skills/tanstack-store/SKILL.md | 14 ++-- src/adapters/backends/idb.ts | 2 +- 12 files changed, 141 insertions(+), 119 deletions(-) create mode 100644 .changeset/subpath-mirror-folders.md diff --git a/.changeset/crosstab-adapter.md b/.changeset/crosstab-adapter.md index 18e501e..9bb8b07 100644 --- a/.changeset/crosstab-adapter.md +++ b/.changeset/crosstab-adapter.md @@ -2,4 +2,4 @@ "@stainless-code/persist": minor --- -Add `./crosstab` subpath — `createBroadcastCrossTab`, a zero-dep `BroadcastChannel` bridge for cross-tab sync over backends that fire no `storage` events (IndexedDB). Returns `{ crossTabEventTarget, wrap, close }`: pass the target as `crossTabEventTarget` and `wrap(storage)` as `storage` so writes/removes broadcast to other tabs. Posts `storageArea: null` on every event so key-only matching applies in every tab (each tab owns its own backend instance — reference equality on `raw` would fail across tabs). Guards `BroadcastChannel` availability (SSR, Node <18) and posts after the write settles so receivers rehydrate into committed state. +Add `./transport/crosstab` subpath — `createBroadcastCrossTab`, a zero-dep `BroadcastChannel` bridge for cross-tab sync over backends that fire no `storage` events (IndexedDB). Returns `{ crossTabEventTarget, wrap, close }`: pass the target as `crossTabEventTarget` and `wrap(storage)` as `storage` so writes/removes broadcast to other tabs. Posts `storageArea: null` on every event so key-only matching applies in every tab (each tab owns its own backend instance — reference equality on `raw` would fail across tabs). Guards `BroadcastChannel` availability (SSR, Node <18) and posts after the write settles so receivers rehydrate into committed state. diff --git a/.changeset/rn-storage-adapters.md b/.changeset/rn-storage-adapters.md index 2fb7ada..31ca8d1 100644 --- a/.changeset/rn-storage-adapters.md +++ b/.changeset/rn-storage-adapters.md @@ -4,8 +4,8 @@ Add 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): -- `./async-storage` (peer `@react-native-async-storage/async-storage >=1.0.0`) — `asyncStorageStateStorage` / `createAsyncStorage`. Fully 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. Pair `encryptionKey` for secrets-at-rest. -- `./secure-store` (peer `expo-secure-store >=12.0.0`) — `secureStoreStateStorage` / `createSecureStoreStorage`. OS keychain/keystore, async, **~2KB value limit per key** — for small secrets (auth tokens), not large state; pair `partialize` to persist a tiny slice. +- `./backends/async-storage` (peer `@react-native-async-storage/async-storage >=1.0.0`) — `asyncStorageStateStorage` / `createAsyncStorage`. Fully async, string-wire; `useHydrated` gating mandatory. Accepts a custom instance (`getLegacyStorage()`, `createAsyncStorage(name)`) to namespace. +- `./backends/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. Pair `encryptionKey` for secrets-at-rest. +- `./backends/secure-store` (peer `expo-secure-store >=12.0.0`) — `secureStoreStateStorage` / `createSecureStoreStorage`. OS keychain/keystore, async, **~2KB value limit per key** — for small secrets (auth tokens), not large state; pair `partialize` to persist a tiny slice. All three compose via `createJSONStorage` (jsonCodec default); swap codecs with `createStorage(backend, codec)`. Tests use `mock.module` to fake each peer (Map-backed) — validates shape, not the real RN runtime. diff --git a/.changeset/solid-vue-hydration.md b/.changeset/solid-vue-hydration.md index 1c79a14..5771874 100644 --- a/.changeset/solid-vue-hydration.md +++ b/.changeset/solid-vue-hydration.md @@ -2,9 +2,9 @@ "@stainless-code/persist": minor --- -Add `./solid` and `./vue` hydration subpaths — `useHydrated(signal)` over the `HydrationSignal` seam, mirroring the React adapter (`src/use-hydrated.ts`). +Add `./frameworks/solid` and `./frameworks/vue` hydration subpaths — `useHydrated(signal)` over the `HydrationSignal` seam, mirroring the React adapter (`src/use-hydrated.ts`). -- `./solid` (peer `solid-js >=1.6.0`): returns a Solid `Accessor` via `from`; the subscription is owned by the reactive scope and cleaned up on scope dispose. Uses the `from(producer, initialValue)` overload so the accessor is `Accessor` (not `boolean | undefined`); reads `isHydrated()` for the initial value (pull-model signal — no initial notification). -- `./vue` (peer `vue >=3.3.0`): returns a Vue `Ref` via `shallowRef`; subscription cleaned up via `onScopeDispose` — call inside `setup()` or an `effectScope()`. +- `./frameworks/solid` (peer `solid-js >=1.6.0`): returns a Solid `Accessor` via `from`; the subscription is owned by the reactive scope and cleaned up on scope dispose. Uses the `from(producer, initialValue)` overload so the accessor is `Accessor` (not `boolean | undefined`); reads `isHydrated()` for the initial value (pull-model signal — no initial notification). +- `./frameworks/vue` (peer `vue >=3.3.0`): returns a Vue `Ref` via `shallowRef`; subscription cleaned up via `onScopeDispose` — call inside `setup()` or an `effectScope()`. Both render `true` on the server (the no-op `PersistApi` is always-hydrated, so the signal is `true` server-side) — matching the `HydrationSignal` adapter contract. Each ships as its own subpath with the peer as optional, no cross-entry value imports (isolation test included). diff --git a/.changeset/subpath-mirror-folders.md b/.changeset/subpath-mirror-folders.md new file mode 100644 index 0000000..6b63e89 --- /dev/null +++ b/.changeset/subpath-mirror-folders.md @@ -0,0 +1,19 @@ +--- +"@stainless-code/persist": minor +--- + +Reorganize the public subpath namespace to mirror the folder structure (`src/core/` + `src/adapters//`). Adapter subpaths are now category-prefixed so the public surface is 1:1 with the source layout — a contributor adding `adapters/backends/opfs.ts` knows the subpath is `./backends/opfs` with zero mental mapping. **Breaking** (early package; consumers must update imports): + +- `./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/.mjs`); the `exports` map routes each categorized subpath to its flat dist file. Internal `src/` was refolded into `core/` + `adapters//` with the `persist-` filename prefix dropped (folder is the category, file is the subpath basename). diff --git a/.changeset/zod-codec.md b/.changeset/zod-codec.md index daf2e27..1c54fd0 100644 --- a/.changeset/zod-codec.md +++ b/.changeset/zod-codec.md @@ -2,4 +2,4 @@ "@stainless-code/persist": minor --- -Add `./zod` subpath — `zodCodec` / `createZodStorage`, a schema-gated codec over the `StorageCodec` seam. `encode` validates `state` against a `ZodType` before serializing the envelope (invalid state never persists; the throw surfaces via `onError` phase `"write"`). `decode` parses + validates the stored `state`; a validation failure throws into persist-core's corrupt-payload path → returns `null`, or with `clearCorruptOnFailure` removes the key. Validates `state` only — `version` / `timestamp` / `buster` stay the envelope's concern. `zod` is an optional peer (`>=3.20.0`, stable across v3/v4 via `ZodType` + `.parse`). +Add `./codecs/zod` subpath — `zodCodec` / `createZodStorage`, a schema-gated codec over the `StorageCodec` seam. `encode` validates `state` against a `ZodType` before serializing the envelope (invalid state never persists; the throw surfaces via `onError` phase `"write"`). `decode` parses + validates the stored `state`; a validation failure throws into persist-core's corrupt-payload path → returns `null`, or with `clearCorruptOnFailure` removes the key. Validates `state` only — `version` / `timestamp` / `buster` stay the envelope's concern. `zod` is an optional peer (`>=3.20.0`, stable across v3/v4 via `ZodType` + `.parse`). diff --git a/README.md b/README.md index f58456f..903d498 100644 --- a/README.md +++ b/README.md @@ -25,20 +25,20 @@ bun add @stainless-code/persist Each subpath owns its dependency as an **optional peer** — import only the entries you use, install the matching peer only when you do: -| Subpath | Optional peer | -| ---------------------------------------- | ------------------------------------------- | -| `@stainless-code/persist` | none (zero-dep core) | -| `@stainless-code/persist/seroval` | `seroval` | -| `@stainless-code/persist/idb` | `idb-keyval` | -| `@stainless-code/persist/tanstack-store` | `@tanstack/store` | -| `@stainless-code/persist/react` | `react` | -| `@stainless-code/persist/crosstab` | none (web global) | -| `@stainless-code/persist/zod` | `zod` | -| `@stainless-code/persist/solid` | `solid-js` | -| `@stainless-code/persist/vue` | `vue` | -| `@stainless-code/persist/async-storage` | `@react-native-async-storage/async-storage` | -| `@stainless-code/persist/mmkv` | `react-native-mmkv` | -| `@stainless-code/persist/secure-store` | `expo-secure-store` | +| Subpath | Optional peer | +| ------------------------------------------------ | ------------------------------------------- | +| `@stainless-code/persist` | none (zero-dep core) | +| `@stainless-code/persist/codecs/seroval` | `seroval` | +| `@stainless-code/persist/backends/idb` | `idb-keyval` | +| `@stainless-code/persist/sources/tanstack-store` | `@tanstack/store` | +| `@stainless-code/persist/frameworks/react` | `react` | +| `@stainless-code/persist/transport/crosstab` | none (web global) | +| `@stainless-code/persist/codecs/zod` | `zod` | +| `@stainless-code/persist/frameworks/solid` | `solid-js` | +| `@stainless-code/persist/frameworks/vue` | `vue` | +| `@stainless-code/persist/backends/async-storage` | `@react-native-async-storage/async-storage` | +| `@stainless-code/persist/backends/mmkv` | `react-native-mmkv` | +| `@stainless-code/persist/backends/secure-store` | `expo-secure-store` | ```bash # only when you use the matching entry @@ -57,10 +57,10 @@ npm install seroval idb-keyval @tanstack/store react ```ts import { Store } from "@tanstack/store"; -import { createSerovalStorage } from "@stainless-code/persist/seroval"; -import { persistStore } from "@stainless-code/persist/tanstack-store"; +import { createSerovalStorage } from "@stainless-code/persist/codecs/seroval"; +import { persistStore } from "@stainless-code/persist/sources/tanstack-store"; import { toHydrationSignal } from "@stainless-code/persist"; -import { useHydrated } from "@stainless-code/persist/react"; +import { useHydrated } from "@stainless-code/persist/frameworks/react"; const store = new Store({ theme: "light" }); const persist = persistStore(store, { @@ -101,8 +101,8 @@ bun add @stainless-code/persist @tanstack/store react idb-keyval // prefs-store.ts import { Store } from "@tanstack/store"; import { toHydrationSignal } from "@stainless-code/persist"; -import { createIdbStorage } from "@stainless-code/persist/idb"; -import { persistStore } from "@stainless-code/persist/tanstack-store"; +import { createIdbStorage } from "@stainless-code/persist/backends/idb"; +import { persistStore } from "@stainless-code/persist/sources/tanstack-store"; export type Prefs = { theme: "light" | "dark"; recent: string[] }; @@ -120,7 +120,7 @@ export const prefsHydration = toHydrationSignal(persist); ```tsx // PrefsPanel.tsx import { useSelector } from "@tanstack/store"; -import { useHydrated } from "@stainless-code/persist/react"; +import { useHydrated } from "@stainless-code/persist/frameworks/react"; import { prefsStore, prefsHydration } from "./prefs-store"; export function PrefsPanel() { @@ -157,20 +157,20 @@ Persistence middleware for any `getState`/`setState`/`subscribe` store (TanStack ## Entry points (one subpath = one optional peer) -| Subpath | Symbols | Optional peer | -| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | -| `@stainless-code/persist` | `persistSource`, `PersistApi`, `createStorage`, `jsonCodec`, `identityCodec`, registry, `HydrationSignal` (`hydration`) | none | -| `@stainless-code/persist/seroval` | `serovalCodec`, `createSerovalStorage` | `seroval` | -| `@stainless-code/persist/idb` | `idbStateStorage`, `createIdbStorage` (structured-clone mode) | `idb-keyval` | -| `@stainless-code/persist/tanstack-store` | `persistStore`, `persistAtom` | `@tanstack/store` (types only) | -| `@stainless-code/persist/react` | `useHydrated` React hook | `react` | -| `@stainless-code/persist/crosstab` | `createBroadcastCrossTab` | none (web global) | -| `@stainless-code/persist/zod` | `zodCodec`, `createZodStorage` | `zod` | -| `@stainless-code/persist/solid` | `useHydrated` (Solid `Accessor`) | `solid-js` | -| `@stainless-code/persist/vue` | `useHydrated` (Vue `Ref`) | `vue` | -| `@stainless-code/persist/async-storage` | `asyncStorageStateStorage`, `createAsyncStorage` | `@react-native-async-storage/async-storage` | -| `@stainless-code/persist/mmkv` | `mmkvStateStorage`, `createMmkvStorage` | `react-native-mmkv` | -| `@stainless-code/persist/secure-store` | `secureStoreStateStorage`, `createSecureStoreStorage` | `expo-secure-store` | +| Subpath | Symbols | Optional peer | +| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| `@stainless-code/persist` | `persistSource`, `PersistApi`, `createStorage`, `jsonCodec`, `identityCodec`, registry, `HydrationSignal` (`hydration`) | none | +| `@stainless-code/persist/codecs/seroval` | `serovalCodec`, `createSerovalStorage` | `seroval` | +| `@stainless-code/persist/backends/idb` | `idbStateStorage`, `createIdbStorage` (structured-clone mode) | `idb-keyval` | +| `@stainless-code/persist/sources/tanstack-store` | `persistStore`, `persistAtom` | `@tanstack/store` (types only) | +| `@stainless-code/persist/frameworks/react` | `useHydrated` React hook | `react` | +| `@stainless-code/persist/transport/crosstab` | `createBroadcastCrossTab` | none (web global) | +| `@stainless-code/persist/codecs/zod` | `zodCodec`, `createZodStorage` | `zod` | +| `@stainless-code/persist/frameworks/solid` | `useHydrated` (Solid `Accessor`) | `solid-js` | +| `@stainless-code/persist/frameworks/vue` | `useHydrated` (Vue `Ref`) | `vue` | +| `@stainless-code/persist/backends/async-storage` | `asyncStorageStateStorage`, `createAsyncStorage` | `@react-native-async-storage/async-storage` | +| `@stainless-code/persist/backends/mmkv` | `mmkvStateStorage`, `createMmkvStorage` | `react-native-mmkv` | +| `@stainless-code/persist/backends/secure-store` | `secureStoreStateStorage`, `createSecureStoreStorage` | `expo-secure-store` | No barrel — importing a subpath is the dependency opt-in. @@ -179,14 +179,14 @@ No barrel — importing a subpath is the dependency opt-in. **1. Backend (`StateStorage`)** — anything with `getItem`/`setItem`/`removeItem`, sync or Promise-returning, string-wire by default, generic for structured backends. ```ts -import { createSerovalStorage } from "@stainless-code/persist/seroval"; -import { createIdbStorage } from "@stainless-code/persist/idb"; +import { createSerovalStorage } from "@stainless-code/persist/codecs/seroval"; +import { createIdbStorage } from "@stainless-code/persist/backends/idb"; import { createJSONStorage } from "@stainless-code/persist"; createSerovalStorage(() => localStorage); // durable prefs createSerovalStorage(() => sessionStorage); // per-visit state (dies with the tab) createIdbStorage(); // IndexedDB, structured-clone mode -createJSONStorage(() => AsyncStorage); // React Native — or use ./async-storage for the typed adapter +createJSONStorage(() => AsyncStorage); // React Native — or use ./backends/async-storage for the typed adapter createAsyncStorage(); // React Native AsyncStorage — async, useHydrated gating createMmkvStorage({ id: "app-prefs" }); // React Native MMKV — sync, no gate needed createSecureStoreStorage(); // expo-secure-store — OS keychain, ~2KB/key, for secrets @@ -201,9 +201,9 @@ import { identityCodec, createStorage, } from "@stainless-code/persist"; -import { serovalCodec } from "@stainless-code/persist/seroval"; -import { zodCodec } from "@stainless-code/persist/zod"; -import { idbStateStorage } from "@stainless-code/persist/idb"; +import { serovalCodec } from "@stainless-code/persist/codecs/seroval"; +import { zodCodec } from "@stainless-code/persist/codecs/zod"; +import { idbStateStorage } from "@stainless-code/persist/backends/idb"; import { z } from "zod"; const PrefsSchema = z.object({ theme: z.enum(["light", "dark"]) }); @@ -226,7 +226,7 @@ const encryptedCodec = { import { persistStore, persistAtom, -} from "@stainless-code/persist/tanstack-store"; +} from "@stainless-code/persist/sources/tanstack-store"; import { persistSource } from "@stainless-code/persist"; persistStore(store, opts); // @tanstack/store Store @@ -240,9 +240,12 @@ Compose freely: `createStorage(backend, codec, options)` covers every backend × ```ts import { createStorage } from "@stainless-code/persist"; -import { idbStateStorage, createIdbStorage } from "@stainless-code/persist/idb"; -import { serovalCodec } from "@stainless-code/persist/seroval"; -import { persistStore } from "@stainless-code/persist/tanstack-store"; +import { + idbStateStorage, + createIdbStorage, +} from "@stainless-code/persist/backends/idb"; +import { serovalCodec } from "@stainless-code/persist/codecs/seroval"; +import { persistStore } from "@stainless-code/persist/sources/tanstack-store"; // Encryption at rest over IndexedDB createStorage(() => idbStateStorage(), encryptedCodec, { @@ -270,8 +273,8 @@ persistStore(store, { ```ts import { createPersistRegistry } from "@stainless-code/persist"; -import { createSerovalStorage } from "@stainless-code/persist/seroval"; -import { persistStore } from "@stainless-code/persist/tanstack-store"; +import { createSerovalStorage } from "@stainless-code/persist/codecs/seroval"; +import { persistStore } from "@stainless-code/persist/sources/tanstack-store"; // One registry for every persisted store — clearAll() at logout wipes all keys // (allSettled; first rejection rethrows; destroy() unregisters each store) @@ -370,8 +373,8 @@ persistStore(store, { ### Cross-tab over IndexedDB ```ts -import { createBroadcastCrossTab } from "@stainless-code/persist/crosstab"; -import { createIdbStorage } from "@stainless-code/persist/idb"; +import { createBroadcastCrossTab } from "@stainless-code/persist/transport/crosstab"; +import { createIdbStorage } from "@stainless-code/persist/backends/idb"; // IDB fires no storage events — bridge a BroadcastChannel as the transport. // storageArea: null in every posted event → key-only matching in every tab. @@ -385,11 +388,11 @@ persistStore(store, { // teardown: persist.destroy(); bridge.close(); ``` -Caveats that matter per backend: async backends (IDB) can't settle hydration before first paint → gate UI on `useHydrated` (`@stainless-code/persist/react`); `sessionStorage` is per-tab (crossTab is meaningless); `identityCodec` never with string-only backends. +Caveats that matter per backend: async backends (IDB) can't settle hydration before first paint → gate UI on `useHydrated` (`@stainless-code/persist/frameworks/react`); `sessionStorage` is per-tab (crossTab is meaningless); `identityCodec` never with string-only backends. ## Writing a framework adapter -The React hook (`@stainless-code/persist/react`) is ~20 lines over `HydrationSignal` — every adapter is the same shape. Solid (`@stainless-code/persist/solid`, `Accessor` via `from`) and Vue (`@stainless-code/persist/vue`, `Ref` via `shallowRef` + `onScopeDispose`) ship the same way. The contract (full version on `HydrationSignal`'s JSDoc): subscribe returns an idempotent unsubscribe; each subscribe call is an independent subscription; **no initial notification and no payload** — pull `isHydrated()` after attach and on every notification; transitions while detached aren't replayed (the snapshot re-read recovers); **render `hydrated: true` on the server** (no storage server-side); `null` signal = no persistence = hydrated. +The React hook (`@stainless-code/persist/frameworks/react`) is ~20 lines over `HydrationSignal` — every adapter is the same shape. Solid (`@stainless-code/persist/frameworks/solid`, `Accessor` via `from`) and Vue (`@stainless-code/persist/frameworks/vue`, `Ref` via `shallowRef` + `onScopeDispose`) ship the same way. The contract (full version on `HydrationSignal`'s JSDoc): subscribe returns an idempotent unsubscribe; each subscribe call is an independent subscription; **no initial notification and no payload** — pull `isHydrated()` after attach and on every notification; transitions while detached aren't replayed (the snapshot re-read recovers); **render `hydrated: true` on the server** (no storage server-side); `null` signal = no persistence = hydrated. ```ts import type { HydrationSignal } from "@stainless-code/persist"; diff --git a/docs/architecture.md b/docs/architecture.md index 46f61f8..711003d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,20 +16,20 @@ Persistence is bound to a structural `PersistableSource` (`getState` / `setState ## Entry points (one subpath = one optional peer) -| Entry | Module | Optional peer | -| ---------------------------------------- | ------------------------------------------- | --------------------------------------------- | -| `@stainless-code/persist` | `core/index` (`persist-core` + `hydration`) | none (zero-dep core, enforced by a gate test) | -| `@stainless-code/persist/seroval` | `adapters/codecs/seroval` | `seroval` | -| `@stainless-code/persist/zod` | `adapters/codecs/zod` | `zod` | -| `@stainless-code/persist/idb` | `adapters/backends/idb` | `idb-keyval` | -| `@stainless-code/persist/async-storage` | `adapters/backends/async-storage` | `@react-native-async-storage/async-storage` | -| `@stainless-code/persist/mmkv` | `adapters/backends/mmkv` | `react-native-mmkv` | -| `@stainless-code/persist/secure-store` | `adapters/backends/secure-store` | `expo-secure-store` | -| `@stainless-code/persist/crosstab` | `adapters/transport/crosstab` | none (web global) | -| `@stainless-code/persist/tanstack-store` | `adapters/sources/tanstack-store` | `@tanstack/store` (types only) | -| `@stainless-code/persist/react` | `adapters/frameworks/react` | `react` | -| `@stainless-code/persist/solid` | `adapters/frameworks/solid` | `solid-js` | -| `@stainless-code/persist/vue` | `adapters/frameworks/vue` | `vue` | +| Entry | Module | Optional peer | +| ------------------------------------------------ | ------------------------------------------- | --------------------------------------------- | +| `@stainless-code/persist` | `core/index` (`persist-core` + `hydration`) | none (zero-dep core, enforced by a gate test) | +| `@stainless-code/persist/codecs/seroval` | `adapters/codecs/seroval` | `seroval` | +| `@stainless-code/persist/codecs/zod` | `adapters/codecs/zod` | `zod` | +| `@stainless-code/persist/backends/idb` | `adapters/backends/idb` | `idb-keyval` | +| `@stainless-code/persist/backends/async-storage` | `adapters/backends/async-storage` | `@react-native-async-storage/async-storage` | +| `@stainless-code/persist/backends/mmkv` | `adapters/backends/mmkv` | `react-native-mmkv` | +| `@stainless-code/persist/backends/secure-store` | `adapters/backends/secure-store` | `expo-secure-store` | +| `@stainless-code/persist/transport/crosstab` | `adapters/transport/crosstab` | none (web global) | +| `@stainless-code/persist/sources/tanstack-store` | `adapters/sources/tanstack-store` | `@tanstack/store` (types only) | +| `@stainless-code/persist/frameworks/react` | `adapters/frameworks/react` | `react` | +| `@stainless-code/persist/frameworks/solid` | `adapters/frameworks/solid` | `solid-js` | +| `@stainless-code/persist/frameworks/vue` | `adapters/frameworks/vue` | `vue` | No barrel — importing a subpath is the dependency opt-in. Each subpath entry owns its peer dep, which stays external in the build (`tsdown.config.ts` `neverBundle`) so consumers tree-shake cleanly. @@ -49,7 +49,7 @@ A per-entry self-check test pins the invariant: every adapter's relative imports ## Hydration lifecycle -`persistSource` hydrates on create (skip with `skipHydration`; `rehydrate()` is awaitable), subscribe-writes on every `setState` (gated until hydrated; optional trailing `throttleMs`), and tears down via `destroy()`. The hydration signal (`HydrationSignal` from `hydration`) is observed from outside the store — framework adapters mount it into their external-store mechanism (React `useSyncExternalStore` via `./react`, Solid `from` via `./solid`, Vue `shallowRef` + `onScopeDispose` via `./vue`; a Svelte `createSubscriber` sketch is in the README) without coupling to the store's read path. SSR policy: render `hydrated = true` on the server; `null` signal = no persistence = hydrated. +`persistSource` hydrates on create (skip with `skipHydration`; `rehydrate()` is awaitable), subscribe-writes on every `setState` (gated until hydrated; optional trailing `throttleMs`), and tears down via `destroy()`. The hydration signal (`HydrationSignal` from `hydration`) is observed from outside the store — framework adapters mount it into their external-store mechanism (React `useSyncExternalStore` via `./frameworks/react`, Solid `from` via `./frameworks/solid`, Vue `shallowRef` + `onScopeDispose` via `./frameworks/vue`; a Svelte `createSubscriber` sketch is in the README) without coupling to the store's read path. SSR policy: render `hydrated = true` on the server; `null` signal = no persistence = hydrated. ## Sync vs async diff --git a/docs/glossary.md b/docs/glossary.md index b09a488..a49f1e0 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -4,13 +4,13 @@ ## Seams -| Term | Definition | Aliases / avoid | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -| **backend** | The `StateStorage` seam — `getItem` / `setItem` / `removeItem` against a physical store (sync or Promise). | storage driver, engine | -| **codec** | The `StorageCodec` seam — pure `encode` / `decode` between the persisted envelope and the backend's wire type. | serializer, (de)serializer | -| **source** | The `PersistableSource` seam — the reactive store being persisted (`getState` / `setState` / `subscribe`), structural and store-agnostic. | store (avoid — overloaded) | -| **storage** | The composed `PersistStorage` — a backend × codec cell produced by `createStorage`; the keyed store of envelopes `persistSource` reads/writes. | persisted storage, PersistStorage | -| **entry** | A subpath export in `package.json` `exports` — one entry = one optional peer opt-in (`./seroval`, `./idb`, `./tanstack-store`, `./react`). | subpath, entry point | +| Term | Definition | Aliases / avoid | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| **backend** | The `StateStorage` seam — `getItem` / `setItem` / `removeItem` against a physical store (sync or Promise). | storage driver, engine | +| **codec** | The `StorageCodec` seam — pure `encode` / `decode` between the persisted envelope and the backend's wire type. | serializer, (de)serializer | +| **source** | The `PersistableSource` seam — the reactive store being persisted (`getState` / `setState` / `subscribe`), structural and store-agnostic. | store (avoid — overloaded) | +| **storage** | The composed `PersistStorage` — a backend × codec cell produced by `createStorage`; the keyed store of envelopes `persistSource` reads/writes. | persisted storage, PersistStorage | +| **entry** | A subpath export in `package.json` `exports` — one entry = one optional peer opt-in (`./codecs/seroval`, `./backends/idb`, `./sources/tanstack-store`, `./frameworks/react`). | subpath, entry point | ## Envelope & wire diff --git a/docs/roadmap.md b/docs/roadmap.md index 786da19..228030b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -12,11 +12,11 @@ Forward-looking plans only — **not** a mirror of `src/`. **Doc index:** [READM ## Strategy -| Layer | Role | -| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Core** (`persist-core` + `hydration`) | Zero-dep middleware: `persistSource`, `createStorage`, codecs, registry, hydration signal. No value imports (gate-test enforced). | -| **Codec / backend subpaths** | Own their optional peer (`seroval`, `idb-keyval`); compose into `createStorage`. | -| **Framework adapters** | One entry per framework (`./tanstack-store`, `./react`); each adapter is ~20 lines over `HydrationSignal` — the same shape scales to Svelte / Solid / Vue. | +| Layer | Role | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Core** (`persist-core` + `hydration`) | Zero-dep middleware: `persistSource`, `createStorage`, codecs, registry, hydration signal. No value imports (gate-test enforced). | +| **Codec / backend subpaths** | Own their optional peer (`seroval`, `idb-keyval`); compose into `createStorage`. | +| **Framework adapters** | One entry per framework (`./sources/tanstack-store`, `./frameworks/react`); each adapter is ~20 lines over `HydrationSignal` — the same shape scales to Svelte / Solid / Vue. | ## Non-goals (v1) diff --git a/package.json b/package.json index e2db661..d98cc5f 100644 --- a/package.json +++ b/package.json @@ -34,49 +34,49 @@ "types": "./dist/index.d.mts", "import": "./dist/index.mjs" }, - "./seroval": { + "./codecs/seroval": { "types": "./dist/seroval.d.mts", "import": "./dist/seroval.mjs" }, - "./idb": { + "./codecs/zod": { + "types": "./dist/zod.d.mts", + "import": "./dist/zod.mjs" + }, + "./backends/idb": { "types": "./dist/idb.d.mts", "import": "./dist/idb.mjs" }, - "./tanstack-store": { - "types": "./dist/tanstack-store.d.mts", - "import": "./dist/tanstack-store.mjs" + "./backends/async-storage": { + "types": "./dist/async-storage.d.mts", + "import": "./dist/async-storage.mjs" }, - "./react": { - "types": "./dist/react.d.mts", - "import": "./dist/react.mjs" + "./backends/mmkv": { + "types": "./dist/mmkv.d.mts", + "import": "./dist/mmkv.mjs" }, - "./crosstab": { + "./backends/secure-store": { + "types": "./dist/secure-store.d.mts", + "import": "./dist/secure-store.mjs" + }, + "./transport/crosstab": { "types": "./dist/crosstab.d.mts", "import": "./dist/crosstab.mjs" }, - "./zod": { - "types": "./dist/zod.d.mts", - "import": "./dist/zod.mjs" + "./sources/tanstack-store": { + "types": "./dist/tanstack-store.d.mts", + "import": "./dist/tanstack-store.mjs" + }, + "./frameworks/react": { + "types": "./dist/react.d.mts", + "import": "./dist/react.mjs" }, - "./solid": { + "./frameworks/solid": { "types": "./dist/solid.d.mts", "import": "./dist/solid.mjs" }, - "./vue": { + "./frameworks/vue": { "types": "./dist/vue.d.mts", "import": "./dist/vue.mjs" - }, - "./async-storage": { - "types": "./dist/async-storage.d.mts", - "import": "./dist/async-storage.mjs" - }, - "./mmkv": { - "types": "./dist/mmkv.d.mts", - "import": "./dist/mmkv.mjs" - }, - "./secure-store": { - "types": "./dist/secure-store.d.mts", - "import": "./dist/secure-store.mjs" } }, "publishConfig": { diff --git a/skills/tanstack-store/SKILL.md b/skills/tanstack-store/SKILL.md index 5c03de5..0995f6a 100644 --- a/skills/tanstack-store/SKILL.md +++ b/skills/tanstack-store/SKILL.md @@ -13,7 +13,7 @@ sources: # Persisting TanStack Store -`@stainless-code/persist/tanstack-store` ships two adapters over the store-agnostic `persistSource` core: `persistStore(store, options)` for `@tanstack/store`'s `Store` (action-bearing stores included), and `persistAtom(atom, options)` for a writable `Atom`. The middleware owns the lifecycle so the store stays a plain store; the adapters are thin wrappers that supply the `PersistableSource` shape. +`@stainless-code/persist/sources/tanstack-store` ships two adapters over the store-agnostic `persistSource` core: `persistStore(store, options)` for `@tanstack/store`'s `Store` (action-bearing stores included), and `persistAtom(atom, options)` for a writable `Atom`. The middleware owns the lifecycle so the store stays a plain store; the adapters are thin wrappers that supply the `PersistableSource` shape. ## When to use this skill @@ -31,14 +31,14 @@ bun add @stainless-code/persist @tanstack/store bun add seroval ``` -`@tanstack/store` is an optional peer of the `/tanstack-store` subpath — importing the subpath is the dep opt-in. +`@tanstack/store` is an optional peer of the `/sources/tanstack-store` subpath — importing the subpath is the dep opt-in. ## Minimal wiring ```ts import { Store } from "@tanstack/store"; -import { createSerovalStorage } from "@stainless-code/persist/seroval"; -import { persistStore } from "@stainless-code/persist/tanstack-store"; +import { createSerovalStorage } from "@stainless-code/persist/codecs/seroval"; +import { persistStore } from "@stainless-code/persist/sources/tanstack-store"; const store = new Store({ theme: "light" }); const persist = persistStore(store, { @@ -58,7 +58,7 @@ The middleware hydrates on create, subscribes to `setState`, and writes through. ```ts import { createAtom } from "@tanstack/store"; -import { persistAtom } from "@stainless-code/persist/tanstack-store"; +import { persistAtom } from "@stainless-code/persist/sources/tanstack-store"; const theme = createAtom<"light" | "dark">("light"); const persist = persistAtom(theme, { name: "app:theme:v1" }); @@ -70,11 +70,11 @@ const persist = persistAtom(theme, { name: "app:theme:v1" }); Writes are **gated until hydration settles** — a `setState` fired before the stored state is loaded will not clobber stored state with the constructor default. This is why you don't need to manually defer your first write. - **Sync backend (localStorage):** hydration settles before first paint for stores created at module load. Caveat: `hydrate` is async and `await`s the (sync) `getItem` return, so the flag flips in a **microtask**, not synchronously — `hasHydrated()` is `false` for a brief window right after `persistStore()` returns. Module-load creation settles before React's first render; creation inside a component mount may not. No flash, no `Suspense`; `useHydrated` is still the safe way to read it. -- **Async backend (IndexedDB):** hydration settles after first paint. Gate the UI on `useHydrated` (`@stainless-code/persist/react`) or read `persist.hasHydrated()` before rendering persisted-dependent UI. +- **Async backend (IndexedDB):** hydration settles after first paint. Gate the UI on `useHydrated` (`@stainless-code/persist/frameworks/react`) or read `persist.hasHydrated()` before rendering persisted-dependent UI. ```ts import { toHydrationSignal } from "@stainless-code/persist"; -import { useHydrated } from "@stainless-code/persist/react"; +import { useHydrated } from "@stainless-code/persist/frameworks/react"; export const prefsHydration = toHydrationSignal(persist); // in a component: diff --git a/src/adapters/backends/idb.ts b/src/adapters/backends/idb.ts index 0c3672b..9403887 100644 --- a/src/adapters/backends/idb.ts +++ b/src/adapters/backends/idb.ts @@ -66,7 +66,7 @@ export function idbStateStorage( * * @example * ```ts - * import { createIdbStorage } from "@stainless-code/persist/idb"; + * import { createIdbStorage } from "@stainless-code/persist/backends/idb"; * * const storage = createIdbStorage(); // Set/Map/Date just work * ``` From 5a4b137ab841288b67b639bb0b66447307943ab6 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sat, 4 Jul 2026 19:54:33 +0300 Subject: [PATCH 09/77] feat(svelte): add Svelte hydration adapters (runes + store) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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; 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. --- .changeset/svelte-hydration.md | 10 +++ README.md | 64 +++++++++-------- bun.lock | 31 +++++++++ docs/architecture.md | 34 ++++----- package.json | 13 ++++ src/adapters/frameworks/svelte-store.test.ts | 73 ++++++++++++++++++++ src/adapters/frameworks/svelte-store.ts | 40 +++++++++++ src/adapters/frameworks/svelte.test.ts | 57 +++++++++++++++ src/adapters/frameworks/svelte.ts | 50 ++++++++++++++ tsdown.config.ts | 3 + 10 files changed, 329 insertions(+), 46 deletions(-) create mode 100644 .changeset/svelte-hydration.md create mode 100644 src/adapters/frameworks/svelte-store.test.ts create mode 100644 src/adapters/frameworks/svelte-store.ts create mode 100644 src/adapters/frameworks/svelte.test.ts create mode 100644 src/adapters/frameworks/svelte.ts diff --git a/.changeset/svelte-hydration.md b/.changeset/svelte-hydration.md new file mode 100644 index 0000000..600c738 --- /dev/null +++ b/.changeset/svelte-hydration.md @@ -0,0 +1,10 @@ +--- +"@stainless-code/persist": minor +--- + +Add Svelte hydration adapters over the `HydrationSignal` seam, covering both pre- and post-runes Svelte. Two subpaths because `svelte/reactivity` (runes) is Svelte 5+ and would break a Svelte 4 import — each subpath owns its dep range: + +- `./frameworks/svelte` (peer `svelte >=5.0.0`) — `hydratedRune(signal)` via `svelte/reactivity` `createSubscriber`. Returns `{ readonly current: boolean }`; read `current` inside a reactive context (`$derived`/`$effect`/component/`{#if}`). Subscription owned by the reactive context, cleaned up on context dispose. Post-runes. +- `./frameworks/svelte-store` (peer `svelte >=3.0.0`) — `hydratedStore(signal)` via `svelte/store` `readable`. Returns `Readable`; auto-subscribe with `$hydratedStore`. Works on Svelte 4 (pre-runes) AND Svelte 5 (for users who prefer the store API). Subscription tied to the store's subscriber lifecycle. + +Both render `true` on the server (no-op `PersistApi` is always-hydrated) — matching the `HydrationSignal` adapter contract. Each is its own subpath with `svelte` optional, no cross-entry value imports (isolation test included). 7 co-located tests: the store adapter is fully tested in bun (`svelte/store` works standalone — value, subscriber updates, signal subscribe/unsubscribe lifecycle); the runes adapter pins the value contract (the reactive auto-update needs a Svelte component context — `createSubscriber`'s start is a no-op without an owner — and rides on the `HydrationSignal` contract pinned in `core/hydration.test.ts`, same philosophy as the React `use-hydrated` bun test). diff --git a/README.md b/README.md index 903d498..e01f799 100644 --- a/README.md +++ b/README.md @@ -25,20 +25,22 @@ bun add @stainless-code/persist Each subpath owns its dependency as an **optional peer** — import only the entries you use, install the matching peer only when you do: -| Subpath | Optional peer | -| ------------------------------------------------ | ------------------------------------------- | -| `@stainless-code/persist` | none (zero-dep core) | -| `@stainless-code/persist/codecs/seroval` | `seroval` | -| `@stainless-code/persist/backends/idb` | `idb-keyval` | -| `@stainless-code/persist/sources/tanstack-store` | `@tanstack/store` | -| `@stainless-code/persist/frameworks/react` | `react` | -| `@stainless-code/persist/transport/crosstab` | none (web global) | -| `@stainless-code/persist/codecs/zod` | `zod` | -| `@stainless-code/persist/frameworks/solid` | `solid-js` | -| `@stainless-code/persist/frameworks/vue` | `vue` | -| `@stainless-code/persist/backends/async-storage` | `@react-native-async-storage/async-storage` | -| `@stainless-code/persist/backends/mmkv` | `react-native-mmkv` | -| `@stainless-code/persist/backends/secure-store` | `expo-secure-store` | +| Subpath | Optional peer | +| ------------------------------------------------- | ------------------------------------------- | +| `@stainless-code/persist` | none (zero-dep core) | +| `@stainless-code/persist/codecs/seroval` | `seroval` | +| `@stainless-code/persist/codecs/zod` | `zod` | +| `@stainless-code/persist/backends/idb` | `idb-keyval` | +| `@stainless-code/persist/backends/async-storage` | `@react-native-async-storage/async-storage` | +| `@stainless-code/persist/backends/mmkv` | `react-native-mmkv` | +| `@stainless-code/persist/backends/secure-store` | `expo-secure-store` | +| `@stainless-code/persist/transport/crosstab` | none (web global) | +| `@stainless-code/persist/sources/tanstack-store` | `@tanstack/store` | +| `@stainless-code/persist/frameworks/react` | `react` | +| `@stainless-code/persist/frameworks/solid` | `solid-js` | +| `@stainless-code/persist/frameworks/vue` | `vue` | +| `@stainless-code/persist/frameworks/svelte` | `svelte` | +| `@stainless-code/persist/frameworks/svelte-store` | `svelte` | ```bash # only when you use the matching entry @@ -157,20 +159,22 @@ Persistence middleware for any `getState`/`setState`/`subscribe` store (TanStack ## Entry points (one subpath = one optional peer) -| Subpath | Symbols | Optional peer | -| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | -| `@stainless-code/persist` | `persistSource`, `PersistApi`, `createStorage`, `jsonCodec`, `identityCodec`, registry, `HydrationSignal` (`hydration`) | none | -| `@stainless-code/persist/codecs/seroval` | `serovalCodec`, `createSerovalStorage` | `seroval` | -| `@stainless-code/persist/backends/idb` | `idbStateStorage`, `createIdbStorage` (structured-clone mode) | `idb-keyval` | -| `@stainless-code/persist/sources/tanstack-store` | `persistStore`, `persistAtom` | `@tanstack/store` (types only) | -| `@stainless-code/persist/frameworks/react` | `useHydrated` React hook | `react` | -| `@stainless-code/persist/transport/crosstab` | `createBroadcastCrossTab` | none (web global) | -| `@stainless-code/persist/codecs/zod` | `zodCodec`, `createZodStorage` | `zod` | -| `@stainless-code/persist/frameworks/solid` | `useHydrated` (Solid `Accessor`) | `solid-js` | -| `@stainless-code/persist/frameworks/vue` | `useHydrated` (Vue `Ref`) | `vue` | -| `@stainless-code/persist/backends/async-storage` | `asyncStorageStateStorage`, `createAsyncStorage` | `@react-native-async-storage/async-storage` | -| `@stainless-code/persist/backends/mmkv` | `mmkvStateStorage`, `createMmkvStorage` | `react-native-mmkv` | -| `@stainless-code/persist/backends/secure-store` | `secureStoreStateStorage`, `createSecureStoreStorage` | `expo-secure-store` | +| Subpath | Symbols | Optional peer | +| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| `@stainless-code/persist` | `persistSource`, `PersistApi`, `createStorage`, `jsonCodec`, `identityCodec`, registry, `HydrationSignal` (`hydration`) | none | +| `@stainless-code/persist/codecs/seroval` | `serovalCodec`, `createSerovalStorage` | `seroval` | +| `@stainless-code/persist/codecs/zod` | `zodCodec`, `createZodStorage` | `zod` | +| `@stainless-code/persist/backends/idb` | `idbStateStorage`, `createIdbStorage` (structured-clone mode) | `idb-keyval` | +| `@stainless-code/persist/backends/async-storage` | `asyncStorageStateStorage`, `createAsyncStorage` | `@react-native-async-storage/async-storage` | +| `@stainless-code/persist/backends/mmkv` | `mmkvStateStorage`, `createMmkvStorage` | `react-native-mmkv` | +| `@stainless-code/persist/backends/secure-store` | `secureStoreStateStorage`, `createSecureStoreStorage` | `expo-secure-store` | +| `@stainless-code/persist/transport/crosstab` | `createBroadcastCrossTab` | none (web global) | +| `@stainless-code/persist/sources/tanstack-store` | `persistStore`, `persistAtom` | `@tanstack/store` (types only) | +| `@stainless-code/persist/frameworks/react` | `useHydrated` React hook | `react` | +| `@stainless-code/persist/frameworks/solid` | `useHydrated` (Solid `Accessor`) | `solid-js` | +| `@stainless-code/persist/frameworks/vue` | `useHydrated` (Vue `Ref`) | `vue` | +| `@stainless-code/persist/frameworks/svelte` | `hydratedRune` (Svelte 5 runes `current`) | `svelte` (>=5) | +| `@stainless-code/persist/frameworks/svelte-store` | `hydratedStore` (Svelte `Readable`) | `svelte` (>=3) | No barrel — importing a subpath is the dependency opt-in. @@ -392,12 +396,12 @@ Caveats that matter per backend: async backends (IDB) can't settle hydration bef ## Writing a framework adapter -The React hook (`@stainless-code/persist/frameworks/react`) is ~20 lines over `HydrationSignal` — every adapter is the same shape. Solid (`@stainless-code/persist/frameworks/solid`, `Accessor` via `from`) and Vue (`@stainless-code/persist/frameworks/vue`, `Ref` via `shallowRef` + `onScopeDispose`) ship the same way. The contract (full version on `HydrationSignal`'s JSDoc): subscribe returns an idempotent unsubscribe; each subscribe call is an independent subscription; **no initial notification and no payload** — pull `isHydrated()` after attach and on every notification; transitions while detached aren't replayed (the snapshot re-read recovers); **render `hydrated: true` on the server** (no storage server-side); `null` signal = no persistence = hydrated. +The React hook (`@stainless-code/persist/frameworks/react`) is ~20 lines over `HydrationSignal` — every adapter is the same shape. Solid (`@stainless-code/persist/frameworks/solid`, `Accessor` via `from`), Vue (`@stainless-code/persist/frameworks/vue`, `Ref` via `shallowRef` + `onScopeDispose`), and Svelte (`@stainless-code/persist/frameworks/svelte` runes `hydratedRune`; `@stainless-code/persist/frameworks/svelte-store` `hydratedStore` for Svelte 4 + Svelte 5 store users) ship the same way. The contract (full version on `HydrationSignal`'s JSDoc): subscribe returns an idempotent unsubscribe; each subscribe call is an independent subscription; **no initial notification and no payload** — pull `isHydrated()` after attach and on every notification; transitions while detached aren't replayed (the snapshot re-read recovers); **render `hydrated: true` on the server** (no storage server-side); `null` signal = no persistence = hydrated. ```ts import type { HydrationSignal } from "@stainless-code/persist"; -// Svelte 5 sketch +// The shipped `./frameworks/svelte` adapter, in full — every adapter is this shape: export function hydratedRune(signal: HydrationSignal | null) { if (!signal) return { diff --git a/bun.lock b/bun.lock index 92e86cb..803e57c 100644 --- a/bun.lock +++ b/bun.lock @@ -29,6 +29,7 @@ "react-native-mmkv": "^4.3.2", "seroval": "1.5.4", "solid-js": "^1.9.14", + "svelte": "^5.56.4", "tsdown": "0.22.3", "typedoc": "0.28.19", "typescript": "6.0.3", @@ -37,18 +38,24 @@ "zod": "^4.4.3", }, "peerDependencies": { + "@react-native-async-storage/async-storage": ">=1.0.0", "@tanstack/store": ">=0.10.0", + "expo-secure-store": ">=12.0.0", "idb-keyval": ">=4.0.0", "react": "^18.0.0 || ^19.0.0", + "react-native-mmkv": ">=4.0.0", "seroval": ">=1.0.0", "solid-js": ">=1.6.0", "vue": ">=3.3.0", "zod": ">=3.20.0", }, "optionalPeers": [ + "@react-native-async-storage/async-storage", "@tanstack/store", + "expo-secure-store", "idb-keyval", "react", + "react-native-mmkv", "seroval", "solid-js", "vue", @@ -503,6 +510,8 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="], + "@tanstack/intent": ["@tanstack/intent@0.3.4", "", { "dependencies": { "cac": "^6.7.14", "jsonc-parser": "^3.3.1", "semver": "^7.8.4", "std-env": "^4.1.0", "yaml": "2.9.0" }, "bin": { "intent": "dist/cli.mjs" } }, "sha512-yK1VRa118Xdcj+YmuVPVVkSLtyabYnr7Wcn6sBMdHhFEfav/QHIP0G7NdNOA7qSHgbrXiKmLMyYJCQpJ8OLgfQ=="], "@tanstack/store": ["@tanstack/store@0.11.0", "", {}, "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw=="], @@ -539,6 +548,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], @@ -633,6 +644,8 @@ "ast-kit": ["ast-kit@3.0.0", "", { "dependencies": { "@babel/parser": "^8.0.0", "estree-walker": "^3.0.3", "pathe": "^2.0.3" } }, "sha512-8OG92q3R35qjC/4i6BLBMg8IB+fClWu/1PEwg2Z9Rn+BuNaiEgJzpzn+pxWOdHJWDCAwu2JP0wCDTozAM4QirQ=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="], "babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.13.0", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5", "core-js-compat": "^3.43.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A=="], @@ -709,6 +722,8 @@ "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -755,6 +770,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], + "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], "dnssd-advertise": ["dnssd-advertise@1.1.6", "", {}, "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg=="], @@ -793,8 +810,12 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "esrap": ["esrap@2.2.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], @@ -933,6 +954,8 @@ "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + "is-subdir": ["is-subdir@1.2.0", "", { "dependencies": { "better-path-resolve": "1.0.0" } }, "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw=="], "is-windows": ["is-windows@1.0.2", "", {}, "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA=="], @@ -1005,6 +1028,8 @@ "listr2": ["listr2@10.2.2", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw=="], + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], @@ -1335,6 +1360,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "svelte": ["svelte@5.56.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], "term-size": ["term-size@2.2.1", "", {}, "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg=="], @@ -1465,6 +1492,8 @@ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@babel/core/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], @@ -1627,6 +1656,8 @@ "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "svelte/aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + "terminal-link/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], diff --git a/docs/architecture.md b/docs/architecture.md index 711003d..8b9dc07 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,20 +16,22 @@ Persistence is bound to a structural `PersistableSource` (`getState` / `setState ## Entry points (one subpath = one optional peer) -| Entry | Module | Optional peer | -| ------------------------------------------------ | ------------------------------------------- | --------------------------------------------- | -| `@stainless-code/persist` | `core/index` (`persist-core` + `hydration`) | none (zero-dep core, enforced by a gate test) | -| `@stainless-code/persist/codecs/seroval` | `adapters/codecs/seroval` | `seroval` | -| `@stainless-code/persist/codecs/zod` | `adapters/codecs/zod` | `zod` | -| `@stainless-code/persist/backends/idb` | `adapters/backends/idb` | `idb-keyval` | -| `@stainless-code/persist/backends/async-storage` | `adapters/backends/async-storage` | `@react-native-async-storage/async-storage` | -| `@stainless-code/persist/backends/mmkv` | `adapters/backends/mmkv` | `react-native-mmkv` | -| `@stainless-code/persist/backends/secure-store` | `adapters/backends/secure-store` | `expo-secure-store` | -| `@stainless-code/persist/transport/crosstab` | `adapters/transport/crosstab` | none (web global) | -| `@stainless-code/persist/sources/tanstack-store` | `adapters/sources/tanstack-store` | `@tanstack/store` (types only) | -| `@stainless-code/persist/frameworks/react` | `adapters/frameworks/react` | `react` | -| `@stainless-code/persist/frameworks/solid` | `adapters/frameworks/solid` | `solid-js` | -| `@stainless-code/persist/frameworks/vue` | `adapters/frameworks/vue` | `vue` | +| Entry | Module | Optional peer | +| ------------------------------------------------- | ------------------------------------------- | --------------------------------------------- | +| `@stainless-code/persist` | `core/index` (`persist-core` + `hydration`) | none (zero-dep core, enforced by a gate test) | +| `@stainless-code/persist/codecs/seroval` | `adapters/codecs/seroval` | `seroval` | +| `@stainless-code/persist/codecs/zod` | `adapters/codecs/zod` | `zod` | +| `@stainless-code/persist/backends/idb` | `adapters/backends/idb` | `idb-keyval` | +| `@stainless-code/persist/backends/async-storage` | `adapters/backends/async-storage` | `@react-native-async-storage/async-storage` | +| `@stainless-code/persist/backends/mmkv` | `adapters/backends/mmkv` | `react-native-mmkv` | +| `@stainless-code/persist/backends/secure-store` | `adapters/backends/secure-store` | `expo-secure-store` | +| `@stainless-code/persist/transport/crosstab` | `adapters/transport/crosstab` | none (web global) | +| `@stainless-code/persist/sources/tanstack-store` | `adapters/sources/tanstack-store` | `@tanstack/store` (types only) | +| `@stainless-code/persist/frameworks/react` | `adapters/frameworks/react` | `react` | +| `@stainless-code/persist/frameworks/solid` | `adapters/frameworks/solid` | `solid-js` | +| `@stainless-code/persist/frameworks/vue` | `adapters/frameworks/vue` | `vue` | +| `@stainless-code/persist/frameworks/svelte` | `adapters/frameworks/svelte` | `svelte` (>=5 runes) | +| `@stainless-code/persist/frameworks/svelte-store` | `adapters/frameworks/svelte-store` | `svelte` (>=3 store) | No barrel — importing a subpath is the dependency opt-in. Each subpath entry owns its peer dep, which stays external in the build (`tsdown.config.ts` `neverBundle`) so consumers tree-shake cleanly. @@ -43,13 +45,13 @@ No barrel — importing a subpath is the dependency opt-in. Each subpath entry o - `backends/` — `StateStorage` adapters (idb, async-storage, mmkv, secure-store) - `transport/` — `CrossTabEventTarget` adapters (crosstab — BroadcastChannel bridge) - `sources/` — `PersistableSource` adapters (tanstack-store) - - `frameworks/` — `HydrationSignal` framework adapters (react, solid, vue) + - `frameworks/` — `HydrationSignal` framework adapters (react, solid, vue, svelte, svelte-store) A per-entry self-check test pins the invariant: every adapter's relative imports resolve into `core/` (no cross-adapter coupling). `dist/` is flat (`dist/.mjs`) via tsdown's record-form `entry`, regardless of src depth. ## Hydration lifecycle -`persistSource` hydrates on create (skip with `skipHydration`; `rehydrate()` is awaitable), subscribe-writes on every `setState` (gated until hydrated; optional trailing `throttleMs`), and tears down via `destroy()`. The hydration signal (`HydrationSignal` from `hydration`) is observed from outside the store — framework adapters mount it into their external-store mechanism (React `useSyncExternalStore` via `./frameworks/react`, Solid `from` via `./frameworks/solid`, Vue `shallowRef` + `onScopeDispose` via `./frameworks/vue`; a Svelte `createSubscriber` sketch is in the README) without coupling to the store's read path. SSR policy: render `hydrated = true` on the server; `null` signal = no persistence = hydrated. +`persistSource` hydrates on create (skip with `skipHydration`; `rehydrate()` is awaitable), subscribe-writes on every `setState` (gated until hydrated; optional trailing `throttleMs`), and tears down via `destroy()`. The hydration signal (`HydrationSignal` from `hydration`) is observed from outside the store — framework adapters mount it into their external-store mechanism (React `useSyncExternalStore` via `./frameworks/react`, Solid `from` via `./frameworks/solid`, Vue `shallowRef` + `onScopeDispose` via `./frameworks/vue`, Svelte runes `createSubscriber` via `./frameworks/svelte` / stores `readable` via `./frameworks/svelte-store`) without coupling to the store's read path. SSR policy: render `hydrated = true` on the server; `null` signal = no persistence = hydrated. ## Sync vs async diff --git a/package.json b/package.json index d98cc5f..7ebdbba 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,14 @@ "./frameworks/vue": { "types": "./dist/vue.d.mts", "import": "./dist/vue.mjs" + }, + "./frameworks/svelte": { + "types": "./dist/svelte.d.mts", + "import": "./dist/svelte.mjs" + }, + "./frameworks/svelte-store": { + "types": "./dist/svelte-store.d.mts", + "import": "./dist/svelte-store.mjs" } }, "publishConfig": { @@ -134,6 +142,7 @@ "react-native-mmkv": "^4.3.2", "seroval": "1.5.4", "solid-js": "^1.9.14", + "svelte": "^5.56.4", "tsdown": "0.22.3", "typedoc": "0.28.19", "typescript": "6.0.3", @@ -150,6 +159,7 @@ "react-native-mmkv": ">=4.0.0", "seroval": ">=1.0.0", "solid-js": ">=1.6.0", + "svelte": ">=3.0.0", "vue": ">=3.3.0", "zod": ">=3.20.0" }, @@ -169,6 +179,9 @@ "solid-js": { "optional": true }, + "svelte": { + "optional": true + }, "vue": { "optional": true }, diff --git a/src/adapters/frameworks/svelte-store.test.ts b/src/adapters/frameworks/svelte-store.test.ts new file mode 100644 index 0000000..e7d95d8 --- /dev/null +++ b/src/adapters/frameworks/svelte-store.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "bun:test"; + +import { get } from "svelte/store"; + +import { hydratedStore } from "./svelte-store"; + +function createFakeSignal() { + let hydrated = false; + const listeners = new Set<() => void>(); + return { + subscribeHydrated: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + isHydrated: () => hydrated, + set: (value: boolean) => { + hydrated = value; + listeners.forEach((l) => l()); + }, + listenerCount: () => listeners.size, + }; +} + +describe("hydratedStore (svelte 3+ stores)", () => { + it("yields true for a null signal", () => { + expect(get(hydratedStore(null))).toBe(true); + }); + + it("mirrors isHydrated() and pushes updates to subscribers", () => { + const signal = createFakeSignal(); + const store = hydratedStore(signal); + const values: boolean[] = []; + const unsubscribe = store.subscribe((value) => values.push(value)); + + // start runs on first subscribe → pushes the initial value (false). + expect(values).toEqual([false]); + signal.set(true); + expect(values).toEqual([false, true]); + signal.set(false); + expect(values).toEqual([false, true, false]); + + unsubscribe(); + const lengthBefore = values.length; + signal.set(true); + expect(values.length).toBe(lengthBefore); // no more pushes after unsubscribe + }); + + it("subscribes to the signal on first subscriber and unsubscribes on the last", () => { + const signal = createFakeSignal(); + const store = hydratedStore(signal); + expect(signal.listenerCount()).toBe(0); + + const unsubscribe = store.subscribe(() => {}); + expect(signal.listenerCount()).toBe(1); + + unsubscribe(); + expect(signal.listenerCount()).toBe(0); + }); +}); + +describe("svelte-store dependency isolation", () => { + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file( + new URL("./svelte-store.ts", import.meta.url), + ).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); + } + }); +}); diff --git a/src/adapters/frameworks/svelte-store.ts b/src/adapters/frameworks/svelte-store.ts new file mode 100644 index 0000000..e761ef1 --- /dev/null +++ b/src/adapters/frameworks/svelte-store.ts @@ -0,0 +1,40 @@ +// Svelte 3+ (stores) hydration entry — owns the `svelte` peer dep (>=3.0.0) +// so the core stays zero-dep. Ships as its own subpath entry with svelte as an +// optional peer; no barrel re-exports it (importing it IS the dep opt-in, +// enforced by an isolation test). Works on Svelte 4 (pre-runes) AND Svelte 5 +// (for users who prefer the store API). For Svelte 5 runes, use +// `./frameworks/svelte`. +import { readable } from "svelte/store"; +import type { Readable } from "svelte/store"; + +import type { HydrationSignal } from "../../core/hydration"; + +/** + * Mount a `HydrationSignal` into a Svelte `readable` store. Returns a + * `Readable` — auto-subscribe in a component with `$hydratedStore`. + * Null/undefined signal → a store that stays `true` (store stays the same with + * or without persistence). The signal subscription is tied to the store's + * subscriber lifecycle (start on first subscriber, unsubscribe on the last) — + * no manual teardown. + * + * The signal is always-hydrated on the server → the store yields `true` + * during SSR — matching the `HydrationSignal` adapter contract. + * + * @example + * ```ts + * const hydrated = hydratedStore(prefsHydration); + * // in a Svelte 4/5 component: {#if $hydrated}{:else}{/if} + * ``` + */ +export function hydratedStore( + signal: HydrationSignal | null | undefined, +): Readable { + if (!signal) return readable(true); + return readable(signal.isHydrated(), (set) => { + const unsubscribe = signal.subscribeHydrated(() => { + set(signal.isHydrated()); + }); + set(signal.isHydrated()); // pull-model: re-read on first subscriber + return unsubscribe; + }); +} diff --git a/src/adapters/frameworks/svelte.test.ts b/src/adapters/frameworks/svelte.test.ts new file mode 100644 index 0000000..88f238f --- /dev/null +++ b/src/adapters/frameworks/svelte.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "bun:test"; + +import { hydratedRune } from "./svelte"; + +function createFakeSignal() { + let hydrated = false; + const listeners = new Set<() => void>(); + return { + subscribeHydrated: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + isHydrated: () => hydrated, + set: (value: boolean) => { + hydrated = value; + listeners.forEach((l) => l()); + }, + listenerCount: () => listeners.size, + }; +} + +describe("hydratedRune (svelte 5 runes)", () => { + it("returns current=true for a null/undefined signal", () => { + expect(hydratedRune(null).current).toBe(true); + expect(hydratedRune(undefined).current).toBe(true); + }); + + it("current mirrors isHydrated() on each access (value contract)", () => { + const signal = createFakeSignal(); + const rune = hydratedRune(signal); + expect(rune.current).toBe(false); + signal.set(true); + expect(rune.current).toBe(true); + signal.set(false); + expect(rune.current).toBe(false); + }); + + // The reactive auto-update + subscription cleanup need a Svelte component + // context (`createSubscriber`'s start runs lazily inside an owner); outside + // one, `subscribe()` is a no-op. The HydrationSignal contract that wiring + // rides on is pinned in `core/hydration.test.ts`; the value contract above + // is what's exercisable in bun. +}); + +describe("svelte dependency isolation", () => { + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file( + new URL("./svelte.ts", import.meta.url), + ).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); + } + }); +}); diff --git a/src/adapters/frameworks/svelte.ts b/src/adapters/frameworks/svelte.ts new file mode 100644 index 0000000..013ce0d --- /dev/null +++ b/src/adapters/frameworks/svelte.ts @@ -0,0 +1,50 @@ +// Svelte 5 (runes) hydration entry — owns the `svelte` peer dep (>=5.0.0) so +// the core stays zero-dep. Ships as its own subpath entry with svelte as an +// optional peer; no barrel re-exports it (importing it IS the dep opt-in, +// enforced by an isolation test). For Svelte 4 (pre-runes) use +// `./frameworks/svelte-store`. +import { createSubscriber } from "svelte/reactivity"; + +import type { HydrationSignal } from "../../core/hydration"; + +const alwaysTrue: { readonly current: boolean } = { + get current() { + return true; + }, +}; + +/** + * Mount a `HydrationSignal` into Svelte 5 reactivity via `createSubscriber`. + * Returns an object with a `current` getter — read it inside a reactive + * context (`$derived`, `$effect`, a component, `{#if}`) to track the gate. + * Null/undefined signal → `current` is always `true` (store stays the same + * with or without persistence). The subscription is owned by the reactive + * context that reads `current` and cleaned up on context dispose — no manual + * teardown. + * + * The signal is always-hydrated on the server (no storage → no-op + * `PersistApi`), so `current` renders `true` during SSR — matching the + * `HydrationSignal` adapter contract. + * + * Svelte 4 (pre-runes) users: use `./frameworks/svelte-store` (`hydratedStore`). + * + * @example + * ```ts + * const hydrated = hydratedRune(prefsHydration); + * // in a component: {#if hydrated.current}{:else}{/if} + * ``` + */ +export function hydratedRune(signal: HydrationSignal | null | undefined): { + readonly current: boolean; +} { + if (!signal) return alwaysTrue; + const subscribe = createSubscriber((update) => + signal.subscribeHydrated(update), + ); + return { + get current() { + subscribe(); + return signal.isHydrated(); + }, + }; +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 1d90819..979192a 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -21,6 +21,8 @@ export default defineConfig({ react: "src/adapters/frameworks/react.ts", solid: "src/adapters/frameworks/solid.ts", vue: "src/adapters/frameworks/vue.ts", + svelte: "src/adapters/frameworks/svelte.ts", + "svelte-store": "src/adapters/frameworks/svelte-store.ts", }, outDir, format: "esm", @@ -34,6 +36,7 @@ export default defineConfig({ "react", "zod", "solid-js", + "svelte", "vue", "@react-native-async-storage/async-storage", "react-native-mmkv", From 7ad3c3423d21e98da7198c03ef663b2724c93380 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sat, 4 Jul 2026 19:56:54 +0300 Subject: [PATCH 10/77] refactor(build)!: mirror tsdown entry + dist layout to src folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/subpath-mirror-folders.md | 2 +- docs/architecture.md | 2 +- package.json | 56 ++++++++++++++-------------- tsdown.config.ts | 38 +++++++++---------- 4 files changed, 49 insertions(+), 49 deletions(-) diff --git a/.changeset/subpath-mirror-folders.md b/.changeset/subpath-mirror-folders.md index 6b63e89..31d008d 100644 --- a/.changeset/subpath-mirror-folders.md +++ b/.changeset/subpath-mirror-folders.md @@ -16,4 +16,4 @@ Reorganize the public subpath namespace to mirror the folder structure (`src/cor - `./solid` → `./frameworks/solid` - `./vue` → `./frameworks/vue` -The `.` (core) entry is unchanged. `dist/` stays flat (`dist/.mjs`); the `exports` map routes each categorized subpath to its flat dist file. Internal `src/` was refolded into `core/` + `adapters//` with the `persist-` filename prefix dropped (folder is the category, file is the subpath basename). +The `.` (core) entry is unchanged. `dist/` mirrors `src/` (`dist//.mjs` via tsdown's record-form `entry` keyed by `/`) — src folder → tsdown key → dist path → subpath, all 1:1. Internal `src/` was refolded into `core/` + `adapters//` with the `persist-` filename prefix dropped (folder is the category, file is the subpath basename). diff --git a/docs/architecture.md b/docs/architecture.md index 8b9dc07..2f6b03c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -47,7 +47,7 @@ No barrel — importing a subpath is the dependency opt-in. Each subpath entry o - `sources/` — `PersistableSource` adapters (tanstack-store) - `frameworks/` — `HydrationSignal` framework adapters (react, solid, vue, svelte, svelte-store) -A per-entry self-check test pins the invariant: every adapter's relative imports resolve into `core/` (no cross-adapter coupling). `dist/` is flat (`dist/.mjs`) via tsdown's record-form `entry`, regardless of src depth. +A per-entry self-check test pins the invariant: every adapter's relative imports resolve into `core/` (no cross-adapter coupling). `dist/` mirrors `src/` (`dist//.mjs` via tsdown's record-form `entry` keyed by `/`) — src folder → tsdown key → dist path → subpath, all 1:1. ## Hydration lifecycle diff --git a/package.json b/package.json index 7ebdbba..25cff78 100644 --- a/package.json +++ b/package.json @@ -31,60 +31,60 @@ "sideEffects": false, "exports": { ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" + "types": "./dist/core/index.d.mts", + "import": "./dist/core/index.mjs" }, "./codecs/seroval": { - "types": "./dist/seroval.d.mts", - "import": "./dist/seroval.mjs" + "types": "./dist/codecs/seroval.d.mts", + "import": "./dist/codecs/seroval.mjs" }, "./codecs/zod": { - "types": "./dist/zod.d.mts", - "import": "./dist/zod.mjs" + "types": "./dist/codecs/zod.d.mts", + "import": "./dist/codecs/zod.mjs" }, "./backends/idb": { - "types": "./dist/idb.d.mts", - "import": "./dist/idb.mjs" + "types": "./dist/backends/idb.d.mts", + "import": "./dist/backends/idb.mjs" }, "./backends/async-storage": { - "types": "./dist/async-storage.d.mts", - "import": "./dist/async-storage.mjs" + "types": "./dist/backends/async-storage.d.mts", + "import": "./dist/backends/async-storage.mjs" }, "./backends/mmkv": { - "types": "./dist/mmkv.d.mts", - "import": "./dist/mmkv.mjs" + "types": "./dist/backends/mmkv.d.mts", + "import": "./dist/backends/mmkv.mjs" }, "./backends/secure-store": { - "types": "./dist/secure-store.d.mts", - "import": "./dist/secure-store.mjs" + "types": "./dist/backends/secure-store.d.mts", + "import": "./dist/backends/secure-store.mjs" }, "./transport/crosstab": { - "types": "./dist/crosstab.d.mts", - "import": "./dist/crosstab.mjs" + "types": "./dist/transport/crosstab.d.mts", + "import": "./dist/transport/crosstab.mjs" }, "./sources/tanstack-store": { - "types": "./dist/tanstack-store.d.mts", - "import": "./dist/tanstack-store.mjs" + "types": "./dist/sources/tanstack-store.d.mts", + "import": "./dist/sources/tanstack-store.mjs" }, "./frameworks/react": { - "types": "./dist/react.d.mts", - "import": "./dist/react.mjs" + "types": "./dist/frameworks/react.d.mts", + "import": "./dist/frameworks/react.mjs" }, "./frameworks/solid": { - "types": "./dist/solid.d.mts", - "import": "./dist/solid.mjs" + "types": "./dist/frameworks/solid.d.mts", + "import": "./dist/frameworks/solid.mjs" }, "./frameworks/vue": { - "types": "./dist/vue.d.mts", - "import": "./dist/vue.mjs" + "types": "./dist/frameworks/vue.d.mts", + "import": "./dist/frameworks/vue.mjs" }, "./frameworks/svelte": { - "types": "./dist/svelte.d.mts", - "import": "./dist/svelte.mjs" + "types": "./dist/frameworks/svelte.d.mts", + "import": "./dist/frameworks/svelte.mjs" }, "./frameworks/svelte-store": { - "types": "./dist/svelte-store.d.mts", - "import": "./dist/svelte-store.mjs" + "types": "./dist/frameworks/svelte-store.d.mts", + "import": "./dist/frameworks/svelte-store.mjs" } }, "publishConfig": { diff --git a/tsdown.config.ts b/tsdown.config.ts index 979192a..1fc708c 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -2,27 +2,27 @@ import { defineConfig } from "tsdown"; const outDir = "dist"; -// Twelve subpath entries — each maps 1:1 to an `exports` entry. The core -// (`index`) re-exports `persist-core` + `hydration`; the adapters under -// `adapters//` own their optional peer deps, which stay external so -// consumers tree-shake cleanly. The record form flattens dist output to -// `dist/.mjs` regardless of the src folder depth. +// Fourteen subpath entries — each maps 1:1 to an `exports` entry and mirrors +// the src folder structure: key `/` → `src/adapters//.ts` +// → `dist//.mjs` → subpath `.//`. The core (`core/index`) +// re-exports `persist-core` + `hydration`; the adapters own their optional peer +// deps, which stay external so consumers tree-shake cleanly. export default defineConfig({ entry: { - index: "src/core/index.ts", - seroval: "src/adapters/codecs/seroval.ts", - zod: "src/adapters/codecs/zod.ts", - idb: "src/adapters/backends/idb.ts", - "async-storage": "src/adapters/backends/async-storage.ts", - mmkv: "src/adapters/backends/mmkv.ts", - "secure-store": "src/adapters/backends/secure-store.ts", - crosstab: "src/adapters/transport/crosstab.ts", - "tanstack-store": "src/adapters/sources/tanstack-store.ts", - react: "src/adapters/frameworks/react.ts", - solid: "src/adapters/frameworks/solid.ts", - vue: "src/adapters/frameworks/vue.ts", - svelte: "src/adapters/frameworks/svelte.ts", - "svelte-store": "src/adapters/frameworks/svelte-store.ts", + "core/index": "src/core/index.ts", + "codecs/seroval": "src/adapters/codecs/seroval.ts", + "codecs/zod": "src/adapters/codecs/zod.ts", + "backends/idb": "src/adapters/backends/idb.ts", + "backends/async-storage": "src/adapters/backends/async-storage.ts", + "backends/mmkv": "src/adapters/backends/mmkv.ts", + "backends/secure-store": "src/adapters/backends/secure-store.ts", + "transport/crosstab": "src/adapters/transport/crosstab.ts", + "sources/tanstack-store": "src/adapters/sources/tanstack-store.ts", + "frameworks/react": "src/adapters/frameworks/react.ts", + "frameworks/solid": "src/adapters/frameworks/solid.ts", + "frameworks/vue": "src/adapters/frameworks/vue.ts", + "frameworks/svelte": "src/adapters/frameworks/svelte.ts", + "frameworks/svelte-store": "src/adapters/frameworks/svelte-store.ts", }, outDir, format: "esm", From 35f69a44c77a4e55a8a084d4ebeec5aa348cfd03 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sat, 4 Jul 2026 20:13:43 +0300 Subject: [PATCH 11/77] feat(backends): add encrypted + compressed storage wrappers; README comparison + migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/encrypted-compressed.md | 12 ++ README.md | 255 ++++++++++++++++++++++- docs/architecture.md | 4 +- package.json | 8 + src/adapters/backends/compressed.test.ts | 177 ++++++++++++++++ src/adapters/backends/compressed.ts | 113 ++++++++++ src/adapters/backends/encrypted.test.ts | 190 +++++++++++++++++ src/adapters/backends/encrypted.ts | 111 ++++++++++ tsdown.config.ts | 2 + 9 files changed, 866 insertions(+), 6 deletions(-) create mode 100644 .changeset/encrypted-compressed.md create mode 100644 src/adapters/backends/compressed.test.ts create mode 100644 src/adapters/backends/compressed.ts create mode 100644 src/adapters/backends/encrypted.test.ts create mode 100644 src/adapters/backends/encrypted.ts diff --git a/.changeset/encrypted-compressed.md b/.changeset/encrypted-compressed.md new file mode 100644 index 0000000..e24421b --- /dev/null +++ b/.changeset/encrypted-compressed.md @@ -0,0 +1,12 @@ +--- +"@stainless-code/persist": minor +--- + +Add two zero-dep storage wrappers over the `StateStorage` seam — both async web-global adapters (no peer dep), composing with `createStorage(backend, codec)`: + +- `./backends/encrypted` — `createEncryptedStorage(getStorage, { key })`: AES-GCM via WebCrypto (`crypto.subtle`). Each stored value is `base64(iv).base64(ciphertext)`; the AES-GCM auth tag means a wrong key or tampered ciphertext throws on decrypt → persist-core's corrupt-payload path returns `null` (or `clearCorruptOnFailure` removes the key). Returns `undefined` when `crypto.subtle` is unavailable so `createStorage` collapses to the no-op `PersistApi`. +- `./backends/compressed` — `createCompressedStorage(getStorage, { format? })`: native `CompressionStream`/`DecompressionStream` (`gzip` | `deflate` | `deflate-raw`, default `gzip`); output is base64 so it stays string-wire. Returns `undefined` when the stream APIs are unavailable. Stacks with `createEncryptedStorage` (compress-then-encrypt is the standard order). + +**Design note:** encryption + compression are backend **wrappers**, not sync `StorageCodec`s, because `crypto.subtle` and the stream APIs are async and the `StorageCodec` seam is sync. The codec serializes the envelope (sync); the wrapper encrypts/compresses the serialized string (async). 14 co-located tests (round-trip, ciphertext-not-plaintext, wrong-key-fails / compression-ratio, missing-key, formats, persistSource end-to-end, availability guard, dependency isolation). + +Also adds a README comparison table vs zustand-persist / redux-persist / @tanstack/query-persist-client / pinia-persist, and a migration guide with option-mapping tables + port snippets for each incumbent. diff --git a/README.md b/README.md index e01f799..f644cce 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ Each subpath owns its dependency as an **optional peer** — import only the ent | `@stainless-code/persist/backends/async-storage` | `@react-native-async-storage/async-storage` | | `@stainless-code/persist/backends/mmkv` | `react-native-mmkv` | | `@stainless-code/persist/backends/secure-store` | `expo-secure-store` | +| `@stainless-code/persist/backends/encrypted` | none (web global) | +| `@stainless-code/persist/backends/compressed` | none (web global) | | `@stainless-code/persist/transport/crosstab` | none (web global) | | `@stainless-code/persist/sources/tanstack-store` | `@tanstack/store` | | `@stainless-code/persist/frameworks/react` | `react` | @@ -151,6 +153,224 @@ IndexedDB fires no `storage` events — `crossTab: true` alone does nothing on t Both TanStack Persist and zustand persist wire a single store library to a single storage with a flat options bag. `@stainless-code/persist` is a **middleware model with a first-class hydration lifecycle**: persistence is bound to a structural `PersistableSource` (`getState`/`setState`/`subscribe`) rather than a specific store, so the same middleware persists TanStack Store, zustand, Redux, or a hand-rolled atom. Three seams — backend (`StateStorage`), codec (`StorageCodec`), source (`PersistableSource`) — make every backend × codec cell a one-line composition. The hydration lifecycle (`onHydrate` / `onFinishHydration` / `hasHydrated`, surfaced via `HydrationSignal` and `useHydrated`) gates UI flash without coupling to the store's read path, versioned `migrate` handles schema evolution, `crossTab` + `onCrossTabRemove` syncs tabs, and `retryWrite` shrinks-or-gives-up on quota errors with a write-generation guard so stale retries never clobber newer state. +## Comparison with other persist libraries + +Every row is a seam or lifecycle concern — not a roadmap item. `@stainless-code/persist` treats each as composable; incumbents bake most of them into framework-specific middleware. + +| Capability | `@stainless-code/persist` | zustand-persist | redux-persist | `@tanstack/query-persist-client` | pinia-persist | +| ----------------------------------------------------- | :-----------------------: | :-------------: | :-----------: | :------------------------------: | :-----------: | +| Store-agnostic (structural source) | ✓ | ✗ | ~ | ~ | ✗ | +| Codec seam (swap serialization) | ✓ | ~ | ~ | ✗ | ~ | +| Storage seam (swap backend) | ✓ | ✓ | ✓ | ✓ | ✓ | +| Hydration signal (gate UI flash) | ✓ | ~ | ~ | ✗ | ✗ | +| Cross-tab sync | ✓ | ✗ | ✗ | ✗ | ✗ | +| `migrate` (versioned) | ✓ | ✓ | ✓ | ~ | ~ | +| `retryWrite` (quota shrink-or-give-up) | ✓ | ✗ | ✗ | ~ | ✗ | +| `throttleMs` | ✓ | ✗ | ✗ | ✗ | ✗ | +| `maxAge` / `buster` expiry | ✓ | ✗ | ✗ | ✓ | ✗ | +| Schema validation (codec) | ✓ | ✗ | ✗ | ✗ | ✗ | +| Framework hydration adapters (React/Solid/Vue/Svelte) | ✓ | ✗ | ✗ | ✗ | ✗ | + +**Differentiator:** `@stainless-code/persist` is the only library here with a first-class hydration signal **and** a codec seam **and** a storage seam — so every backend×codec cell is a one-line composition, not a feature request. + +## Migrating from … + +Same mental model everywhere: pick a source (`PersistableSource`), wire storage, gate UI with `HydrationSignal`. Option names map 1:1 where noted; gaps are explicit. + +### Migrating from zustand-persist + +Near 1:1 — this API is modeled on zustand persist, plus store-agnostic sources, a first-class `HydrationSignal`, and a codec seam. + +| zustand-persist | `@stainless-code/persist` | +| ------------------------------- | ---------------------------------------------------------------------------------- | +| `name` | `name` | +| `storage` / `createJSONStorage` | `storage` / `createJSONStorage` (core) | +| `partialize` | `partialize` | +| `version` | `version` | +| `migrate` | `migrate` | +| `merge` | `merge` | +| `onRehydrateStorage` | `onRehydrateStorage` | +| `skipHydration` | `skipHydration` | +| `persist` middleware | `persistStore(store, opts)` or `persistSource(source, opts)` | +| — | `toHydrationSignal(persist)` + `useHydrated` (no zustand equivalent) | +| — | `crossTab`, `maxAge`, `buster`, `throttleMs`, `retryWrite` (no zustand equivalent) | + +```ts +// zustand-persist +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; + +export const usePrefs = create( + persist(() => ({ theme: "light" }), { + name: "app:prefs:v1", + storage: createJSONStorage(() => localStorage), + }), +); + +// @stainless-code/persist +import { Store } from "@tanstack/store"; +import { createJSONStorage, toHydrationSignal } from "@stainless-code/persist"; +import { persistStore } from "@stainless-code/persist/sources/tanstack-store"; +import { useHydrated } from "@stainless-code/persist/frameworks/react"; + +export const prefsStore = new Store({ theme: "light" }); +const persist = persistStore(prefsStore, { + name: "app:prefs:v1", + storage: createJSONStorage(() => localStorage), +}); +export const prefsHydration = toHydrationSignal(persist); +// const { hydrated } = useHydrated(prefsHydration); +``` + +### Migrating from redux-persist + +redux-persist reconciles whole reducer trees implicitly; here `merge` is explicit and a hydration signal gates UI until the snapshot lands. redux stores have `getState`/`subscribe`/`dispatch` (no `setState`), so wrap the store in a `PersistableSource` whose `setState` dispatches an action your reducer recognizes to replace state. + +| redux-persist | `@stainless-code/persist` | +| --------------------------------- | -------------------------------------------------------------------------- | +| `key` | `name` | +| `storage` | `storage` | +| `version` | `version` | +| `migrate` | `migrate` | +| `whitelist` / `blacklist` | `partialize` (project the slice) | +| `transforms` | `merge` or custom `StorageCodec` via `createStorage` | +| `stateReconciler` | `merge` (default shallow-spread; customize) | +| `persistReducer` / `persistStore` | `persistSource(reduxSource, opts)` | +| — | `toHydrationSignal` + framework adapter (no redux-persist equivalent) | +| — | `crossTab`, `maxAge`, `buster`, `throttleMs`, `retryWrite` (no equivalent) | + +```ts +// redux-persist +import { persistReducer, persistStore } from "redux-persist"; +import storage from "redux-persist/lib/storage"; + +const persistedReducer = persistReducer({ key: "root", storage }, rootReducer); +export const store = createStore(persistedReducer); +persistStore(store); + +// @stainless-code/persist — wrap the redux store (dispatch ↔ setState) +import { + createJSONStorage, + persistSource, + toHydrationSignal, +} from "@stainless-code/persist"; + +const reduxSource = { + getState: () => store.getState(), + setState: (updater) => + store.dispatch({ type: "PERSIST_SET", payload: updater(store.getState()) }), + subscribe: (listener) => store.subscribe(listener), +}; +const persist = persistSource(reduxSource, { + name: "root", + storage: createJSONStorage(() => localStorage), +}); +export const rootHydration = toHydrationSignal(persist); +``` + +### Migrating from @tanstack/query-persist-client + +query-persist-client owns the query cache lifecycle; here the seam is any `PersistableSource` — supply a cache-shaped source and compose storage like any other store. + +| query-persist-client | `@stainless-code/persist` | +| ----------------------------------- | ---------------------------------------------------------------------------------------- | +| `persister` (`Persister` interface) | `storage` (`PersistStorage` — `getItem`/`setItem`/`removeItem`, or wrap `createStorage`) | +| `maxAge` | `maxAge` | +| `buster` | `buster` | +| `retry` | `retryWrite` | +| `dehydrate` / `hydrate` | `partialize` / `merge` | +| `persistQueryClient` | `persistSource(queryCacheSource, opts)` | +| — | `toHydrationSignal` + framework adapter (no equivalent) | +| — | codec seam via `createStorage(backend, codec)` (no equivalent) | + +```ts +// @tanstack/query-persist-client +import { persistQueryClient } from "@tanstack/query-persist-client"; +import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; + +persistQueryClient({ + queryClient, + persister: createSyncStoragePersister({ storage: window.localStorage }), + maxAge: 1000 * 60 * 60 * 24, + buster: BUILD_ID, +}); + +// @stainless-code/persist — supply a cache-shaped source +import { + createJSONStorage, + persistSource, + toHydrationSignal, +} from "@stainless-code/persist"; + +const queryCacheSource = { + getState: () => queryClient.getQueryCache().getAll(), + setState: (entries) => + entries.forEach(({ queryKey, state }) => + queryClient.setQueryData(queryKey, state.data), + ), + subscribe: (cb) => queryClient.getQueryCache().subscribe(cb), +}; +const persist = persistSource(queryCacheSource, { + name: "query-cache", + storage: createJSONStorage(() => localStorage), + maxAge: 1000 * 60 * 60 * 24, + buster: BUILD_ID, +}); +export const queryHydration = toHydrationSignal(persist); +``` + +### Migrating from pinia-persist + +pinia-persist is a Pinia plugin; here persistence is a middleware call on any reactive source — same storage, explicit partialization, optional codec. Wrap the Pinia store's `$state` + `$subscribe` in a `PersistableSource`. + +| pinia-persist | `@stainless-code/persist` | +| -------------------------------- | ------------------------------------------------------------------------------------- | +| `key` | `name` | +| `storage` | `storage` | +| `paths` | `partialize` (pick paths) | +| `serializer` | custom `StorageCodec` or default `jsonCodec` via `createStorage` | +| `beforeRestore` / `afterRestore` | `onRehydrateStorage` | +| `debug` | `onError` | +| `pinia.use(plugin)` | `persistSource(piniaSource, opts)` | +| — | `toHydrationSignal` + framework adapter (no equivalent) | +| — | `crossTab`, `migrate`, `maxAge`, `buster`, `throttleMs`, `retryWrite` (no equivalent) | + +```ts +// pinia-persist +import { createPinia } from "pinia"; +import piniaPluginPersistedstate from "pinia-plugin-persistedstate"; + +const pinia = createPinia(); +pinia.use( + piniaPluginPersistedstate({ + key: "prefs", + storage: localStorage, + paths: ["theme"], + }), +); + +// @stainless-code/persist — wrap $state / $subscribe +import { + createJSONStorage, + persistSource, + toHydrationSignal, +} from "@stainless-code/persist"; + +const piniaSource = { + getState: () => piniaStore.$state, + setState: (updater) => { + piniaStore.$state = updater(piniaStore.$state); + }, + subscribe: (listener) => piniaStore.$subscribe(() => listener()), +}; +const persist = persistSource(piniaSource, { + name: "prefs", + storage: createJSONStorage(() => localStorage), + partialize: (s) => ({ theme: s.theme }), +}); +export const prefsHydration = toHydrationSignal(persist); +``` + --- # Extensibility guide @@ -168,6 +388,8 @@ Persistence middleware for any `getState`/`setState`/`subscribe` store (TanStack | `@stainless-code/persist/backends/async-storage` | `asyncStorageStateStorage`, `createAsyncStorage` | `@react-native-async-storage/async-storage` | | `@stainless-code/persist/backends/mmkv` | `mmkvStateStorage`, `createMmkvStorage` | `react-native-mmkv` | | `@stainless-code/persist/backends/secure-store` | `secureStoreStateStorage`, `createSecureStoreStorage` | `expo-secure-store` | +| `@stainless-code/persist/backends/encrypted` | `createEncryptedStorage` (AES-GCM WebCrypto) | none (web global) | +| `@stainless-code/persist/backends/compressed` | `createCompressedStorage` (gzip/deflate/deflate-raw) | none (web global) | | `@stainless-code/persist/transport/crosstab` | `createBroadcastCrossTab` | none (web global) | | `@stainless-code/persist/sources/tanstack-store` | `persistStore`, `persistAtom` | `@tanstack/store` (types only) | | `@stainless-code/persist/frameworks/react` | `useHydrated` React hook | `react` | @@ -221,7 +443,7 @@ const superjsonCodec = { encode: superjson.stringify, decode: superjson.parse }; const encryptedCodec = { encode: (v) => encrypt(JSON.stringify(v)), decode: (raw) => JSON.parse(decrypt(raw)), -}; +}; // sync cipher — for WebCrypto (async) use ./backends/encrypted ``` **3. Store source (`PersistableSource`)** — structural, so the middleware persists anything reactive: @@ -248,13 +470,36 @@ import { idbStateStorage, createIdbStorage, } from "@stainless-code/persist/backends/idb"; +import { createEncryptedStorage } from "@stainless-code/persist/backends/encrypted"; +import { createCompressedStorage } from "@stainless-code/persist/backends/compressed"; import { serovalCodec } from "@stainless-code/persist/codecs/seroval"; import { persistStore } from "@stainless-code/persist/sources/tanstack-store"; -// Encryption at rest over IndexedDB -createStorage(() => idbStateStorage(), encryptedCodec, { - clearCorruptOnFailure: true, -}); +// Encryption at rest (AES-GCM WebCrypto) over IndexedDB. Encryption is a +// backend wrapper (crypto.subtle is async, the StorageCodec seam is sync), +// not a sync codec; idbStateStorage() in string-wire mode holds the encrypted base64. +const key = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"], +); +createStorage( + () => createEncryptedStorage(() => idbStateStorage(), { key })!, + serovalCodec(), + { + clearCorruptOnFailure: true, + }, +); + +// Compress-then-encrypt (standard order) — the two wrappers stack +createStorage( + () => + createEncryptedStorage( + () => createCompressedStorage(() => idbStateStorage())!, + { key }, + )!, + serovalCodec(), +); // Legacy string payloads in IDB (written by an older version) createStorage(() => idbStateStorage(), serovalCodec()); diff --git a/docs/architecture.md b/docs/architecture.md index 2f6b03c..90b868b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,6 +25,8 @@ Persistence is bound to a structural `PersistableSource` (`getState` / `setState | `@stainless-code/persist/backends/async-storage` | `adapters/backends/async-storage` | `@react-native-async-storage/async-storage` | | `@stainless-code/persist/backends/mmkv` | `adapters/backends/mmkv` | `react-native-mmkv` | | `@stainless-code/persist/backends/secure-store` | `adapters/backends/secure-store` | `expo-secure-store` | +| `@stainless-code/persist/backends/encrypted` | `adapters/backends/encrypted` | none (web global) | +| `@stainless-code/persist/backends/compressed` | `adapters/backends/compressed` | none (web global) | | `@stainless-code/persist/transport/crosstab` | `adapters/transport/crosstab` | none (web global) | | `@stainless-code/persist/sources/tanstack-store` | `adapters/sources/tanstack-store` | `@tanstack/store` (types only) | | `@stainless-code/persist/frameworks/react` | `adapters/frameworks/react` | `react` | @@ -42,7 +44,7 @@ No barrel — importing a subpath is the dependency opt-in. Each subpath entry o - **`core/`** — the zero-dep engine (`persist-core.ts`, `hydration.ts`) plus `index.ts` (the `.` entry that re-exports both). Nothing in `core/` imports an adapter. - **`adapters//`** — opt-in entries that own an optional peer and import only from `core/`: - `codecs/` — `StorageCodec` adapters (seroval, zod) - - `backends/` — `StateStorage` adapters (idb, async-storage, mmkv, secure-store) + - `backends/` — `StateStorage` adapters + wrappers (idb, async-storage, mmkv, secure-store, encrypted, compressed) - `transport/` — `CrossTabEventTarget` adapters (crosstab — BroadcastChannel bridge) - `sources/` — `PersistableSource` adapters (tanstack-store) - `frameworks/` — `HydrationSignal` framework adapters (react, solid, vue, svelte, svelte-store) diff --git a/package.json b/package.json index 25cff78..ae382b3 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,14 @@ "types": "./dist/backends/secure-store.d.mts", "import": "./dist/backends/secure-store.mjs" }, + "./backends/encrypted": { + "types": "./dist/backends/encrypted.d.mts", + "import": "./dist/backends/encrypted.mjs" + }, + "./backends/compressed": { + "types": "./dist/backends/compressed.d.mts", + "import": "./dist/backends/compressed.mjs" + }, "./transport/crosstab": { "types": "./dist/transport/crosstab.d.mts", "import": "./dist/transport/crosstab.mjs" diff --git a/src/adapters/backends/compressed.test.ts b/src/adapters/backends/compressed.test.ts new file mode 100644 index 0000000..732137f --- /dev/null +++ b/src/adapters/backends/compressed.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it } from "bun:test"; + +import { createStorage, persistSource } from "../../core/persist-core"; +import type { PersistableSource, StateStorage } from "../../core/persist-core"; +import { serovalCodec } from "../codecs/seroval"; +import { createCompressedStorage } from "./compressed"; + +class MemoryStorage implements StateStorage { + private store = new Map(); + + clear() { + this.store.clear(); + } + + getItem(key: string) { + return this.store.get(key) ?? null; + } + + removeItem(key: string) { + this.store.delete(key); + } + + setItem(key: string, value: string) { + this.store.set(key, value); + } +} + +function createMockSource(initial: T): PersistableSource & { state: T } { + let state = initial; + const listeners = new Set<() => void>(); + + return { + get state() { + return state; + }, + getState: () => state, + setState: (updater) => { + state = updater(state); + listeners.forEach((listener) => listener()); + }, + subscribe: (listener) => { + listeners.add(listener); + return { + unsubscribe: () => listeners.delete(listener), + }; + }, + }; +} + +function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { + return new Promise((resolve, reject) => { + let ticks = 0; + const tick = () => { + if (hasHydrated()) { + resolve(); + return; + } + // Bounded: a hydration regression fails loudly here instead of hanging + // the suite until the runner's opaque timeout. + if (++ticks > maxTicks) { + reject(new Error("waitForHydration: never hydrated")); + return; + } + queueMicrotask(tick); + }; + tick(); + }); +} + +describe("createCompressedStorage", () => { + let memory: MemoryStorage; + + beforeEach(() => { + memory = new MemoryStorage(); + }); + + it("round-trips: setItem then getItem returns the plaintext", async () => { + const storage = createCompressedStorage(() => memory)!; + const plaintext = "x".repeat(10_000); + + await storage.setItem("k", plaintext); + expect(await storage.getItem("k")).toBe(plaintext); + }); + + it("the stored value is smaller than the plaintext (compression works)", async () => { + const storage = createCompressedStorage(() => memory)!; + const plaintext = "x".repeat(10_000); + + await storage.setItem("k", plaintext); + const stored = memory.getItem("k")!; + expect(stored.length).toBeLessThan(10_000); + }); + + it("getItem returns null for a missing key", async () => { + const storage = createCompressedStorage(() => memory)!; + expect(await storage.getItem("missing")).toBeNull(); + }); + + it("supports gzip, deflate, deflate-raw formats", async () => { + const formats = ["gzip", "deflate", "deflate-raw"] as const; + const plaintext = "hello compression"; + + for (const format of formats) { + memory.clear(); + const storage = createCompressedStorage(() => memory, { format })!; + await storage.setItem("k", plaintext); + expect(await storage.getItem("k")).toBe(plaintext); + } + }); + + it("composes with createStorage + persistSource end-to-end", async () => { + const name = "compressed-persist"; + const storage = createStorage<{ count: number }>( + () => createCompressedStorage(() => memory)!, + serovalCodec(), + )!; + + const source = createMockSource({ count: 0 }); + const persist = persistSource(source, { name, storage }); + await waitForHydration(persist.hasHydrated); + + source.setState(() => ({ count: 7 })); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const stored = memory.getItem(name)!; + const plaintext = serovalCodec<{ count: number }>().encode({ + state: { count: 7 }, + version: 0, + }); + expect(stored).not.toBe(plaintext); + expect(stored).not.toContain('"count":7'); + + const source2 = createMockSource({ count: 0 }); + const persist2 = persistSource(source2, { + name, + storage, + skipHydration: true, + }); + await persist2.rehydrate(); + expect(source2.state.count).toBe(7); + + persist.destroy(); + persist2.destroy(); + }); + + it("returns undefined when CompressionStream is unavailable", () => { + const original = globalThis.CompressionStream; + try { + // @ts-expect-error — simulating a runtime without CompressionStream + delete globalThis.CompressionStream; + if (typeof globalThis.CompressionStream !== "undefined") { + // Non-configurable — fall back to getStorage-throws guard. + expect( + createCompressedStorage(() => { + throw new Error("no backend"); + }), + ).toBeUndefined(); + return; + } + expect(createCompressedStorage(() => memory)).toBeUndefined(); + } finally { + globalThis.CompressionStream = original; + } + }); + + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file( + new URL("./compressed.ts", import.meta.url), + ).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); + } + }); +}); diff --git a/src/adapters/backends/compressed.ts b/src/adapters/backends/compressed.ts new file mode 100644 index 0000000..fde5d71 --- /dev/null +++ b/src/adapters/backends/compressed.ts @@ -0,0 +1,113 @@ +// Compressed storage entry — owns NO peer dep (`CompressionStream` / +// `DecompressionStream` are web globals, available in browsers + Node 18+). Ships +// as its own subpath so consumers not compressing don't pull it. For +// string-wire backends (localStorage, AsyncStorage, etc.); output is +// base64-encoded so it stays string-wire. +import type { StateStorage } from "../../core/persist-core"; + +export type CompressionFormat = "gzip" | "deflate" | "deflate-raw"; + +export interface CreateCompressedStorageOptions { + /** Compression format. @default "gzip" */ + format?: CompressionFormat; +} + +/** + * Wrap a string-wire `StateStorage` with native `CompressionStream` / + * `DecompressionStream` compression. Supported formats: `gzip`, `deflate`, + * `deflate-raw`. Output is base64-encoded so it stays string-wire. + * + * Compression is a backend wrapper, **not** a sync `StorageCodec`, because + * the stream APIs are async — the `StorageCodec` seam is sync. Compose with + * `createStorage(backend, codec)`: the codec serializes the envelope (sync), + * this wrapper compresses the serialized string (async). + * + * Returns `undefined` when the stream APIs are unavailable so `createStorage` + * collapses to the no-op `PersistApi`. Stacks with `createEncryptedStorage` + * (compress-then-encrypt is the standard order). + * + * @example + * ```ts + * const storage = createStorage( + * () => createCompressedStorage(() => localStorage)!, + * serovalCodec(), + * ); + * ``` + */ +export function createCompressedStorage( + getStorage: () => StateStorage, + options?: CreateCompressedStorageOptions, +): StateStorage | undefined { + if ( + typeof CompressionStream === "undefined" || + typeof DecompressionStream === "undefined" + ) { + return undefined; + } + + let backend: StateStorage; + try { + backend = getStorage(); + } catch { + return undefined; + } + + if ( + typeof backend.getItem !== "function" || + typeof backend.setItem !== "function" || + typeof backend.removeItem !== "function" + ) { + return undefined; + } + + const format: CompressionFormat = options?.format ?? "gzip"; + + return { + getItem: async (name) => { + const raw = await backend.getItem(name); + if (raw == null) return null; + return decompress(raw, format); + }, + setItem: async (name, value) => { + const compressed = await compress(value, format); + await backend.setItem(name, compressed); + }, + removeItem: (name) => backend.removeItem(name), + }; +} + +async function compress( + plaintext: string, + format: CompressionFormat, +): Promise { + const stream = new Blob([new TextEncoder().encode(plaintext)]) + .stream() + .pipeThrough(new CompressionStream(format)); + const buf = new Uint8Array(await new Response(stream).arrayBuffer()); + return toBase64(buf); +} + +async function decompress( + payload: string, + format: CompressionFormat, +): Promise { + const bytes = fromBase64(payload); + const stream = new Blob([bytes]) + .stream() + .pipeThrough(new DecompressionStream(format)); + const buf = await new Response(stream).arrayBuffer(); + return new TextDecoder().decode(buf); +} + +function toBase64(bytes: Uint8Array): string { + let bin = ""; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin); +} + +function fromBase64(s: string): Uint8Array { + const bin = atob(s); + const bytes = new Uint8Array(new ArrayBuffer(bin.length)); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +} diff --git a/src/adapters/backends/encrypted.test.ts b/src/adapters/backends/encrypted.test.ts new file mode 100644 index 0000000..b8c8c55 --- /dev/null +++ b/src/adapters/backends/encrypted.test.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it } from "bun:test"; + +import { createStorage, persistSource } from "../../core/persist-core"; +import type { PersistableSource, StateStorage } from "../../core/persist-core"; +import { serovalCodec } from "../codecs/seroval"; +import { createEncryptedStorage } from "./encrypted"; + +class MemoryStorage implements StateStorage { + private store = new Map(); + + clear() { + this.store.clear(); + } + + getItem(key: string) { + return this.store.get(key) ?? null; + } + + removeItem(key: string) { + this.store.delete(key); + } + + setItem(key: string, value: string) { + this.store.set(key, value); + } +} + +function createMockSource(initial: T): PersistableSource & { state: T } { + let state = initial; + const listeners = new Set<() => void>(); + + return { + get state() { + return state; + }, + getState: () => state, + setState: (updater) => { + state = updater(state); + listeners.forEach((listener) => listener()); + }, + subscribe: (listener) => { + listeners.add(listener); + return { + unsubscribe: () => listeners.delete(listener), + }; + }, + }; +} + +function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { + return new Promise((resolve, reject) => { + let ticks = 0; + const tick = () => { + if (hasHydrated()) { + resolve(); + return; + } + // Bounded: a hydration regression fails loudly here instead of hanging + // the suite until the runner's opaque timeout. + if (++ticks > maxTicks) { + reject(new Error("waitForHydration: never hydrated")); + return; + } + queueMicrotask(tick); + }; + tick(); + }); +} + +async function makeKey(): Promise { + return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [ + "encrypt", + "decrypt", + ]); +} + +describe("createEncryptedStorage", () => { + let memory: MemoryStorage; + + beforeEach(() => { + memory = new MemoryStorage(); + }); + + it("round-trips: setItem then getItem returns the plaintext", async () => { + const storage = createEncryptedStorage(() => memory, { + key: await makeKey(), + })!; + + await storage.setItem("k", "secret"); + expect(await storage.getItem("k")).toBe("secret"); + }); + + it("the stored value is NOT the plaintext (encrypted base64, iv.ct format)", async () => { + const storage = createEncryptedStorage(() => memory, { + key: await makeKey(), + })!; + + await storage.setItem("k", "secret"); + const raw = memory.getItem("k"); + expect(raw).not.toBe("secret"); + expect(raw).toContain("."); + }); + + it("a wrong key fails to decrypt (AES-GCM auth tag)", async () => { + const key1 = await makeKey(); + const key2 = await makeKey(); + const storage1 = createEncryptedStorage(() => memory, { key: key1 })!; + const storage2 = createEncryptedStorage(() => memory, { key: key2 })!; + + await storage1.setItem("k", "secret"); + await expect(storage2.getItem("k")).rejects.toThrow(); + }); + + it("getItem returns null for a missing key", async () => { + const storage = createEncryptedStorage(() => memory, { + key: await makeKey(), + })!; + + expect(await storage.getItem("missing")).toBeNull(); + }); + + it("composes with createStorage + persistSource end-to-end", async () => { + const key = await makeKey(); + const name = "encrypted-persist"; + const storage = createStorage<{ count: number }>( + () => createEncryptedStorage(() => memory, { key })!, + serovalCodec(), + )!; + + const source = createMockSource({ count: 0 }); + const persist = persistSource(source, { name, storage }); + await waitForHydration(persist.hasHydrated); + + source.setState(() => ({ count: 7 })); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const rawStored = memory.getItem(name); + expect(rawStored).not.toBeNull(); + expect(rawStored).not.toContain('"count":7'); + + const source2 = createMockSource({ count: 0 }); + const persist2 = persistSource(source2, { + name, + storage, + skipHydration: true, + }); + await persist2.rehydrate(); + expect(source2.state.count).toBe(7); + + persist.destroy(); + persist2.destroy(); + }); + + it("returns undefined when crypto.subtle is unavailable", () => { + const originalCrypto = globalThis.crypto; + try { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (globalThis as { crypto?: Crypto }).crypto; + if (typeof globalThis.crypto !== "undefined") { + // Non-configurable in this runtime — fall back to getStorage-throws guard. + expect( + createEncryptedStorage( + () => { + throw new Error("no backend"); + }, + { key: {} as CryptoKey }, + ), + ).toBeUndefined(); + return; + } + expect( + createEncryptedStorage(() => memory, { key: {} as CryptoKey }), + ).toBeUndefined(); + } finally { + globalThis.crypto = originalCrypto; + } + }); + + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file( + new URL("./encrypted.ts", import.meta.url), + ).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); + } + }); +}); diff --git a/src/adapters/backends/encrypted.ts b/src/adapters/backends/encrypted.ts new file mode 100644 index 0000000..909123a --- /dev/null +++ b/src/adapters/backends/encrypted.ts @@ -0,0 +1,111 @@ +// Encrypted storage entry — owns NO peer dep (`crypto.subtle` is a web global, +// available in browsers + Node 20+). Ships as its own subpath so consumers +// not encrypting don't pull it. For string-wire backends (localStorage, +// AsyncStorage, etc.). +import type { StateStorage } from "../../core/persist-core"; + +export interface CreateEncryptedStorageOptions { + /** AES-GCM `CryptoKey` — derive via `crypto.subtle.importKey` / `generateKey`. */ + key: CryptoKey; +} + +/** + * Wrap a string-wire `StateStorage` with AES-GCM encryption via WebCrypto. + * Each stored value is `base64(iv).base64(ciphertext)` — the 12-byte IV is + * prepended to the ciphertext, both base64-encoded. AES-GCM's auth tag means + * a wrong key or tampered ciphertext throws on decrypt; persist-core's + * corrupt-payload path returns `null` (or `clearCorruptOnFailure` removes the + * key). + * + * Encryption is a backend wrapper, **not** a sync `StorageCodec`, because + * `crypto.subtle` is async — the `StorageCodec` seam is sync. Compose with + * `createStorage(backend, codec)`: the codec serializes the envelope (sync), + * this wrapper encrypts the serialized string (async). + * + * Returns `undefined` when `crypto.subtle` is unavailable so `createStorage` + * collapses to the no-op `PersistApi`. + * + * @example + * ```ts + * const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]); + * const storage = createStorage( + * () => createEncryptedStorage(() => localStorage, { key })!, + * serovalCodec(), + * ); + * persistStore(store, { name: "app:prefs:v1", storage, clearCorruptOnFailure: true }); + * ``` + */ +export function createEncryptedStorage( + getStorage: () => StateStorage, + options: CreateEncryptedStorageOptions, +): StateStorage | undefined { + if (typeof crypto === "undefined" || !crypto?.subtle) { + return undefined; + } + + let backend: StateStorage; + try { + backend = getStorage(); + } catch { + return undefined; + } + + if ( + typeof backend.getItem !== "function" || + typeof backend.setItem !== "function" || + typeof backend.removeItem !== "function" + ) { + return undefined; + } + + return { + getItem: async (name) => { + const raw = await backend.getItem(name); + if (raw == null) return null; + return decryptAesGcm(raw, options.key); + }, + setItem: async (name, value) => { + const ciphertext = await encryptAesGcm(value, options.key); + await backend.setItem(name, ciphertext); + }, + removeItem: (name) => backend.removeItem(name), + }; +} + +async function encryptAesGcm( + plaintext: string, + key: CryptoKey, +): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode(plaintext); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded), + ); + return `${toBase64(iv)}.${toBase64(ciphertext)}`; +} + +async function decryptAesGcm(payload: string, key: CryptoKey): Promise { + const [ivB64, ctB64] = payload.split("."); + if (!ivB64 || !ctB64) throw new Error("invalid ciphertext payload"); + const iv = fromBase64(ivB64); + const ciphertext = fromBase64(ctB64); + const plain = await crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + key, + ciphertext, + ); + return new TextDecoder().decode(plain); +} + +function toBase64(bytes: Uint8Array): string { + let bin = ""; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin); +} + +function fromBase64(s: string): Uint8Array { + const bin = atob(s); + const bytes = new Uint8Array(new ArrayBuffer(bin.length)); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 1fc708c..c9573fe 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -16,6 +16,8 @@ export default defineConfig({ "backends/async-storage": "src/adapters/backends/async-storage.ts", "backends/mmkv": "src/adapters/backends/mmkv.ts", "backends/secure-store": "src/adapters/backends/secure-store.ts", + "backends/encrypted": "src/adapters/backends/encrypted.ts", + "backends/compressed": "src/adapters/backends/compressed.ts", "transport/crosstab": "src/adapters/transport/crosstab.ts", "sources/tanstack-store": "src/adapters/sources/tanstack-store.ts", "frameworks/react": "src/adapters/frameworks/react.ts", From 90146a6a574454c99c9ed30314f8c1cdcdc01ce0 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sat, 4 Jul 2026 20:24:30 +0300 Subject: [PATCH 12/77] docs(readme): fact-check the comparison table + migration guide vs official sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 82 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 44 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index f644cce..464c791 100644 --- a/README.md +++ b/README.md @@ -157,21 +157,23 @@ Both TanStack Persist and zustand persist wire a single store library to a singl Every row is a seam or lifecycle concern — not a roadmap item. `@stainless-code/persist` treats each as composable; incumbents bake most of them into framework-specific middleware. -| Capability | `@stainless-code/persist` | zustand-persist | redux-persist | `@tanstack/query-persist-client` | pinia-persist | -| ----------------------------------------------------- | :-----------------------: | :-------------: | :-----------: | :------------------------------: | :-----------: | -| Store-agnostic (structural source) | ✓ | ✗ | ~ | ~ | ✗ | -| Codec seam (swap serialization) | ✓ | ~ | ~ | ✗ | ~ | -| Storage seam (swap backend) | ✓ | ✓ | ✓ | ✓ | ✓ | -| Hydration signal (gate UI flash) | ✓ | ~ | ~ | ✗ | ✗ | -| Cross-tab sync | ✓ | ✗ | ✗ | ✗ | ✗ | -| `migrate` (versioned) | ✓ | ✓ | ✓ | ~ | ~ | -| `retryWrite` (quota shrink-or-give-up) | ✓ | ✗ | ✗ | ~ | ✗ | -| `throttleMs` | ✓ | ✗ | ✗ | ✗ | ✗ | -| `maxAge` / `buster` expiry | ✓ | ✗ | ✗ | ✓ | ✗ | -| Schema validation (codec) | ✓ | ✗ | ✗ | ✗ | ✗ | -| Framework hydration adapters (React/Solid/Vue/Svelte) | ✓ | ✗ | ✗ | ✗ | ✗ | - -**Differentiator:** `@stainless-code/persist` is the only library here with a first-class hydration signal **and** a codec seam **and** a storage seam — so every backend×codec cell is a one-line composition, not a feature request. +| Capability | `@stainless-code/persist` | zustand-persist | redux-persist | `@tanstack/query-persist-client` | pinia-persist | +| -------------------------------------- | :-----------------------: | :-------------: | :-----------: | :------------------------------: | :-----------: | +| Store-agnostic (structural source) | ✓ | ✗ | ✗ | ~ | ✗ | +| Codec seam (swap serialization) | ✓ | ~ | ✓ | ~ | ~ | +| Storage seam (swap backend) | ✓ | ✓ | ✓ | ✓ | ✓ | +| Hydration signal (gate UI flash) | ✓ | ~ | ~ | ✓ | ~ | +| Cross-tab sync | ✓ | ✗ | ✗ | ✗ | ✗ | +| `migrate` (versioned) | ✓ | ✓ | ✓ | ✗ | ✗ | +| `retryWrite` (quota shrink-or-give-up) | ✓ | ✗ | ✗ | ✓ | ✗ | +| `throttleMs` | ✓ | ✗ | ✓ | ✓ | ✗ | +| `maxAge` / `buster` expiry | ✓ | ✗ | ✗ | ✓ | ✗ | +| Schema validation (codec) | ✓ | ✗ | ✗ | ✗ | ✗ | +| Framework hydration adapters | ✓ | ✗ | ✗ | ✓ | ✗ | + +**Differentiator:** `@stainless-code/persist` is the only library here with built-in cross-tab sync, a schema-validation codec, and a fully store-agnostic source (`PersistableSource`) — and the only one that scores ✓ on every row. The closest peer, `@tanstack/query-persist-client`, matches on the hydration signal, `retryWrite` (shrink-or-give-up), `throttleMs`, `maxAge`/`buster`, and framework adapters — but is query-cache-bound (not store-agnostic), exposes no codec seam on its `Persister` interface, and ships no versioned `migrate`, cross-tab sync, or schema validation. + +_Cells verified against each library's source on 2026-07-04: `pmndrs/zustand`, `rt2zz/redux-persist`, `TanStack/query`, `prazdevs/pinia-plugin-persistedstate`._ ## Migrating from … @@ -226,18 +228,20 @@ export const prefsHydration = toHydrationSignal(persist); redux-persist reconciles whole reducer trees implicitly; here `merge` is explicit and a hydration signal gates UI until the snapshot lands. redux stores have `getState`/`subscribe`/`dispatch` (no `setState`), so wrap the store in a `PersistableSource` whose `setState` dispatches an action your reducer recognizes to replace state. -| redux-persist | `@stainless-code/persist` | -| --------------------------------- | -------------------------------------------------------------------------- | -| `key` | `name` | -| `storage` | `storage` | -| `version` | `version` | -| `migrate` | `migrate` | -| `whitelist` / `blacklist` | `partialize` (project the slice) | -| `transforms` | `merge` or custom `StorageCodec` via `createStorage` | -| `stateReconciler` | `merge` (default shallow-spread; customize) | -| `persistReducer` / `persistStore` | `persistSource(reduxSource, opts)` | -| — | `toHydrationSignal` + framework adapter (no redux-persist equivalent) | -| — | `crossTab`, `maxAge`, `buster`, `throttleMs`, `retryWrite` (no equivalent) | +| redux-persist | `@stainless-code/persist` | +| --------------------------------- | ------------------------------------------------------------------------ | +| `key` | `name` | +| `storage` | `storage` | +| `version` | `version` | +| `migrate` | `migrate` | +| `whitelist` / `blacklist` | `partialize` (project the slice) | +| `transforms` | `merge` (per-reducer in/out) | +| `serialize` / `deserialize` | custom `StorageCodec` via `createStorage` | +| `stateReconciler` | `merge` (default shallow-spread; customize) | +| `throttle` | `throttleMs` | +| `persistReducer` / `persistStore` | `persistSource(reduxSource, opts)` | +| — | `toHydrationSignal` + framework adapter (no redux-persist equivalent) | +| — | `crossTab`, `maxAge`, `buster`, `retryWrite`, schema validation (no eq.) | ```ts // redux-persist @@ -272,16 +276,18 @@ export const rootHydration = toHydrationSignal(persist); query-persist-client owns the query cache lifecycle; here the seam is any `PersistableSource` — supply a cache-shaped source and compose storage like any other store. -| query-persist-client | `@stainless-code/persist` | -| ----------------------------------- | ---------------------------------------------------------------------------------------- | -| `persister` (`Persister` interface) | `storage` (`PersistStorage` — `getItem`/`setItem`/`removeItem`, or wrap `createStorage`) | -| `maxAge` | `maxAge` | -| `buster` | `buster` | -| `retry` | `retryWrite` | -| `dehydrate` / `hydrate` | `partialize` / `merge` | -| `persistQueryClient` | `persistSource(queryCacheSource, opts)` | -| — | `toHydrationSignal` + framework adapter (no equivalent) | -| — | codec seam via `createStorage(backend, codec)` (no equivalent) | +| query-persist-client | `@stainless-code/persist` | +| ----------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `persister` (`Persister` interface) | `storage` (`PersistStorage` — `getItem`/`setItem`/`removeItem`, or wrap `createStorage`) | +| `serialize` / `deserialize` | `StorageCodec` via `createStorage` | +| `maxAge` | `maxAge` | +| `buster` | `buster` | +| `retry` (shrink-or-give-up) | `retryWrite` | +| `throttleTime` | `throttleMs` | +| `dehydrate` / `hydrate` | `partialize` / `merge` | +| `persistQueryClient` | `persistSource(queryCacheSource, opts)` | +| `useIsRestoring` / `PersistQueryClientProvider` | `toHydrationSignal` + framework adapter (`useHydrated`/`hydratedRune`/`hydratedStore`) | +| — | store-agnostic source, versioned `migrate`, `crossTab`, schema validation (no equivalent) | ```ts // @tanstack/query-persist-client @@ -329,7 +335,7 @@ pinia-persist is a Pinia plugin; here persistence is a middleware call on any re | `storage` | `storage` | | `paths` | `partialize` (pick paths) | | `serializer` | custom `StorageCodec` or default `jsonCodec` via `createStorage` | -| `beforeRestore` / `afterRestore` | `onRehydrateStorage` | +| `beforeHydrate` / `afterHydrate` | `onRehydrateStorage` | | `debug` | `onError` | | `pinia.use(plugin)` | `persistSource(piniaSource, opts)` | | — | `toHydrationSignal` + framework adapter (no equivalent) | From e37b667c246b303d85f9d0ba9b7d3cee51ea7136 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sat, 4 Jul 2026 20:26:07 +0300 Subject: [PATCH 13/77] docs(plans): hydrate the upstream TanStack pitch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/plans/upstream-tanstack-pitch.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/plans/upstream-tanstack-pitch.md b/docs/plans/upstream-tanstack-pitch.md index 4c69f14..1fedf3a 100644 --- a/docs/plans/upstream-tanstack-pitch.md +++ b/docs/plans/upstream-tanstack-pitch.md @@ -1,19 +1,19 @@ # Upstream TanStack Persist — pitch draft -**Status:** Draft for posting (GitHub Discussion / Discord). Addressee: Kevin Vandy, Luca (LeCarbonator). Context: TanStack Persist is being shaped; you invited proposals, want Query-persister parity and "get the initial shape right"; agreed route was publish under `@stainless-code/persist` first, then collaborate. This is that follow-up. +**Status:** Draft for posting (GitHub Discussion / Discord). Addressee: Kevin Vandy, Luca (LeCarbonator). Context: TanStack Persist is being shaped; you invited proposals, want Query-persister parity and "get the initial shape right"; agreed route was publish under `@stainless-code/persist` first, then collaborate. This is that follow-up. _Hydrated 2026-07-04 — the reference has grown to 14 opt-in subpaths across five seam categories (codecs, backends, transport, sources, frameworks) with 158 tests; the shape calls are unchanged, the breadth is wider, and the parity claims are fact-checked against each peer's source._ --- -Hey Kevin, Luca — following up on the persist proposal. We spun the work out as [`@stainless-code/persist`](https://github.com/stainless-code/persist) (extracted, 99 tests, shipping under stainless-code). It's a working reference for the shape questions you're deciding now; happy to fold any of it upstream. Quick tour. +Hey Kevin, Luca — following up on the persist proposal. We spun the work out as [`@stainless-code/persist`](https://github.com/stainless-code/persist) (extracted, 158 tests, shipping under stainless-code). It's a working reference for the shape questions you're deciding now; happy to fold any of it upstream. Quick tour. -**1. What it is.** A middleware model over a _structural_ `PersistableSource` (`getState`/`setState`/`subscribe`) — not a store-specific wrapper. `persistSource(source, options)` persists anything reactive; `persistStore`/`persistAtom` are ~20-line adapters for `@tanstack/store`. This differs from a passive `StoragePersister` (dump/load a blob): the middleware owns the lifecycle — hydrate-on-create, subscribe-writes gated until hydrated, trailing throttle, teardown — so the store stays a plain store and persistence is a sidekick. Three seams (`StateStorage` backend × `StorageCodec` × `PersistableSource`) make every backend × codec cell a one-line composition instead of a feature request. ([README § Extensibility guide](https://github.com/stainless-code/persist#extensibility-guide)) +**1. What it is.** A middleware model over a _structural_ `PersistableSource` (`getState`/`setState`/`subscribe`) — not a store-specific wrapper. `persistSource(source, options)` persists anything reactive; `persistStore`/`persistAtom` are ~20-line adapters for `@tanstack/store`. This differs from a passive `StoragePersister` (dump/load a blob): the middleware owns the lifecycle — hydrate-on-create, subscribe-writes gated until hydrated, trailing throttle, teardown — so the store stays a plain store and persistence is a sidekick. Three seams (`StateStorage` backend × `StorageCodec` × `PersistableSource`) make every backend × codec cell a one-line composition instead of a feature request. 14 opt-in subpaths now exercise those seams — codecs (seroval, zod), backends (IndexedDB, AsyncStorage, MMKV, SecureStore, plus encrypted/compressed wrappers), a BroadcastChannel cross-tab transport, store sources (TanStack), and framework hydration adapters (React/Solid/Vue/Svelte) — so the shape is proven to scale, not just sketched. ([README § Extensibility guide](https://github.com/stainless-code/persist#extensibility-guide)) **2. Sync vs async — one API.** We deliberately did NOT split sync/async backends into two APIs. `getItem` returning a native `Promise` switches the read path to async; sync backends stay sync and hydration settles **before first paint** (no flash, no `Suspense`). Async backends (IndexedDB) ride the _same_ `getItem` Promise branch; UI gates on a hydration signal. `instanceof Promise` (not thenable duck-typing) so a stored value with a `then` field is never mistaken for a pending read. ([docs/architecture.md § Sync vs async](https://github.com/stainless-code/persist/blob/main/docs/architecture.md#sync-vs-async)) -**3. Query-persister parity + beyond.** Shipped: `buster`, `maxAge`, `throttleMs`, `retryWrite` (shrink-or-give-up with a write-generation guard so stale retries never clobber newer state). Beyond parity: versioned `migrate`, cross-tab sync (`crossTab` + `onCrossTabRemove`), a first-class hydration signal (`HydrationSignal` + React `useHydrated` via `useSyncExternalStore`), and the codec seam. **One deliberate divergence:** `maxAge` is opt-in, not default-on — prefs shouldn't silently expire. ([docs/architecture.md § Beyond Query-persister parity](https://github.com/stainless-code/persist/blob/main/docs/architecture.md#beyond-query-persister-parity)) +**3. Query-persister parity + the honest delta.** We fact-checked a feature-by-feature comparison against each peer's source ([README § Comparison](https://github.com/stainless-code/persist#comparison-with-other-persist-libraries)). **Parity** — `@tanstack/query-persist-client` has these too: `buster`, `maxAge`, `throttleTime` (our `throttleMs`), `retry` (our `retryWrite` shrink-or-give-up, with a write-generation guard so stale retries never clobber newer state), a hydration gate (`useIsRestoring`/`PersistQueryClientProvider`), and framework hydration adapters (it ships React/Solid/Svelte/Angular/Preact). **Beyond parity** — where `@stainless-code/persist` goes further: a fully store-agnostic source (query is cache-bound), a codec seam on the storage layer independent of the backend (query couples `serialize`/`deserialize` to the persister factory), versioned `migrate` (query has only `buster`, no `version`), cross-tab sync (`crossTab` + `onCrossTabRemove` + a `BroadcastChannel` bridge for backends that fire no `storage` events), and schema validation (a `zod` codec — invalid state never writes; corrupt reads discard). **One deliberate divergence:** `maxAge` is opt-in, not default-on — prefs shouldn't silently expire. ([docs/architecture.md § Beyond Query-persister parity](https://github.com/stainless-code/persist/blob/main/docs/architecture.md#beyond-query-persister-parity)) -**4. How it could merge.** The seams map cleanly onto TanStack Persist: `StateStorage` + `StorageCodec` are drop-in; `persistSource` is the store-agnostic core a `persistStore`/`persistQuery`/`persistForm` family can share; `HydrationSignal` is the framework-agnostic subscribe target each adapter mounts (React `useSyncExternalStore`, Svelte `createSubscriber`, Solid `from`, Vue `shallowRef`). We'd contribute the core + the TanStack Store adapter + the React hook, and keep iterating on the public surface with you. No barrel — one subpath per optional peer — so the dependency opt-in is the import. +**4. How it could merge.** The seams map cleanly onto TanStack Persist: `StateStorage` + `StorageCodec` are drop-in; `persistSource` is the store-agnostic core a `persistStore`/`persistQuery`/`persistForm` family can share; `HydrationSignal` is the framework-agnostic subscribe target — we ship React (`useSyncExternalStore`), Solid (`from`), Vue (`shallowRef` + `onScopeDispose`), and Svelte (runes via `createSubscriber`, plus a `readable` store variant for Svelte 4) adapters over it, each ~20 lines. We'd contribute the core + the TanStack Store adapter + the framework hydration adapters, and keep iterating on the public surface with you. No barrel — one subpath per optional peer, categorized to mirror the source folders (`./codecs/…`, `./backends/…`, `./transport/…`, `./sources/…`, `./frameworks/…`) — so the dependency opt-in is the import and a contributor adding an adapter wires it with zero mental mapping. -**5. Links.** Repo: . README: [quick start](https://github.com/stainless-code/persist#quick-start) · [extensibility guide](https://github.com/stainless-code/persist#extensibility-guide) · [recipes](https://github.com/stainless-code/persist#recipes). Architecture: [docs/architecture.md](https://github.com/stainless-code/persist/blob/main/docs/architecture.md). +**5. Links.** Repo: . README: [quick start](https://github.com/stainless-code/persist#quick-start) · [extensibility guide](https://github.com/stainless-code/persist#extensibility-guide) · [comparison vs zustand/redux/query/pinia](https://github.com/stainless-code/persist#comparison-with-other-persist-libraries) · [migration guide](https://github.com/stainless-code/persist#migrating-from-) · [recipes](https://github.com/stainless-code/persist#recipes). Architecture: [docs/architecture.md](https://github.com/stainless-code/persist/blob/main/docs/architecture.md). Happy to open a Discussion with this, or take it inline — whatever's easiest. The `maxAge` default and the sync-first read path are the two shape calls most worth a second opinion. From d8cfb37c2dcdb308f5bb882e91b45dfe34cb7fd3 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sat, 4 Jul 2026 20:34:37 +0300 Subject: [PATCH 14/77] docs(adapters): prune redundant comments + tighten user-facing JSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/adapters/backends/async-storage.ts | 8 ++------ src/adapters/backends/compressed.ts | 24 ++++++++-------------- src/adapters/backends/encrypted.ts | 27 +++++++++---------------- src/adapters/backends/idb.ts | 5 +---- src/adapters/backends/mmkv.ts | 5 +---- src/adapters/backends/secure-store.ts | 5 +---- src/adapters/codecs/seroval.ts | 3 +-- src/adapters/codecs/zod.ts | 3 +-- src/adapters/frameworks/solid.ts | 19 +++++------------ src/adapters/frameworks/svelte-store.ts | 22 ++++++-------------- src/adapters/frameworks/svelte.ts | 26 ++++++++---------------- src/adapters/frameworks/vue.ts | 21 ++++++------------- src/adapters/sources/tanstack-store.ts | 4 +--- src/adapters/transport/crosstab.ts | 5 +---- 14 files changed, 52 insertions(+), 125 deletions(-) diff --git a/src/adapters/backends/async-storage.ts b/src/adapters/backends/async-storage.ts index 32a0786..07d2f47 100644 --- a/src/adapters/backends/async-storage.ts +++ b/src/adapters/backends/async-storage.ts @@ -1,8 +1,4 @@ -// React Native AsyncStorage entry — owns the -// `@react-native-async-storage/async-storage` dependency so the core stays -// zero-dep. Ships as its own subpath entry with that peer optional. No barrel -// re-exports this module: importing it directly IS the dependency opt-in -// (enforced by an isolation test). +// React Native AsyncStorage backend — peer `@react-native-async-storage/async-storage` >=1.0.0. import AsyncStorage from "@react-native-async-storage/async-storage"; import type { AsyncStorage as AsyncStorageInstance } from "@react-native-async-storage/async-storage"; @@ -32,7 +28,7 @@ export function asyncStorageStateStorage( /** * Build a JSON-encoded `PersistStorage` over React Native `AsyncStorage`. * Async backend → hydration can't settle before first render, so `useHydrated` - * gating is mandatory (the `./react` / `./solid` / `./vue` adapters). + * gating is mandatory (the `./frameworks/react` / `./frameworks/solid` / `./frameworks/vue` / `./frameworks/svelte` adapters). * * @example * ```ts diff --git a/src/adapters/backends/compressed.ts b/src/adapters/backends/compressed.ts index fde5d71..852aedf 100644 --- a/src/adapters/backends/compressed.ts +++ b/src/adapters/backends/compressed.ts @@ -1,8 +1,4 @@ -// Compressed storage entry — owns NO peer dep (`CompressionStream` / -// `DecompressionStream` are web globals, available in browsers + Node 18+). Ships -// as its own subpath so consumers not compressing don't pull it. For -// string-wire backends (localStorage, AsyncStorage, etc.); output is -// base64-encoded so it stays string-wire. +// Compressed storage wrapper — no peer dep (`CompressionStream`/`DecompressionStream` are web globals, browsers + Node 18+). String-wire backends; output is base64. import type { StateStorage } from "../../core/persist-core"; export type CompressionFormat = "gzip" | "deflate" | "deflate-raw"; @@ -13,18 +9,14 @@ export interface CreateCompressedStorageOptions { } /** - * Wrap a string-wire `StateStorage` with native `CompressionStream` / - * `DecompressionStream` compression. Supported formats: `gzip`, `deflate`, - * `deflate-raw`. Output is base64-encoded so it stays string-wire. + * Native `CompressionStream`/`DecompressionStream` compression over a + * string-wire `StateStorage`. Formats: `gzip` (default), `deflate`, + * `deflate-raw`; output is base64 so it stays string-wire. * - * Compression is a backend wrapper, **not** a sync `StorageCodec`, because - * the stream APIs are async — the `StorageCodec` seam is sync. Compose with - * `createStorage(backend, codec)`: the codec serializes the envelope (sync), - * this wrapper compresses the serialized string (async). - * - * Returns `undefined` when the stream APIs are unavailable so `createStorage` - * collapses to the no-op `PersistApi`. Stacks with `createEncryptedStorage` - * (compress-then-encrypt is the standard order). + * A backend **wrapper**, not a sync `StorageCodec`, because the stream APIs + * are async. Compose: `createStorage(() => createCompressedStorage(backend), codec)`. + * Returns `undefined` when the stream APIs are unavailable. Stacks with + * `createEncryptedStorage` (compress-then-encrypt is the standard order). * * @example * ```ts diff --git a/src/adapters/backends/encrypted.ts b/src/adapters/backends/encrypted.ts index 909123a..8ba6d0c 100644 --- a/src/adapters/backends/encrypted.ts +++ b/src/adapters/backends/encrypted.ts @@ -1,7 +1,4 @@ -// Encrypted storage entry — owns NO peer dep (`crypto.subtle` is a web global, -// available in browsers + Node 20+). Ships as its own subpath so consumers -// not encrypting don't pull it. For string-wire backends (localStorage, -// AsyncStorage, etc.). +// AES-GCM encrypted storage wrapper — no peer dep (`crypto.subtle` is a web global, browsers + Node 20+). String-wire backends. import type { StateStorage } from "../../core/persist-core"; export interface CreateEncryptedStorageOptions { @@ -10,20 +7,16 @@ export interface CreateEncryptedStorageOptions { } /** - * Wrap a string-wire `StateStorage` with AES-GCM encryption via WebCrypto. - * Each stored value is `base64(iv).base64(ciphertext)` — the 12-byte IV is - * prepended to the ciphertext, both base64-encoded. AES-GCM's auth tag means - * a wrong key or tampered ciphertext throws on decrypt; persist-core's - * corrupt-payload path returns `null` (or `clearCorruptOnFailure` removes the - * key). + * AES-GCM encryption over a string-wire `StateStorage` (WebCrypto). Each + * stored value is `base64(iv).base64(ciphertext)` (12-byte IV prepended); the + * auth tag means a wrong key or tampered ciphertext throws on decrypt → + * persist-core's corrupt-payload path returns `null` (or `clearCorruptOnFailure` + * removes the key). * - * Encryption is a backend wrapper, **not** a sync `StorageCodec`, because - * `crypto.subtle` is async — the `StorageCodec` seam is sync. Compose with - * `createStorage(backend, codec)`: the codec serializes the envelope (sync), - * this wrapper encrypts the serialized string (async). - * - * Returns `undefined` when `crypto.subtle` is unavailable so `createStorage` - * collapses to the no-op `PersistApi`. + * A backend **wrapper**, not a sync `StorageCodec`, because `crypto.subtle` + * is async — the codec serializes (sync), this encrypts the string (async). + * Compose: `createStorage(() => createEncryptedStorage(backend, { key }), codec)`. + * Returns `undefined` when `crypto.subtle` is unavailable. * * @example * ```ts diff --git a/src/adapters/backends/idb.ts b/src/adapters/backends/idb.ts index 9403887..7d2e712 100644 --- a/src/adapters/backends/idb.ts +++ b/src/adapters/backends/idb.ts @@ -1,7 +1,4 @@ -// IndexedDB entry — owns the `idb-keyval` dependency so the core stays -// zero-dep. Ships as its own subpath entry with idb-keyval as an optional -// peer. No barrel re-exports this module: importing it directly IS the -// dependency opt-in (enforced by an isolation test). +// IndexedDB backend — peer `idb-keyval` >=4.0.0. import type { UseStore } from "idb-keyval"; import { del, get, set } from "idb-keyval"; diff --git a/src/adapters/backends/mmkv.ts b/src/adapters/backends/mmkv.ts index 6625341..f016a4e 100644 --- a/src/adapters/backends/mmkv.ts +++ b/src/adapters/backends/mmkv.ts @@ -1,7 +1,4 @@ -// React Native MMKV entry — owns the `react-native-mmkv` dependency so the -// core stays zero-dep. Ships as its own subpath entry with that peer optional. -// No barrel re-exports this module: importing it directly IS the dependency -// opt-in (enforced by an isolation test). +// React Native MMKV backend — peer `react-native-mmkv` >=4.0.0. Synchronous (no hydration gate needed). import { createMMKV } from "react-native-mmkv"; import type { MMKV } from "react-native-mmkv"; diff --git a/src/adapters/backends/secure-store.ts b/src/adapters/backends/secure-store.ts index a9ea446..da79358 100644 --- a/src/adapters/backends/secure-store.ts +++ b/src/adapters/backends/secure-store.ts @@ -1,7 +1,4 @@ -// expo-secure-store entry — owns the `expo-secure-store` dependency so the -// core stays zero-dep. Ships as its own subpath entry with that peer optional. -// No barrel re-exports this module: importing it directly IS the dependency -// opt-in (enforced by an isolation test). +// expo-secure-store backend — peer `expo-secure-store` >=12.0.0. ~2KB/key limit — for small secrets. import * as SecureStore from "expo-secure-store"; import type { StateStorage } from "../../core/persist-core"; diff --git a/src/adapters/codecs/seroval.ts b/src/adapters/codecs/seroval.ts index 95d99d2..f900d10 100644 --- a/src/adapters/codecs/seroval.ts +++ b/src/adapters/codecs/seroval.ts @@ -1,5 +1,4 @@ -// Seroval codec entry — owns the `seroval` dependency so the core stays -// zero-dep. Ships as its own subpath entry with seroval as an optional peer. +// seroval codec — peer `seroval` >=1.0.0. import { fromJSON, toJSON } from "seroval"; import type { diff --git a/src/adapters/codecs/zod.ts b/src/adapters/codecs/zod.ts index cfe6035..aa679f6 100644 --- a/src/adapters/codecs/zod.ts +++ b/src/adapters/codecs/zod.ts @@ -1,5 +1,4 @@ -// Zod codec entry — owns the `zod` dependency so the core stays -// zero-dep. Ships as its own subpath entry with zod as an optional peer. +// zod-validated codec — peer `zod` >=3.20.0. import { ZodType } from "zod"; import type { diff --git a/src/adapters/frameworks/solid.ts b/src/adapters/frameworks/solid.ts index 0821fbc..25eefdd 100644 --- a/src/adapters/frameworks/solid.ts +++ b/src/adapters/frameworks/solid.ts @@ -1,7 +1,4 @@ -// Solid hydration entry — owns the `solid-js` peer dep so the core stays -// zero-dep. Ships as its own subpath entry with solid-js as an optional peer; -// no barrel re-exports it (importing it IS the dep opt-in, enforced by an -// isolation test). +// Solid hydration adapter — peer `solid-js` >=1.6.0. import { from } from "solid-js"; import type { Accessor } from "solid-js"; @@ -10,16 +7,10 @@ import type { HydrationSignal } from "../../core/hydration"; const alwaysTrue: Accessor = () => true; /** - * Mount a `HydrationSignal` into Solid's reactivity via `from`. Returns a - * Solid `Accessor` — read it inside a reactive scope (`createEffect`, - * a component, JSX) to track the hydration gate. Null/undefined signal → - * always `true` (store stays the same with or without persistence). The - * subscription is owned by the reactive scope that creates the accessor and - * is cleaned up automatically on scope dispose — no manual teardown. - * - * The signal is always-hydrated on the server (no storage → no-op - * `PersistApi`), so this accessor renders `true` during SSR without - * special-casing — matching the `HydrationSignal` adapter contract. + * Mount a `HydrationSignal` into Solid's reactivity via `from`. Returns an + * `Accessor` — read it in a reactive scope (`createEffect`/component/JSX). + * Null/undefined signal → always `true`; the subscription is owned by the + * reactive scope and cleaned up on scope dispose. Renders `true` on the server. * * @example * ```ts diff --git a/src/adapters/frameworks/svelte-store.ts b/src/adapters/frameworks/svelte-store.ts index e761ef1..e91a7e4 100644 --- a/src/adapters/frameworks/svelte-store.ts +++ b/src/adapters/frameworks/svelte-store.ts @@ -1,29 +1,19 @@ -// Svelte 3+ (stores) hydration entry — owns the `svelte` peer dep (>=3.0.0) -// so the core stays zero-dep. Ships as its own subpath entry with svelte as an -// optional peer; no barrel re-exports it (importing it IS the dep opt-in, -// enforced by an isolation test). Works on Svelte 4 (pre-runes) AND Svelte 5 -// (for users who prefer the store API). For Svelte 5 runes, use -// `./frameworks/svelte`. +// Svelte 3+ (stores) hydration adapter — peer `svelte` >=3.0.0 (Svelte 4 + Svelte 5 store users). Svelte 5 runes: `./frameworks/svelte`. import { readable } from "svelte/store"; import type { Readable } from "svelte/store"; import type { HydrationSignal } from "../../core/hydration"; /** - * Mount a `HydrationSignal` into a Svelte `readable` store. Returns a - * `Readable` — auto-subscribe in a component with `$hydratedStore`. - * Null/undefined signal → a store that stays `true` (store stays the same with - * or without persistence). The signal subscription is tied to the store's - * subscriber lifecycle (start on first subscriber, unsubscribe on the last) — - * no manual teardown. - * - * The signal is always-hydrated on the server → the store yields `true` - * during SSR — matching the `HydrationSignal` adapter contract. + * Mount a `HydrationSignal` into a Svelte `readable` store. Auto-subscribe + * with `$hydratedStore`. Null/undefined signal → a store that stays `true`; + * the subscription is tied to the store's subscriber lifecycle (start on first + * subscriber, unsubscribe on the last). Yields `true` on the server. * * @example * ```ts * const hydrated = hydratedStore(prefsHydration); - * // in a Svelte 4/5 component: {#if $hydrated}{:else}{/if} + * // {#if $hydrated}{:else}{/if} * ``` */ export function hydratedStore( diff --git a/src/adapters/frameworks/svelte.ts b/src/adapters/frameworks/svelte.ts index 013ce0d..1b46fdb 100644 --- a/src/adapters/frameworks/svelte.ts +++ b/src/adapters/frameworks/svelte.ts @@ -1,8 +1,4 @@ -// Svelte 5 (runes) hydration entry — owns the `svelte` peer dep (>=5.0.0) so -// the core stays zero-dep. Ships as its own subpath entry with svelte as an -// optional peer; no barrel re-exports it (importing it IS the dep opt-in, -// enforced by an isolation test). For Svelte 4 (pre-runes) use -// `./frameworks/svelte-store`. +// Svelte 5 (runes) hydration adapter — peer `svelte` >=5.0.0. Svelte 4 (pre-runes): `./frameworks/svelte-store`. import { createSubscriber } from "svelte/reactivity"; import type { HydrationSignal } from "../../core/hydration"; @@ -14,24 +10,18 @@ const alwaysTrue: { readonly current: boolean } = { }; /** - * Mount a `HydrationSignal` into Svelte 5 reactivity via `createSubscriber`. - * Returns an object with a `current` getter — read it inside a reactive - * context (`$derived`, `$effect`, a component, `{#if}`) to track the gate. - * Null/undefined signal → `current` is always `true` (store stays the same - * with or without persistence). The subscription is owned by the reactive - * context that reads `current` and cleaned up on context dispose — no manual - * teardown. + * Mount a `HydrationSignal` into Svelte 5 reactivity (`createSubscriber`). + * Read `current` inside a reactive context (`$derived`/`$effect`/`{#if}`) to + * track the gate. Null/undefined signal → `current` is always `true`; the + * subscription is owned by the reactive context (cleaned up on dispose). + * Renders `true` on the server (no-op `PersistApi`). * - * The signal is always-hydrated on the server (no storage → no-op - * `PersistApi`), so `current` renders `true` during SSR — matching the - * `HydrationSignal` adapter contract. - * - * Svelte 4 (pre-runes) users: use `./frameworks/svelte-store` (`hydratedStore`). + * Svelte 4 (pre-runes): `./frameworks/svelte-store` (`hydratedStore`). * * @example * ```ts * const hydrated = hydratedRune(prefsHydration); - * // in a component: {#if hydrated.current}{:else}{/if} + * // {#if hydrated.current}{:else}{/if} * ``` */ export function hydratedRune(signal: HydrationSignal | null | undefined): { diff --git a/src/adapters/frameworks/vue.ts b/src/adapters/frameworks/vue.ts index 016cd93..940e83c 100644 --- a/src/adapters/frameworks/vue.ts +++ b/src/adapters/frameworks/vue.ts @@ -1,29 +1,20 @@ -// Vue hydration entry — owns the `vue` peer dep so the core stays zero-dep. -// Ships as its own subpath entry with vue as an optional peer; no barrel -// re-exports it (importing it IS the dep opt-in, enforced by an isolation -// test). +// Vue hydration adapter — peer `vue` >=3.3.0. import { onScopeDispose, shallowRef } from "vue"; import type { Ref } from "vue"; import type { HydrationSignal } from "../../core/hydration"; /** - * Mount a `HydrationSignal` into Vue's reactivity. Returns a `Ref` - * — read it in a template or `effect`/`computed` to track the hydration gate. - * Null/undefined signal → a ref that stays `true` (store stays the same with - * or without persistence). The subscription is cleaned up via - * `onScopeDispose`, so call this inside `setup()` or an `effectScope()` — - * the scope owns the teardown, no manual `destroy()` needed. - * - * The signal is always-hydrated on the server (no storage → no-op - * `PersistApi`), so this ref renders `true` during SSR without - * special-casing — matching the `HydrationSignal` adapter contract. + * Mount a `HydrationSignal` into Vue's reactivity. Returns a `Ref`; + * call inside `setup()` or an `effectScope()` — the subscription is cleaned up + * via `onScopeDispose` (the scope owns teardown). Null/undefined signal → a + * ref that stays `true`. Renders `true` on the server. * * @example * ```ts * // inside setup() — onScopeDispose is active * const hydrated = useHydrated(prefsHydration); - * // template: + * // * ``` */ export function useHydrated( diff --git a/src/adapters/sources/tanstack-store.ts b/src/adapters/sources/tanstack-store.ts index bc020fd..b993220 100644 --- a/src/adapters/sources/tanstack-store.ts +++ b/src/adapters/sources/tanstack-store.ts @@ -1,6 +1,4 @@ -// `@tanstack/store` adapters — the only module that references the store -// package (types only; `PersistableSource` is structural). Ships as a -// store-adapter subpath entry with `@tanstack/store` as a peer dep. +// `@tanstack/store` source adapter — peer `@tanstack/store` (types only; `PersistableSource` is structural). import type { Atom, Store, StoreActionMap } from "@tanstack/store"; import type { PersistApi, PersistOptions } from "../../core/persist-core"; diff --git a/src/adapters/transport/crosstab.ts b/src/adapters/transport/crosstab.ts index ee80591..2cd2139 100644 --- a/src/adapters/transport/crosstab.ts +++ b/src/adapters/transport/crosstab.ts @@ -1,7 +1,4 @@ -// BroadcastChannel cross-tab entry — owns NO peer dep (`BroadcastChannel` is a -// web global, available in browsers + Node 18+). Ships as its own subpath so -// consumers not using cross-tab don't pull it. For async backends that fire no -// `storage` events (IndexedDB). +// BroadcastChannel cross-tab bridge — no peer dep (web global, browsers + Node 18+). For backends that fire no `storage` events (IndexedDB). import type { CrossTabEventTarget, CrossTabStorageEvent, From b5bf85d2aea41b85eed0d41e269528d60e233b63 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sat, 4 Jul 2026 20:45:56 +0300 Subject: [PATCH 15/77] docs: fact-check every reference/path/count across .md, comments, JSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//): - 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. --- .agents/skills/harden-pr/LEDGER.md | 2 +- .../improve-codebase-architecture/LANGUAGE.md | 2 +- .../improve-codebase-architecture/REFERENCE.md | 10 +++++----- .agents/skills/pr-comment-fact-check/SKILL.md | 2 +- .agents/skills/pr-comment-fact-check/WORKFLOW.md | 6 +++--- .agents/skills/tdd/PATTERNS.md | 6 +++--- .agents/skills/tdd/SKILL.md | 2 +- .changeset/solid-vue-hydration.md | 2 +- .github/CONTRIBUTING.md | 6 +++--- .github/ISSUE_TEMPLATE/1_bug.yml | 2 +- .github/ISSUE_TEMPLATE/2_feature_adapter.yml | 2 +- docs/architecture.md | 2 +- docs/glossary.md | 2 +- docs/plans/upstream-tanstack-pitch.md | 4 ++-- src/adapters/frameworks/solid.test.ts | 2 +- src/core/hydration.ts | 2 +- src/core/persist-core.ts | 14 ++++++-------- tsdown.config.ts | 2 +- typedoc.json | 6 +++++- 19 files changed, 39 insertions(+), 37 deletions(-) diff --git a/.agents/skills/harden-pr/LEDGER.md b/.agents/skills/harden-pr/LEDGER.md index 50f576f..4c623b8 100644 --- a/.agents/skills/harden-pr/LEDGER.md +++ b/.agents/skills/harden-pr/LEDGER.md @@ -11,7 +11,7 @@ By-design or false-positive findings — do not re-raise. ``` ## Deferred diff --git a/.agents/skills/improve-codebase-architecture/LANGUAGE.md b/.agents/skills/improve-codebase-architecture/LANGUAGE.md index 595dd2d..43d4cc1 100644 --- a/.agents/skills/improve-codebase-architecture/LANGUAGE.md +++ b/.agents/skills/improve-codebase-architecture/LANGUAGE.md @@ -9,7 +9,7 @@ Shared vocabulary for every suggestion this skill makes. Use these terms exactly **Module** Anything with an interface and an implementation. Deliberately scale-agnostic — applies equally to a function, a class, a file with a public surface, or a subpath entry. _Avoid_: unit, component, service. ("Component" conflicts with React component; use "Module" even when the module is `use-hydrated.ts`.) -_Examples in this repo_: a single factory (`createStorage`); a subpath entry (`persist-idb.ts`); the core entry (`persist-core.ts` + `hydration.ts` behind `src/index.ts`); a codec (`persist-seroval.ts`). +_Examples in this repo_: a single factory (`createStorage`); a subpath entry (`src/adapters/backends/idb.ts`); the core entry (`src/core/persist-core.ts` + `src/core/hydration.ts` behind `src/core/index.ts`); a codec (`src/adapters/codecs/seroval.ts`). **Interface** Everything a caller must know to use the module correctly. Includes the type signature, but also: invariants, ordering constraints, error modes, required configuration, the wire-type contract it depends on, the `HydrationSignal` observation contract. diff --git a/.agents/skills/improve-codebase-architecture/REFERENCE.md b/.agents/skills/improve-codebase-architecture/REFERENCE.md index 2924c72..e5906b7 100644 --- a/.agents/skills/improve-codebase-architecture/REFERENCE.md +++ b/.agents/skills/improve-codebase-architecture/REFERENCE.md @@ -10,13 +10,13 @@ When assessing a candidate for deepening, classify its dependencies: Pure computation, in-memory state, no I/O. Always deepenable — just merge the modules and test directly. -> **Examples in this repo.** Most of `persist-core.ts`: the hydration gate, the write/throttle loop, the registry, the `migrate`/`buster`/`maxAge` helpers. Codec `encode` / `decode` (pure transforms between `StorageValue` and `TRaw`). `hydration.ts` (`HydrationSignal` construction). +> **Examples in this repo.** Most of `src/core/persist-core.ts`: the hydration gate, the write/throttle loop, the registry, the `migrate`/`buster`/`maxAge` helpers. Codec `encode` / `decode` (pure transforms between `StorageValue` and `TRaw`). `src/core/hydration.ts` (`HydrationSignal` construction). ### 2. Local-substitutable Dependencies that have local test stand-ins. Deepenable if the test substitute exists. The deepened module is tested with the local stand-in running in the test suite. -> **Examples in this repo.** An in-memory `StateStorage` map doubles for `localStorage` / `idb-keyval` in `bun:test` (no DOM, no real storage). A hand-rolled `PersistableSource` (a plain object with `getState` / `setState` / `subscribe`) doubles for a TanStack Store — `persist-core.test.ts` exercises the whole middleware without `@tanstack/store`. The `HydrationSignal` is observed from outside the store, so framework adapters test against a synthetic signal, not a real React tree (the `tests-dom` suite covers the React rerender path separately). +> **Examples in this repo.** An in-memory `StateStorage` map doubles for `localStorage` / `idb-keyval` in `bun:test` (no DOM, no real storage). A hand-rolled `PersistableSource` (a plain object with `getState` / `setState` / `subscribe`) doubles for a TanStack Store — `src/core/persist-core.test.ts` exercises the whole middleware without `@tanstack/store`. The `HydrationSignal` is observed from outside the store, so framework adapters test against a synthetic signal, not a real React tree (the `tests-dom` suite covers the React rerender path separately). ### 3. Remote but owned (Ports & Adapters) @@ -30,7 +30,7 @@ Recommendation shape: "Define a `StateStorage` port (already the seam), im Third-party services you don't control (`idb-keyval`, `seroval`, `react`, `@tanstack/store`). Mock at the boundary. The deepened module takes the external dependency as an injected port, and tests provide a mock / stand-in. -> **Examples in this repo.** `idb-keyval` (the `persist-idb` subpath is the adapter; tests use the in-memory `StateStorage`); `seroval` (the `persist-seroval` codec is the adapter; tests use a JSON passthrough codec); `react` (`use-hydrated.ts` is the adapter; `tests-dom` is the only place a real React renderer runs); `@tanstack/store` (`persist-tanstack.ts` is the adapter; core tests use a synthetic source). +> **Examples in this repo.** `idb-keyval` (the `./backends/idb` subpath is the adapter; tests use the in-memory `StateStorage`); `seroval` (the `./codecs/seroval` codec is the adapter; tests use a JSON passthrough codec); `react` (`src/adapters/frameworks/react.ts` is the adapter; `tests-dom` is the only place a real React renderer runs); `@tanstack/store` (`src/adapters/sources/tanstack-store.ts` is the adapter; core tests use a synthetic source). ## Seam discipline @@ -74,7 +74,7 @@ Canonical example layout (if `src/` ever grows a subfolder needing its own rule) ```text .oxlintrc.json ← baseline only src/.oxlintrc.json ← extends root, core zero-dep value-import ban -src/persist-idb/.oxlintrc.json ← extends ../, idb subpath-specific rules +src/adapters/backends/.oxlintrc.json ← extends ../, backends-seam-specific rules ``` Example leaf for a directional rule (keep `persist-core` / `hydration` free of peer-dep value imports — the zero-dep gate): @@ -173,6 +173,6 @@ Which category from `REFERENCE.md` applies and how dependencies are handled: - **File naming**: don't add a `-plan` suffix — the `plans/` folder provides context. `docs/plans/.md`. - **Roadmap link format**: `[](./plans/<file>.md)` under the appropriate section in `docs/roadmap.md`. - **Boundary candidates that need lint enforcement** should propose the exact `.oxlintrc.json` block in the same plan — see [Boundary enforcement](./REFERENCE.md#boundary-enforcement-oxlint) above. -- **Public-surface changes**: when the candidate touches the package public API (an `exports` map entry, a shipped `.d.mts`, the root `README.md`), the plan must include the migration path for **every** consumer-reachable import (and a changeset entry). The published typings are the public surface — don't guess; enumerate via `package.json` `exports` + `src/index.ts` re-exports. +- **Public-surface changes**: when the candidate touches the package public API (an `exports` map entry, a shipped `.d.mts`, the root `README.md`), the plan must include the migration path for **every** consumer-reachable import (and a changeset entry). The published typings are the public surface — don't guess; enumerate via `package.json` `exports` + `src/core/index.ts` re-exports. - **Pure dead-code removal is not a plan candidate.** Those go directly into `docs/roadmap.md`. This skill is for plans that need design discussion. - **Glossary cross-reference**: when the proposal renames or introduces a domain term, link to (and on the same PR, update) [`docs/glossary.md`](../../../docs/glossary.md). If there's no entry yet and the term is genuinely domain-bearing, recommend [`domain-modeling`](../domain-modeling/SKILL.md) first. diff --git a/.agents/skills/pr-comment-fact-check/SKILL.md b/.agents/skills/pr-comment-fact-check/SKILL.md index 2401877..24143b4 100644 --- a/.agents/skills/pr-comment-fact-check/SKILL.md +++ b/.agents/skills/pr-comment-fact-check/SKILL.md @@ -19,7 +19,7 @@ description: STOP and fact-check PR review comments before applying or dismissin Common LLM-reviewer patterns on this repo: -1. **"This isn't tested" without checking siblings** — `persist-core` contracts are pinned across `persist-core.test.ts`, `persist-seroval.test.ts`, `persist-idb.test.ts`, `persist-tanstack.test.ts`; `useHydrated` spans `src/use-hydrated.test.ts` (bun, SSR/snapshot) **and** `tests-dom/**/*.test.tsx` (vitest, rerender/detach). Verify coverage before accepting. +1. **"This isn't tested" without checking siblings** — `persist-core` contracts are pinned across `src/core/persist-core.test.ts`, `src/adapters/codecs/seroval.test.ts`, `src/adapters/backends/idb.test.ts`, `src/adapters/sources/tanstack-store.test.ts`; `useHydrated` spans `src/adapters/frameworks/react.test.ts` (bun, SSR/snapshot) **and** `tests-dom/**/*.test.tsx` (vitest, rerender/detach). Verify coverage before accepting. 2. **Type-safety alarms** — if `bun run typecheck` (tsgo) passes, the claim is almost always wrong, or about runtime behavior the type system can't see (then the reviewer must justify with the runtime case). 3. **Generic "best practice" claims unsupported by our rules** — "always destructure", "prefer interfaces over types", "add `useMemo`/`useCallback`" — stylistic; we either have a rule or we don't. Grep `.agents/` for the convention. 4. **Convention citations that don't exist** — "this breaks the library's API conventions" — grep `.agents/` + `docs/architecture.md`. If not codified, it's preference, not rule. diff --git a/.agents/skills/pr-comment-fact-check/WORKFLOW.md b/.agents/skills/pr-comment-fact-check/WORKFLOW.md index aa05f15..91d45d8 100644 --- a/.agents/skills/pr-comment-fact-check/WORKFLOW.md +++ b/.agents/skills/pr-comment-fact-check/WORKFLOW.md @@ -71,9 +71,9 @@ Output a triage table grouped by verdict, not by file: ```markdown ## ✅ Correct (N) — apply -| # | File:line | Claim (1 line) | Action | -| --- | ------------------ | -------------- | --------------------- | -| 1 | persist-core.ts:42 | … | Apply suggested diff. | +| # | File:line | Claim (1 line) | Action | +| --- | --------------------------- | -------------- | --------------------- | +| 1 | src/core/persist-core.ts:42 | … | Apply suggested diff. | ## ❌ Incorrect / hallucinated (N) — push back diff --git a/.agents/skills/tdd/PATTERNS.md b/.agents/skills/tdd/PATTERNS.md index c1f4452..df536ff 100644 --- a/.agents/skills/tdd/PATTERNS.md +++ b/.agents/skills/tdd/PATTERNS.md @@ -8,10 +8,10 @@ Integration-style through the **public seams** (`persistSource`, `createStorage` ```ts import { describe, expect, it } from "bun:test"; -import { createStorage, jsonCodec, persistSource } from "./persist-core"; -import type { PersistableSource, StateStorage } from "./persist-core"; +import { createStorage, jsonCodec, persistSource } from "../core/persist-core"; +import type { PersistableSource, StateStorage } from "../core/persist-core"; -// Test doubles live in persist-core.test.ts — sketched here for the pattern. +// Test doubles live in src/core/persist-core.test.ts — sketched here for the pattern. class MemoryStorage implements StateStorage { private store = new Map<string, string>(); getItem(key: string) { diff --git a/.agents/skills/tdd/SKILL.md b/.agents/skills/tdd/SKILL.md index 8a7ca88..b4092ec 100644 --- a/.agents/skills/tdd/SKILL.md +++ b/.agents/skills/tdd/SKILL.md @@ -12,7 +12,7 @@ Vertical RED→GREEN cycles — **one behavior per loop**, not horizontal "all t - Runner split per `docs/architecture.md` § Test matrix: - `bun test <co-located *.test.ts>` — `src/**` unit tests (core, codecs, backends, TanStack adapters, `useHydrated` SSR/snapshot contracts). No DOM. - `bun run test:dom` — `tests-dom/**/*.test.tsx` (vitest + jsdom + @testing-library/react) for `useHydrated` rerender / unmount / `useSyncExternalStore` reactivity. -- Co-locate tests next to the module (`persist-core.ts` → `persist-core.test.ts`). +- Co-locate tests next to the module (`src/core/persist-core.ts` → `src/core/persist-core.test.ts`). - After each GREEN: format/lint/typecheck per [`verify-after-each-step`](../../rules/verify-after-each-step.md). - Mock at the **storage backend seam** (`StateStorage`), never inside `persist-core` or a codec. See [`PATTERNS.md`](./PATTERNS.md). diff --git a/.changeset/solid-vue-hydration.md b/.changeset/solid-vue-hydration.md index 5771874..29e1567 100644 --- a/.changeset/solid-vue-hydration.md +++ b/.changeset/solid-vue-hydration.md @@ -2,7 +2,7 @@ "@stainless-code/persist": minor --- -Add `./frameworks/solid` and `./frameworks/vue` hydration subpaths — `useHydrated(signal)` over the `HydrationSignal` seam, mirroring the React adapter (`src/use-hydrated.ts`). +Add `./frameworks/solid` and `./frameworks/vue` hydration subpaths — `useHydrated(signal)` over the `HydrationSignal` seam, mirroring the React adapter (`src/adapters/frameworks/react.ts`). - `./frameworks/solid` (peer `solid-js >=1.6.0`): returns a Solid `Accessor<boolean>` via `from`; the subscription is owned by the reactive scope and cleaned up on scope dispose. Uses the `from(producer, initialValue)` overload so the accessor is `Accessor<boolean>` (not `boolean | undefined`); reads `isHydrated()` for the initial value (pull-model signal — no initial notification). - `./frameworks/vue` (peer `vue >=3.3.0`): returns a Vue `Ref<boolean>` via `shallowRef`; subscription cleaned up via `onScopeDispose` — call inside `setup()` or an `effectScope()`. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 76476c4..0332e9f 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -2,19 +2,19 @@ `@stainless-code/persist` is a small, freshly extracted library. Before large PRs, please open an issue so we can align on: -- **Public surface** — anything exported from an entry point (`src/index.ts` and the four subpath entries) is the public API and must carry JSDoc that reads well in hovers and published typings. See [`docs/architecture.md`](../docs/architecture.md) for the seam model. +- **Public surface** — anything exported from an entry point (`src/core/index.ts` and the fifteen opt-in adapter subpaths) is the public API and must carry JSDoc that reads well in hovers and published typings. See [`docs/architecture.md`](../docs/architecture.md) for the seam model. - **Runtimes** — **Node** `^20.19.0 || >=22.12.0` and **Bun** `>=1.0.0` (`package.json` **engines**). The core is zero-dep by design (enforced by a gate test); each subpath owns its optional peer. ## Dev workflow ```bash bun install # runs `prepare` → Husky git hooks -bun test ./src # 99 bun:test unit tests +bun test ./src # 154 bun:test unit tests bun run test:dom # vitest + jsdom — React useHydrated reactivity (DOM) tests bun run typecheck # tsgo --noEmit bun run lint # oxlint bun run format # oxfmt -bun run build # tsdown → dist/ (five subpath entries) +bun run build # tsdown → dist/ (sixteen subpath entries) bun run docs:api # TypeDoc → docs/api/ (git-ignored HTML site) bun run check # build, then format:check + lint:ci + test + test:dom + typecheck (in parallel) bun run check-updates # interactive dependency updates (`bun update -i --latest`) diff --git a/.github/ISSUE_TEMPLATE/1_bug.yml b/.github/ISSUE_TEMPLATE/1_bug.yml index 2dfa4a1..1f883cf 100644 --- a/.github/ISSUE_TEMPLATE/1_bug.yml +++ b/.github/ISSUE_TEMPLATE/1_bug.yml @@ -5,7 +5,7 @@ body: - type: markdown attributes: value: | - **Core** means the shared middleware: `persistSource`, the hydration lifecycle, `createStorage` / codecs, shipped backends (`./seroval`, `./idb`), the TanStack Store adapters, or the React `useHydrated` hook. For a new backend / codec / framework adapter, use **Feature / adapter proposal** instead. + **Core** means the shared middleware: `persistSource`, the hydration lifecycle, `createStorage` / codecs, shipped backends (`./codecs/seroval`, `./backends/idb`), the TanStack Store adapters, or the React `useHydrated` hook. For a new backend / codec / framework adapter, use **Feature / adapter proposal** instead. - type: textarea id: summary diff --git a/.github/ISSUE_TEMPLATE/2_feature_adapter.yml b/.github/ISSUE_TEMPLATE/2_feature_adapter.yml index adb94a9..3c874d7 100644 --- a/.github/ISSUE_TEMPLATE/2_feature_adapter.yml +++ b/.github/ISSUE_TEMPLATE/2_feature_adapter.yml @@ -27,7 +27,7 @@ body: id: packaging attributes: label: Packaging preference - description: New subpath entries own their optional peer dep; framework hooks get their own entry (e.g. `./react`). + description: New subpath entries own their optional peer dep; framework hooks get their own entry (e.g. `./frameworks/react`). options: - New subpath entry in this repo (owns its optional peer) - Separate npm package (I can help maintain) diff --git a/docs/architecture.md b/docs/architecture.md index 90b868b..514f08e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,7 +68,7 @@ One API. Sync backends (localStorage) settle hydration before first paint; async - **Public surface** — every export from an entry point is the public API and carries JSDoc that reads well in hovers and published typings. `@default` / `@example` tags survive into the shipped `.d.mts` (tsdown dts preserves JSDoc). No `@internal` tags are currently warranted — every export is part of the public surface; `stripInternal: true` is set in `tsconfig.json` as a forward-looking guard so any future `@internal`-marked member is dropped from the dts. - **`PersistStorage.raw`** — stays **public** (semi-public seam): set by `createStorage` and identity-compared by cross-tab rehydrate; hand-rolled `PersistStorage` implementations may omit it. Typed `unknown` deliberately (only identity matters; typing it `StateStorage<TRaw>` would cascade the wire-type generic for no benefit). - **Internal aliases** — non-exported helper types (e.g. the listener alias) are kept out of public signatures: `PersistApi.onHydrate` / `onFinishHydration` inline `(state: TState) => void` so the shipped dts never leaks an unexported type name into a hover. The internal alias is reserved for the implementation's listener Sets. -- **API reference** — `bun run docs:api` runs [TypeDoc](https://typedoc.org) (`typedoc.json`) over the twelve entry points to a static HTML site under `docs/api/` (git-ignored). `treatWarningsAsErrors` + `validation.invalidLink` gate unresolved `{@link}` targets; all current `{@link}` resolve within the `index` core entry (no cross-entry links). +- **API reference** — `bun run docs:api` runs [TypeDoc](https://typedoc.org) (`typedoc.json`) over the sixteen entry points to a static HTML site under `docs/api/` (git-ignored). `treatWarningsAsErrors` + `validation.invalidLink` gate unresolved `{@link}` targets; all current `{@link}` resolve within the `index` core entry (no cross-entry links). ## Test matrix diff --git a/docs/glossary.md b/docs/glossary.md index a49f1e0..7109b7b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -58,4 +58,4 @@ - [`docs/architecture.md`](./architecture.md) — the three-seam model and entry-point layout. - [`docs/README.md`](./README.md) — docs index. -- `src/persist-core.ts`, `src/hydration.ts` — the JSDoc is the full contract. +- `src/core/persist-core.ts`, `src/core/hydration.ts` — the JSDoc is the full contract. diff --git a/docs/plans/upstream-tanstack-pitch.md b/docs/plans/upstream-tanstack-pitch.md index 1fedf3a..626ccd9 100644 --- a/docs/plans/upstream-tanstack-pitch.md +++ b/docs/plans/upstream-tanstack-pitch.md @@ -1,12 +1,12 @@ # Upstream TanStack Persist — pitch draft -**Status:** Draft for posting (GitHub Discussion / Discord). Addressee: Kevin Vandy, Luca (LeCarbonator). Context: TanStack Persist is being shaped; you invited proposals, want Query-persister parity and "get the initial shape right"; agreed route was publish under `@stainless-code/persist` first, then collaborate. This is that follow-up. _Hydrated 2026-07-04 — the reference has grown to 14 opt-in subpaths across five seam categories (codecs, backends, transport, sources, frameworks) with 158 tests; the shape calls are unchanged, the breadth is wider, and the parity claims are fact-checked against each peer's source._ +**Status:** Draft for posting (GitHub Discussion / Discord). Addressee: Kevin Vandy, Luca (LeCarbonator). Context: TanStack Persist is being shaped; you invited proposals, want Query-persister parity and "get the initial shape right"; agreed route was publish under `@stainless-code/persist` first, then collaborate. This is that follow-up. _Hydrated 2026-07-04 — the reference has grown to 15 opt-in subpaths across five seam categories (codecs, backends, transport, sources, frameworks) with 158 tests; the shape calls are unchanged, the breadth is wider, and the parity claims are fact-checked against each peer's source._ --- Hey Kevin, Luca — following up on the persist proposal. We spun the work out as [`@stainless-code/persist`](https://github.com/stainless-code/persist) (extracted, 158 tests, shipping under stainless-code). It's a working reference for the shape questions you're deciding now; happy to fold any of it upstream. Quick tour. -**1. What it is.** A middleware model over a _structural_ `PersistableSource` (`getState`/`setState`/`subscribe`) — not a store-specific wrapper. `persistSource(source, options)` persists anything reactive; `persistStore`/`persistAtom` are ~20-line adapters for `@tanstack/store`. This differs from a passive `StoragePersister` (dump/load a blob): the middleware owns the lifecycle — hydrate-on-create, subscribe-writes gated until hydrated, trailing throttle, teardown — so the store stays a plain store and persistence is a sidekick. Three seams (`StateStorage` backend × `StorageCodec` × `PersistableSource`) make every backend × codec cell a one-line composition instead of a feature request. 14 opt-in subpaths now exercise those seams — codecs (seroval, zod), backends (IndexedDB, AsyncStorage, MMKV, SecureStore, plus encrypted/compressed wrappers), a BroadcastChannel cross-tab transport, store sources (TanStack), and framework hydration adapters (React/Solid/Vue/Svelte) — so the shape is proven to scale, not just sketched. ([README § Extensibility guide](https://github.com/stainless-code/persist#extensibility-guide)) +**1. What it is.** A middleware model over a _structural_ `PersistableSource` (`getState`/`setState`/`subscribe`) — not a store-specific wrapper. `persistSource(source, options)` persists anything reactive; `persistStore`/`persistAtom` are ~20-line adapters for `@tanstack/store`. This differs from a passive `StoragePersister` (dump/load a blob): the middleware owns the lifecycle — hydrate-on-create, subscribe-writes gated until hydrated, trailing throttle, teardown — so the store stays a plain store and persistence is a sidekick. Three seams (`StateStorage` backend × `StorageCodec` × `PersistableSource`) make every backend × codec cell a one-line composition instead of a feature request. 15 opt-in subpaths now exercise those seams — codecs (seroval, zod), backends (IndexedDB, AsyncStorage, MMKV, SecureStore, plus encrypted/compressed wrappers), a BroadcastChannel cross-tab transport, store sources (TanStack), and framework hydration adapters (React/Solid/Vue/Svelte) — so the shape is proven to scale, not just sketched. ([README § Extensibility guide](https://github.com/stainless-code/persist#extensibility-guide)) **2. Sync vs async — one API.** We deliberately did NOT split sync/async backends into two APIs. `getItem` returning a native `Promise` switches the read path to async; sync backends stay sync and hydration settles **before first paint** (no flash, no `Suspense`). Async backends (IndexedDB) ride the _same_ `getItem` Promise branch; UI gates on a hydration signal. `instanceof Promise` (not thenable duck-typing) so a stored value with a `then` field is never mistaken for a pending read. ([docs/architecture.md § Sync vs async](https://github.com/stainless-code/persist/blob/main/docs/architecture.md#sync-vs-async)) diff --git a/src/adapters/frameworks/solid.test.ts b/src/adapters/frameworks/solid.test.ts index f17f000..fe56a82 100644 --- a/src/adapters/frameworks/solid.test.ts +++ b/src/adapters/frameworks/solid.test.ts @@ -1,7 +1,7 @@ import { beforeAll, describe, expect, it, mock } from "bun:test"; // Bun resolves `solid-js` to the SSR build (`createEffect` is a no-op). Point -// both the test and persist-solid at the client build for reactive coverage. +// both the test and `solid` at the client build for reactive coverage. mock.module( "solid-js", // @ts-expect-error client bundle — no `.d.ts` subpath; runtime-only for tests. diff --git a/src/core/hydration.ts b/src/core/hydration.ts index 33e02d6..73f9708 100644 --- a/src/core/hydration.ts +++ b/src/core/hydration.ts @@ -1,5 +1,5 @@ // Hydration is OBSERVED from outside the store via `useSyncExternalStore` -// (see `./use-hydrated.ts`), never by mutating `store.state` with a +// (see `./frameworks/react` — the reference `useHydrated`), never by mutating `store.state` with a // `__hydrated` flag — store creation and `useSelector` reads stay identical // with or without a hydration sidekick. diff --git a/src/core/persist-core.ts b/src/core/persist-core.ts index 37b77dd..0bb7283 100644 --- a/src/core/persist-core.ts +++ b/src/core/persist-core.ts @@ -1,9 +1,7 @@ -// Library core — ZERO value imports by design (enforced by a test). Each -// module maps 1:1 to a package entry point and consumers import them -// directly — no barrel, so every dependency edge is the import graph: -// `./persist-seroval` (seroval codec), `./persist-idb` (idb-keyval backend), -// `./persist-tanstack` (@tanstack/store adapters), `./hydration` + -// `./use-hydrated` (hydration signal + React hook). +// Library core — ZERO value imports by design (enforced by a test). Adapters +// under `./codecs|backends|transport|sources|frameworks/` each map 1:1 to a +// package entry point; consumers import them directly — no barrel, so every +// dependency edge is the import graph. /** * Minimal string-keyed storage interface (matches `localStorage` / @@ -127,7 +125,7 @@ export interface PersistOptions<TState, TPersistedState = TState> { /** * Storage layer to read/write. When no backend is available at all (SSR, * tests), `persistSource` returns a no-op `PersistApi`. For `Set`/`Map`/ - * `Date` round-trips pass `createSerovalStorage` (from `./persist-seroval`). + * `Date` round-trips pass `createSerovalStorage` (from `./codecs/seroval`). * @default JSON-encoded `localStorage` (`createJSONStorage`) */ storage?: PersistStorage<TPersistedState>; @@ -574,7 +572,7 @@ function resolveDefaultStorage<TState, TPersistedState>( if (typeof localStorage === "undefined") return; // JSON default keeps the core zero-dep — a seroval default would drag a // runtime dependency into every consumer. `Set`/`Map`/`Date` users opt in - // by passing `createSerovalStorage` from `./persist-seroval` explicitly. + // by passing `createSerovalStorage` from `./codecs/seroval` explicitly. return createJSONStorage<TPersistedState>(() => localStorage); } diff --git a/tsdown.config.ts b/tsdown.config.ts index c9573fe..1f238fd 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "tsdown"; const outDir = "dist"; -// Fourteen subpath entries — each maps 1:1 to an `exports` entry and mirrors +// Sixteen subpath entries — each maps 1:1 to an `exports` entry and mirrors // the src folder structure: key `<seam>/<name>` → `src/adapters/<seam>/<name>.ts` // → `dist/<seam>/<name>.mjs` → subpath `./<seam>/<name>`. The core (`core/index`) // re-exports `persist-core` + `hydration`; the adapters own their optional peer diff --git a/typedoc.json b/typedoc.json index bb83c14..680fd05 100644 --- a/typedoc.json +++ b/typedoc.json @@ -8,11 +8,15 @@ "src/adapters/backends/async-storage.ts", "src/adapters/backends/mmkv.ts", "src/adapters/backends/secure-store.ts", + "src/adapters/backends/encrypted.ts", + "src/adapters/backends/compressed.ts", "src/adapters/transport/crosstab.ts", "src/adapters/sources/tanstack-store.ts", "src/adapters/frameworks/react.ts", "src/adapters/frameworks/solid.ts", - "src/adapters/frameworks/vue.ts" + "src/adapters/frameworks/vue.ts", + "src/adapters/frameworks/svelte.ts", + "src/adapters/frameworks/svelte-store.ts" ], "out": "docs/api", "name": "@stainless-code/persist", From 129c5f833ce15cebbd2cd5352ff879e336b160ea Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sat, 4 Jul 2026 20:48:38 +0300 Subject: [PATCH 16/77] =?UTF-8?q?docs:=20remove=20stale-prone=20count=20en?= =?UTF-8?q?umerations=20(Sixteen/158/15/=E2=80=A6/N=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/encrypted-compressed.md | 2 +- .changeset/svelte-hydration.md | 2 +- .github/CONTRIBUTING.md | 6 +++--- docs/architecture.md | 2 +- docs/plans/upstream-tanstack-pitch.md | 6 +++--- tsdown.config.ts | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.changeset/encrypted-compressed.md b/.changeset/encrypted-compressed.md index e24421b..75f22c2 100644 --- a/.changeset/encrypted-compressed.md +++ b/.changeset/encrypted-compressed.md @@ -7,6 +7,6 @@ Add two zero-dep storage wrappers over the `StateStorage` seam — both async we - `./backends/encrypted` — `createEncryptedStorage(getStorage, { key })`: AES-GCM via WebCrypto (`crypto.subtle`). Each stored value is `base64(iv).base64(ciphertext)`; the AES-GCM auth tag means a wrong key or tampered ciphertext throws on decrypt → persist-core's corrupt-payload path returns `null` (or `clearCorruptOnFailure` removes the key). Returns `undefined` when `crypto.subtle` is unavailable so `createStorage` collapses to the no-op `PersistApi`. - `./backends/compressed` — `createCompressedStorage(getStorage, { format? })`: native `CompressionStream`/`DecompressionStream` (`gzip` | `deflate` | `deflate-raw`, default `gzip`); output is base64 so it stays string-wire. Returns `undefined` when the stream APIs are unavailable. Stacks with `createEncryptedStorage` (compress-then-encrypt is the standard order). -**Design note:** encryption + compression are backend **wrappers**, not sync `StorageCodec`s, because `crypto.subtle` and the stream APIs are async and the `StorageCodec` seam is sync. The codec serializes the envelope (sync); the wrapper encrypts/compresses the serialized string (async). 14 co-located tests (round-trip, ciphertext-not-plaintext, wrong-key-fails / compression-ratio, missing-key, formats, persistSource end-to-end, availability guard, dependency isolation). +**Design note:** encryption + compression are backend **wrappers**, not sync `StorageCodec`s, because `crypto.subtle` and the stream APIs are async and the `StorageCodec` seam is sync. The codec serializes the envelope (sync); the wrapper encrypts/compresses the serialized string (async). Co-located tests (round-trip, ciphertext-not-plaintext, wrong-key-fails / compression-ratio, missing-key, formats, persistSource end-to-end, availability guard, dependency isolation). Also adds a README comparison table vs zustand-persist / redux-persist / @tanstack/query-persist-client / pinia-persist, and a migration guide with option-mapping tables + port snippets for each incumbent. diff --git a/.changeset/svelte-hydration.md b/.changeset/svelte-hydration.md index 600c738..b5bbd1c 100644 --- a/.changeset/svelte-hydration.md +++ b/.changeset/svelte-hydration.md @@ -7,4 +7,4 @@ Add Svelte hydration adapters over the `HydrationSignal` seam, covering both pre - `./frameworks/svelte` (peer `svelte >=5.0.0`) — `hydratedRune(signal)` via `svelte/reactivity` `createSubscriber`. Returns `{ readonly current: boolean }`; read `current` inside a reactive context (`$derived`/`$effect`/component/`{#if}`). Subscription owned by the reactive context, cleaned up on context 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 (for users who prefer the store API). Subscription tied to the store's subscriber lifecycle. -Both render `true` on the server (no-op `PersistApi` is always-hydrated) — matching the `HydrationSignal` adapter contract. Each is its own subpath with `svelte` optional, no cross-entry value imports (isolation test included). 7 co-located tests: the store adapter is fully tested in bun (`svelte/store` works standalone — value, subscriber updates, signal subscribe/unsubscribe lifecycle); the runes adapter pins the value contract (the reactive auto-update needs a Svelte component context — `createSubscriber`'s start is a no-op without an owner — and rides on the `HydrationSignal` contract pinned in `core/hydration.test.ts`, same philosophy as the React `use-hydrated` bun test). +Both render `true` on the server (no-op `PersistApi` is always-hydrated) — matching the `HydrationSignal` adapter contract. Each is its own subpath with `svelte` optional, no cross-entry value imports (isolation test included). Co-located tests: the store adapter is fully tested in bun (`svelte/store` works standalone — value, subscriber updates, signal subscribe/unsubscribe lifecycle); the runes adapter pins the value contract (the reactive auto-update needs a Svelte component context — `createSubscriber`'s start is a no-op without an owner — and rides on the `HydrationSignal` contract pinned in `core/hydration.test.ts`, same philosophy as the React `use-hydrated` bun test). diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 0332e9f..ef3f622 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -2,19 +2,19 @@ `@stainless-code/persist` is a small, freshly extracted library. Before large PRs, please open an issue so we can align on: -- **Public surface** — anything exported from an entry point (`src/core/index.ts` and the fifteen opt-in adapter subpaths) is the public API and must carry JSDoc that reads well in hovers and published typings. See [`docs/architecture.md`](../docs/architecture.md) for the seam model. +- **Public surface** — anything exported from an entry point (`src/core/index.ts` and the opt-in adapter subpaths) is the public API and must carry JSDoc that reads well in hovers and published typings. See [`docs/architecture.md`](../docs/architecture.md) for the seam model. - **Runtimes** — **Node** `^20.19.0 || >=22.12.0` and **Bun** `>=1.0.0` (`package.json` **engines**). The core is zero-dep by design (enforced by a gate test); each subpath owns its optional peer. ## Dev workflow ```bash bun install # runs `prepare` → Husky git hooks -bun test ./src # 154 bun:test unit tests +bun test ./src # bun:test unit tests bun run test:dom # vitest + jsdom — React useHydrated reactivity (DOM) tests bun run typecheck # tsgo --noEmit bun run lint # oxlint bun run format # oxfmt -bun run build # tsdown → dist/ (sixteen subpath entries) +bun run build # tsdown → dist/ (one file per entry, mirroring src) bun run docs:api # TypeDoc → docs/api/ (git-ignored HTML site) bun run check # build, then format:check + lint:ci + test + test:dom + typecheck (in parallel) bun run check-updates # interactive dependency updates (`bun update -i --latest`) diff --git a/docs/architecture.md b/docs/architecture.md index 514f08e..cb21d85 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,7 +68,7 @@ One API. Sync backends (localStorage) settle hydration before first paint; async - **Public surface** — every export from an entry point is the public API and carries JSDoc that reads well in hovers and published typings. `@default` / `@example` tags survive into the shipped `.d.mts` (tsdown dts preserves JSDoc). No `@internal` tags are currently warranted — every export is part of the public surface; `stripInternal: true` is set in `tsconfig.json` as a forward-looking guard so any future `@internal`-marked member is dropped from the dts. - **`PersistStorage.raw`** — stays **public** (semi-public seam): set by `createStorage` and identity-compared by cross-tab rehydrate; hand-rolled `PersistStorage` implementations may omit it. Typed `unknown` deliberately (only identity matters; typing it `StateStorage<TRaw>` would cascade the wire-type generic for no benefit). - **Internal aliases** — non-exported helper types (e.g. the listener alias) are kept out of public signatures: `PersistApi.onHydrate` / `onFinishHydration` inline `(state: TState) => void` so the shipped dts never leaks an unexported type name into a hover. The internal alias is reserved for the implementation's listener Sets. -- **API reference** — `bun run docs:api` runs [TypeDoc](https://typedoc.org) (`typedoc.json`) over the sixteen entry points to a static HTML site under `docs/api/` (git-ignored). `treatWarningsAsErrors` + `validation.invalidLink` gate unresolved `{@link}` targets; all current `{@link}` resolve within the `index` core entry (no cross-entry links). +- **API reference** — `bun run docs:api` runs [TypeDoc](https://typedoc.org) (`typedoc.json`) over every entry point to a static HTML site under `docs/api/` (git-ignored). `treatWarningsAsErrors` + `validation.invalidLink` gate unresolved `{@link}` targets; all current `{@link}` resolve within the `index` core entry (no cross-entry links). ## Test matrix diff --git a/docs/plans/upstream-tanstack-pitch.md b/docs/plans/upstream-tanstack-pitch.md index 626ccd9..7c77502 100644 --- a/docs/plans/upstream-tanstack-pitch.md +++ b/docs/plans/upstream-tanstack-pitch.md @@ -1,12 +1,12 @@ # Upstream TanStack Persist — pitch draft -**Status:** Draft for posting (GitHub Discussion / Discord). Addressee: Kevin Vandy, Luca (LeCarbonator). Context: TanStack Persist is being shaped; you invited proposals, want Query-persister parity and "get the initial shape right"; agreed route was publish under `@stainless-code/persist` first, then collaborate. This is that follow-up. _Hydrated 2026-07-04 — the reference has grown to 15 opt-in subpaths across five seam categories (codecs, backends, transport, sources, frameworks) with 158 tests; the shape calls are unchanged, the breadth is wider, and the parity claims are fact-checked against each peer's source._ +**Status:** Draft for posting (GitHub Discussion / Discord). Addressee: Kevin Vandy, Luca (LeCarbonator). Context: TanStack Persist is being shaped; you invited proposals, want Query-persister parity and "get the initial shape right"; agreed route was publish under `@stainless-code/persist` first, then collaborate. This is that follow-up. _Hydrated 2026-07-04 — the reference has grown to opt-in subpaths across the seam categories (codecs, backends, transport, sources, frameworks) with a full test suite; the shape calls are unchanged, the breadth is wider, and the parity claims are fact-checked against each peer's source._ --- -Hey Kevin, Luca — following up on the persist proposal. We spun the work out as [`@stainless-code/persist`](https://github.com/stainless-code/persist) (extracted, 158 tests, shipping under stainless-code). It's a working reference for the shape questions you're deciding now; happy to fold any of it upstream. Quick tour. +Hey Kevin, Luca — following up on the persist proposal. We spun the work out as [`@stainless-code/persist`](https://github.com/stainless-code/persist) (extracted, test-covered, shipping under stainless-code). It's a working reference for the shape questions you're deciding now; happy to fold any of it upstream. Quick tour. -**1. What it is.** A middleware model over a _structural_ `PersistableSource` (`getState`/`setState`/`subscribe`) — not a store-specific wrapper. `persistSource(source, options)` persists anything reactive; `persistStore`/`persistAtom` are ~20-line adapters for `@tanstack/store`. This differs from a passive `StoragePersister` (dump/load a blob): the middleware owns the lifecycle — hydrate-on-create, subscribe-writes gated until hydrated, trailing throttle, teardown — so the store stays a plain store and persistence is a sidekick. Three seams (`StateStorage` backend × `StorageCodec` × `PersistableSource`) make every backend × codec cell a one-line composition instead of a feature request. 15 opt-in subpaths now exercise those seams — codecs (seroval, zod), backends (IndexedDB, AsyncStorage, MMKV, SecureStore, plus encrypted/compressed wrappers), a BroadcastChannel cross-tab transport, store sources (TanStack), and framework hydration adapters (React/Solid/Vue/Svelte) — so the shape is proven to scale, not just sketched. ([README § Extensibility guide](https://github.com/stainless-code/persist#extensibility-guide)) +**1. What it is.** A middleware model over a _structural_ `PersistableSource` (`getState`/`setState`/`subscribe`) — not a store-specific wrapper. `persistSource(source, options)` persists anything reactive; `persistStore`/`persistAtom` are ~20-line adapters for `@tanstack/store`. This differs from a passive `StoragePersister` (dump/load a blob): the middleware owns the lifecycle — hydrate-on-create, subscribe-writes gated until hydrated, trailing throttle, teardown — so the store stays a plain store and persistence is a sidekick. Three seams (`StateStorage` backend × `StorageCodec` × `PersistableSource`) make every backend × codec cell a one-line composition instead of a feature request. Opt-in subpaths now exercise those seams — codecs (seroval, zod), backends (IndexedDB, AsyncStorage, MMKV, SecureStore, plus encrypted/compressed wrappers), a BroadcastChannel cross-tab transport, store sources (TanStack), and framework hydration adapters (React/Solid/Vue/Svelte) — so the shape is proven to scale, not just sketched. ([README § Extensibility guide](https://github.com/stainless-code/persist#extensibility-guide)) **2. Sync vs async — one API.** We deliberately did NOT split sync/async backends into two APIs. `getItem` returning a native `Promise` switches the read path to async; sync backends stay sync and hydration settles **before first paint** (no flash, no `Suspense`). Async backends (IndexedDB) ride the _same_ `getItem` Promise branch; UI gates on a hydration signal. `instanceof Promise` (not thenable duck-typing) so a stored value with a `then` field is never mistaken for a pending read. ([docs/architecture.md § Sync vs async](https://github.com/stainless-code/persist/blob/main/docs/architecture.md#sync-vs-async)) diff --git a/tsdown.config.ts b/tsdown.config.ts index 1f238fd..0e59be0 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "tsdown"; const outDir = "dist"; -// Sixteen subpath entries — each maps 1:1 to an `exports` entry and mirrors +// Subpath entries — each maps 1:1 to an `exports` entry and mirrors // the src folder structure: key `<seam>/<name>` → `src/adapters/<seam>/<name>.ts` // → `dist/<seam>/<name>.mjs` → subpath `./<seam>/<name>`. The core (`core/index`) // re-exports `persist-core` + `hydration`; the adapters own their optional peer From 847504944a2362371264f524a0dc85956e1e8e6e Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sat, 4 Jul 2026 20:51:23 +0300 Subject: [PATCH 17/77] docs(plans): rewrite the upstream TanStack pitch fresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/plans/upstream-tanstack-pitch.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/plans/upstream-tanstack-pitch.md b/docs/plans/upstream-tanstack-pitch.md index 7c77502..c002aae 100644 --- a/docs/plans/upstream-tanstack-pitch.md +++ b/docs/plans/upstream-tanstack-pitch.md @@ -1,19 +1,19 @@ -# Upstream TanStack Persist — pitch draft +# Upstream TanStack Persist — pitch -**Status:** Draft for posting (GitHub Discussion / Discord). Addressee: Kevin Vandy, Luca (LeCarbonator). Context: TanStack Persist is being shaped; you invited proposals, want Query-persister parity and "get the initial shape right"; agreed route was publish under `@stainless-code/persist` first, then collaborate. This is that follow-up. _Hydrated 2026-07-04 — the reference has grown to opt-in subpaths across the seam categories (codecs, backends, transport, sources, frameworks) with a full test suite; the shape calls are unchanged, the breadth is wider, and the parity claims are fact-checked against each peer's source._ +**Status:** Draft for posting (GitHub Discussion / Discord). --- -Hey Kevin, Luca — following up on the persist proposal. We spun the work out as [`@stainless-code/persist`](https://github.com/stainless-code/persist) (extracted, test-covered, shipping under stainless-code). It's a working reference for the shape questions you're deciding now; happy to fold any of it upstream. Quick tour. +Hi — sharing [`@stainless-code/persist`](https://github.com/stainless-code/persist), a hydration-aware persistence middleware for any reactive store. Published under stainless-code, extracted, test-covered. It could serve as a reference or a contribution toward TanStack Persist; happy to fold any of it upstream. Quick tour. -**1. What it is.** A middleware model over a _structural_ `PersistableSource` (`getState`/`setState`/`subscribe`) — not a store-specific wrapper. `persistSource(source, options)` persists anything reactive; `persistStore`/`persistAtom` are ~20-line adapters for `@tanstack/store`. This differs from a passive `StoragePersister` (dump/load a blob): the middleware owns the lifecycle — hydrate-on-create, subscribe-writes gated until hydrated, trailing throttle, teardown — so the store stays a plain store and persistence is a sidekick. Three seams (`StateStorage` backend × `StorageCodec` × `PersistableSource`) make every backend × codec cell a one-line composition instead of a feature request. Opt-in subpaths now exercise those seams — codecs (seroval, zod), backends (IndexedDB, AsyncStorage, MMKV, SecureStore, plus encrypted/compressed wrappers), a BroadcastChannel cross-tab transport, store sources (TanStack), and framework hydration adapters (React/Solid/Vue/Svelte) — so the shape is proven to scale, not just sketched. ([README § Extensibility guide](https://github.com/stainless-code/persist#extensibility-guide)) +**1. What it is.** A middleware over a _structural_ `PersistableSource` (`getState`/`setState`/`subscribe`) — not a store-specific wrapper. `persistSource(source, options)` persists anything reactive; `persistStore`/`persistAtom` are thin adapters for `@tanstack/store`. Unlike a passive `StoragePersister` (dump/load a blob), the middleware owns the lifecycle — hydrate-on-create, subscribe-writes gated until hydrated, trailing throttle, teardown — so the store stays a plain store and persistence is a sidekick. Three seams (`StateStorage` × `StorageCodec` × `PersistableSource`) make every backend × codec cell a one-line composition; the surface is exercised across codecs (seroval, zod), backends (IndexedDB, AsyncStorage, MMKV, SecureStore, encrypted, compressed), a BroadcastChannel cross-tab transport, TanStack store sources, and framework hydration adapters (React/Solid/Vue/Svelte) — so the shape is proven to scale, not sketched. ([README § Extensibility guide](https://github.com/stainless-code/persist#extensibility-guide)) -**2. Sync vs async — one API.** We deliberately did NOT split sync/async backends into two APIs. `getItem` returning a native `Promise` switches the read path to async; sync backends stay sync and hydration settles **before first paint** (no flash, no `Suspense`). Async backends (IndexedDB) ride the _same_ `getItem` Promise branch; UI gates on a hydration signal. `instanceof Promise` (not thenable duck-typing) so a stored value with a `then` field is never mistaken for a pending read. ([docs/architecture.md § Sync vs async](https://github.com/stainless-code/persist/blob/main/docs/architecture.md#sync-vs-async)) +**2. Sync vs async — one API.** No split into two APIs. `getItem` returning a native `Promise` switches the read path to async; sync backends stay sync and hydration settles before first paint (no flash, no `Suspense`). Async backends ride the same `getItem` Promise branch; UI gates on a hydration signal. `instanceof Promise` (not thenable duck-typing) so a stored value with a `then` field is never mistaken for a pending read. ([architecture § Sync vs async](https://github.com/stainless-code/persist/blob/main/docs/architecture.md#sync-vs-async)) -**3. Query-persister parity + the honest delta.** We fact-checked a feature-by-feature comparison against each peer's source ([README § Comparison](https://github.com/stainless-code/persist#comparison-with-other-persist-libraries)). **Parity** — `@tanstack/query-persist-client` has these too: `buster`, `maxAge`, `throttleTime` (our `throttleMs`), `retry` (our `retryWrite` shrink-or-give-up, with a write-generation guard so stale retries never clobber newer state), a hydration gate (`useIsRestoring`/`PersistQueryClientProvider`), and framework hydration adapters (it ships React/Solid/Svelte/Angular/Preact). **Beyond parity** — where `@stainless-code/persist` goes further: a fully store-agnostic source (query is cache-bound), a codec seam on the storage layer independent of the backend (query couples `serialize`/`deserialize` to the persister factory), versioned `migrate` (query has only `buster`, no `version`), cross-tab sync (`crossTab` + `onCrossTabRemove` + a `BroadcastChannel` bridge for backends that fire no `storage` events), and schema validation (a `zod` codec — invalid state never writes; corrupt reads discard). **One deliberate divergence:** `maxAge` is opt-in, not default-on — prefs shouldn't silently expire. ([docs/architecture.md § Beyond Query-persister parity](https://github.com/stainless-code/persist/blob/main/docs/architecture.md#beyond-query-persister-parity)) +**3. Where it lands vs `@tanstack/query-persist-client`.** Fact-checked against query's source. **Parity** — query has these too: `buster`, `maxAge`, `throttleTime` (`throttleMs`), `retry` (`retryWrite` shrink-or-give-up), a hydration gate (`useIsRestoring` / `PersistQueryClientProvider`), and framework hydration adapters (it ships React/Solid/Svelte/Angular/Preact). **Beyond** — a store-agnostic source (query is cache-bound), a codec seam independent of the backend (query couples `serialize`/`deserialize` to the persister factory), versioned `migrate` (query has only `buster`, no `version`), cross-tab sync, and schema validation (a `zod` codec). One deliberate divergence: `maxAge` is opt-in, not default-on — prefs shouldn't silently expire. ([README § Comparison](https://github.com/stainless-code/persist#comparison-with-other-persist-libraries)) -**4. How it could merge.** The seams map cleanly onto TanStack Persist: `StateStorage` + `StorageCodec` are drop-in; `persistSource` is the store-agnostic core a `persistStore`/`persistQuery`/`persistForm` family can share; `HydrationSignal` is the framework-agnostic subscribe target — we ship React (`useSyncExternalStore`), Solid (`from`), Vue (`shallowRef` + `onScopeDispose`), and Svelte (runes via `createSubscriber`, plus a `readable` store variant for Svelte 4) adapters over it, each ~20 lines. We'd contribute the core + the TanStack Store adapter + the framework hydration adapters, and keep iterating on the public surface with you. No barrel — one subpath per optional peer, categorized to mirror the source folders (`./codecs/…`, `./backends/…`, `./transport/…`, `./sources/…`, `./frameworks/…`) — so the dependency opt-in is the import and a contributor adding an adapter wires it with zero mental mapping. +**4. How it could merge.** `StateStorage` + `StorageCodec` drop in; `persistSource` is the store-agnostic core a `persistStore`/`persistQuery`/`persistForm` family can share; `HydrationSignal` is the framework-agnostic subscribe target — React (`useSyncExternalStore`), Solid (`from`), Vue (`shallowRef` + `onScopeDispose`), and Svelte (runes `createSubscriber`, plus a `readable` store variant for Svelte 4) mount it. Contributable: the core + the TanStack adapter + the framework hydration adapters. No barrel — one subpath per optional peer, categorized to mirror the source (`./codecs/…`, `./backends/…`, `./transport/…`, `./sources/…`, `./frameworks/…`) — so the dep opt-in is the import. **5. Links.** Repo: <https://github.com/stainless-code/persist>. README: [quick start](https://github.com/stainless-code/persist#quick-start) · [extensibility guide](https://github.com/stainless-code/persist#extensibility-guide) · [comparison vs zustand/redux/query/pinia](https://github.com/stainless-code/persist#comparison-with-other-persist-libraries) · [migration guide](https://github.com/stainless-code/persist#migrating-from-) · [recipes](https://github.com/stainless-code/persist#recipes). Architecture: [docs/architecture.md](https://github.com/stainless-code/persist/blob/main/docs/architecture.md). -Happy to open a Discussion with this, or take it inline — whatever's easiest. The `maxAge` default and the sync-first read path are the two shape calls most worth a second opinion. +Happy to open a Discussion or take it inline. The `maxAge` default and the sync-first read path are the two shape calls most worth a second opinion. From c8ae95c88af7e9406101546a294eedcc945af1ed Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sat, 4 Jul 2026 21:00:16 +0300 Subject: [PATCH 18/77] docs(agents): lift redundant-enumeration + historical-traces lessons into authoring-discipline PROSE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .agents/skills/authoring-discipline/PROSE.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.agents/skills/authoring-discipline/PROSE.md b/.agents/skills/authoring-discipline/PROSE.md index 631fa82..27a5409 100644 --- a/.agents/skills/authoring-discipline/PROSE.md +++ b/.agents/skills/authoring-discipline/PROSE.md @@ -8,9 +8,11 @@ Companion to always-on [`.agents/rules/authoring-discipline.md`](../../rules/aut **Keep:** why (not what), non-obvious constraints, cross-cutting context, pointers when a relationship isn't obvious, sentinels/magic values, the rejected alternative, storage/runtime quirks (sync-vs-async read path, `instanceof Promise` gating, cross-tab identity compare). -**Cut:** file inventories, pasted signatures, restating the next line, generic library practice, duplicate facts across README/architecture/glossary. +**Cut:** file inventories, pasted signatures, restating the next line, generic library practice, duplicate facts across README/architecture/glossary, tallied counts of re-derivable items ("16 subpaths", "158 tests") — the number goes stale the moment it changes and turns into errored info; the items (a table, a folder) carry the story, the number doesn't. -**Comments/JSDoc:** 0 lines when self-explanatory; 1 line default; 2–3 only for irreducible gotchas; `>3 lines` → lift to `docs/` with one-line pointer. The shipped `.d.mts` should read well in hovers — `@param` / `@returns` / `@default` / `@example` carry the meaning; types stay, narrating them does not. +**Comments/JSDoc:** 0 lines when self-explanatory; 1 line default; 2–3 only for irreducible gotchas; `>3 lines` → lift to `docs/` with one-line pointer. The shipped `.d.mts` should read well in hovers — `@param` / `@returns` / `@default` / `@example` (with real, resolving imports) carry the meaning when usage isn't obvious; types stay, narrating them does not. + +**Historical traces** in committed prose — "hydrated on …", "following up on …", changelog-edit residue, stale rosters — earn no ROI once the moment passes; write as if fresh, cut the trace. Source comments: the rule's "update if outdated" covers them. **Doc slimming:** full checklists in [`docs-governance`](../docs-governance/SKILL.md) (anchor preservation, existence test, anti-bloat). From 589d64b52f5d15e1853bb1d5805fafd5be538c17 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sat, 4 Jul 2026 21:05:30 +0300 Subject: [PATCH 19/77] =?UTF-8?q?docs(audit):=20mark=20the=2014=20complete?= =?UTF-8?q?d=20ROI=20plan=20items=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/audits/2026-07-04-docs-adapters-roi.md | 62 ++++++++++----------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/docs/audits/2026-07-04-docs-adapters-roi.md b/docs/audits/2026-07-04-docs-adapters-roi.md index 041a2a4..953bed9 100644 --- a/docs/audits/2026-07-04-docs-adapters-roi.md +++ b/docs/audits/2026-07-04-docs-adapters-roi.md @@ -103,36 +103,36 @@ Docs site (VitePress/Starlight) · Getting Started · Adapters catalog · Storag ## ROI-ordered action items -Ordered by ROI = impact ÷ effort. **Effort:** S / M / L. +Ordered by ROI = impact ÷ effort. **Effort:** S / M / L. **Status as of 2026-07-04:** ✅ = shipped on the `audit/docs-adapters-roi` branch. ### Tier 1 — Ship first (high impact, low effort) -| # | Action | Effort | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | -| 1 | Define "hydration-aware" in the README — one paragraph + a "what flashes without it" diagram. Fixes the single biggest tone gap. | S | -| 2 | Add a complete IDB + React + `useHydrated` + `destroy()` walkthrough to the README. Closes the gap that justifies the library's existence. | S | -| 3 | Link the generated `docs/api/` site from the README + publish to GitHub Pages (`.nojekyll` already present). | S | -| 4 | Document `createPersistRegistry` + clear-all-on-logout with a recipe. | S | -| 5 | Add recipes for `partialize`, `merge`, `retryWrite`, `throttleMs`, `maxAge`, `buster` — six hidden powers, one short block each. | S | -| 6 | BroadcastChannel → `CrossTabEventTarget` bridge adapter for IDB cross-tab. Seam exists (`persist-core.ts:113`); IDB fires no `storage` events so `crossTab` is silently broken on IDB without it (`persist-idb.ts:62`, `skill:116`). | S | -| 7 | `expo-secure-store` / `react-native-mmkv` / `AsyncStorage` storage adapters (one subpath each). Unlocks an entire platform. | S each | -| 8 | `zod`-validated codec adapter — decode runs in existing corrupt-payload try/catch (`persist-core.ts:473`); validation errors map cleanly to `clearCorruptOnFailure`. | S | -| 9 | Solid + Vue framework hydration adapters — `HydrationSignal` JSDoc names both as targets (`hydration.ts:9-10`); each is a one-liner. | S each | +| # | Action | Effort | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | +| ✅ 1 | Define "hydration-aware" in the README — one paragraph + a "what flashes without it" diagram. Fixes the single biggest tone gap. | S | +| ✅ 2 | Add a complete IDB + React + `useHydrated` + `destroy()` walkthrough to the README. Closes the gap that justifies the library's existence. | S | +| ✅ 3 | Link the generated `docs/api/` site from the README + publish to GitHub Pages (`.nojekyll` already present). | S | +| ✅ 4 | Document `createPersistRegistry` + clear-all-on-logout with a recipe. | S | +| ✅ 5 | Add recipes for `partialize`, `merge`, `retryWrite`, `throttleMs`, `maxAge`, `buster` — six hidden powers, one short block each. | S | +| ✅ 6 | BroadcastChannel → `CrossTabEventTarget` bridge adapter for IDB cross-tab. Seam exists (`persist-core.ts:113`); IDB fires no `storage` events so `crossTab` is silently broken on IDB without it (`persist-idb.ts:62`, `skill:116`). | S | +| ✅ 7 | `expo-secure-store` / `react-native-mmkv` / `AsyncStorage` storage adapters (one subpath each). Unlocks an entire platform. | S each | +| ✅ 8 | `zod`-validated codec adapter — decode runs in existing corrupt-payload try/catch (`persist-core.ts:473`); validation errors map cleanly to `clearCorruptOnFailure`. | S | +| ✅ 9 | Solid + Vue framework hydration adapters — `HydrationSignal` JSDoc names both as targets (`hydration.ts:9-10`); each is a one-liner. | S each | ### Tier 2 — Build out the surface (high impact, medium effort) -| # | Action | Effort | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -| 10 | Encryption-at-rest codec (WebCrypto + codec). Headline "custom codec" example in JSDoc (`persist-core.ts:69`, `persist-idb.ts:52`, `skill:155`) with no shipped impl. | M | -| 11 | `examples/` monorepo workspace — runnable `tanstack-idb-react`, `tanstack-localstorage-react`, `nextjs-ssr`, `react-native-mmkv`. No runnable demo today. | M | -| 12 | Docs site (VitePress / Astro Starlight) — split wall-of-text README into Getting Started → Adapters → Recipes → Adapter authoring → Reference; host `docs/api/` under it. | M | -| 13 | TanStack Query persister bridge (`persistQueryClient`-shaped). JSDoc cites Query as reference design (`persist-core.ts:12,263`). Flagship integration. | M | -| 14 | Migration/porting guide — option mapping + conceptual diff vs zustand-persist / redux-persist / query-persist-client / pinia-persist. | S | -| 15 | Comparison table across the 4 incumbents. README paragraph → table. | S | -| 16 | Storage & codec decision matrices — lift + expand the skill's 4-row version to consumer docs. | S | -| 17 | `CompressionStream` codec — native API, ~S now; pairs with binary `TRaw`. | M | -| 18 | Node `fs` storage adapter — trivial `StateStorage`; unblocks server/SSR/CLI. | S | -| 19 | Pack-validation + semver gate in CI — `attw --pack` + `knip` + `publint`. Prevents shipping a broken `exports` map. | S | +| # | Action | Effort | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| ✅ 10 | Encryption-at-rest codec (WebCrypto + codec). Headline "custom codec" example in JSDoc (`persist-core.ts:69`, `persist-idb.ts:52`, `skill:155`) with no shipped impl. | M | +| 11 | `examples/` monorepo workspace — runnable `tanstack-idb-react`, `tanstack-localstorage-react`, `nextjs-ssr`, `react-native-mmkv`. No runnable demo today. | M | +| 12 | Docs site (VitePress / Astro Starlight) — split wall-of-text README into Getting Started → Adapters → Recipes → Adapter authoring → Reference; host `docs/api/` under it. | M | +| 13 | TanStack Query persister bridge (`persistQueryClient`-shaped). JSDoc cites Query as reference design (`persist-core.ts:12,263`). Flagship integration. | M | +| ✅ 14 | Migration/porting guide — option mapping + conceptual diff vs zustand-persist / redux-persist / query-persist-client / pinia-persist. | S | +| ✅ 15 | Comparison table across the 4 incumbents. README paragraph → table. | S | +| 16 | Storage & codec decision matrices — lift + expand the skill's 4-row version to consumer docs. | S | +| ✅ 17 | `CompressionStream` codec — native API, ~S now; pairs with binary `TRaw`. | M | +| 18 | Node `fs` storage adapter — trivial `StateStorage`; unblocks server/SSR/CLI. | S | +| 19 | Pack-validation + semver gate in CI — `attw --pack` + `knip` + `publint`. Prevents shipping a broken `exports` map. | S | ### Tier 3 — Maturity & polish (medium impact, medium effort) @@ -149,13 +149,13 @@ Ordered by ROI = impact ÷ effort. **Effort:** S / M / L. ### Tier 4 — Strategic bets (high impact, high effort) -| # | Action | Effort | -| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| 28 | React ergonomics layer — `<PersistProvider>` + context + `usePersisted(store)` selector binding + auto-`destroy()`. `use-hydrated.ts:22` signals this is intentionally deferred. | M-L | -| 29 | StackBlitz / CodeSandbox playground embedded in docs site. | M | -| 30 | OPFS + SQLite-WASM + Cloudflare KV/Durable Objects storage adapters. | M-L | -| 31 | Migration-chain helper — `createMigrationChain({...})`; today `migrate` is a single callback, v0→v1→v2 chaining is user-written. | M | -| 32 | Continue the TanStack upstream pitch (`docs/plans/upstream-tanstack-pitch.md`) — adapter breadth (#13, #9) + docs polish (#1, #2, #12) are the leverage. Ongoing. | ongoing | +| # | Action | Effort | +| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| 28 | React ergonomics layer — `<PersistProvider>` + context + `usePersisted(store)` selector binding + auto-`destroy()`. `use-hydrated.ts:22` signals this is intentionally deferred. | M-L | +| 29 | StackBlitz / CodeSandbox playground embedded in docs site. | M | +| 30 | OPFS + SQLite-WASM + Cloudflare KV/Durable Objects storage adapters. | M-L | +| 31 | Migration-chain helper — `createMigrationChain({...})`; today `migrate` is a single callback, v0→v1→v2 chaining is user-written. | M | +| ✅ 32 | Continue the TanStack upstream pitch (`docs/plans/upstream-tanstack-pitch.md`) — adapter breadth (#13, #9) + docs polish (#1, #2, #12) are the leverage. Ongoing. | ongoing | --- From d001b7acd85074b7e65478d7fccaea1bd1b40fdb Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sat, 4 Jul 2026 21:08:39 +0300 Subject: [PATCH 20/77] chore(package): refresh description + keywords to the full scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- package.json | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index ae382b3..62d0a27 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,31 @@ { "name": "@stainless-code/persist", "version": "0.1.1", - "description": "Hydration-aware persistence middleware for reactive stores (storage × codec seams, TanStack Store adapters, React hydration hook)", + "description": "Hydration-aware persistence middleware for any reactive store — pluggable storage × codec seams, cross-tab sync, and React/Solid/Vue/Svelte hydration adapters", "keywords": [ + "broadcastchannel", + "codec", + "compression", + "cross-tab", + "encryption", "hydration", "indexeddb", "localstorage", - "persist", + "middleware", "persistence", - "state", + "react", + "react-native", + "reactive", + "seroval", + "solid", + "state-management", "store", + "svelte", "tanstack", - "tanstack-intent" + "tanstack-intent", + "tanstack-store", + "vue", + "zod" ], "homepage": "https://github.com/stainless-code/persist#readme", "bugs": { From 69ab821036e28ed17e5dee75fa852c3bf30b8f73 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sat, 4 Jul 2026 21:20:36 +0300 Subject: [PATCH 21/77] feat(backends): node-fs adapter; pack-validation CI gate; decision matrices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/node-fs-pack-gate.md | 7 + .github/workflows/ci.yml | 20 ++- README.md | 46 ++++++ bun.lock | 225 ++++++++++++++++++++++++-- docs/architecture.md | 3 +- knip.json | 10 ++ package.json | 12 +- src/adapters/backends/node-fs.test.ts | 154 ++++++++++++++++++ src/adapters/backends/node-fs.ts | 51 ++++++ tsdown.config.ts | 1 + typedoc.json | 1 + 11 files changed, 509 insertions(+), 21 deletions(-) create mode 100644 .changeset/node-fs-pack-gate.md create mode 100644 knip.json create mode 100644 src/adapters/backends/node-fs.test.ts create mode 100644 src/adapters/backends/node-fs.ts diff --git a/.changeset/node-fs-pack-gate.md b/.changeset/node-fs-pack-gate.md new file mode 100644 index 0000000..490ad90 --- /dev/null +++ b/.changeset/node-fs-pack-gate.md @@ -0,0 +1,7 @@ +--- +"@stainless-code/persist": minor +--- + +Add `./backends/node-fs` — `nodeFsStateStorage({ dir })`, an async `StateStorage<string>` over Node `fs.promises` (one file per key under `dir`). No peer dep (`node:fs` is a Node built-in). Keys are sanitized to filename-safe segments (`app:prefs:v1` → `app_prefs_v1`); missing files map to `null` (no throw); the dir is created lazily on first write. Compose with `createStorage(() => nodeFsStateStorage({ dir }), codec)`. Unblocks server / SSR / CLI persistence. Co-located tests (round-trip, missing-key, dir creation, filename sanitization, persistSource end-to-end, dependency isolation). + +Also adds a pack-validation + semver gate (`check:pack`: `@arethetypeswrong/cli` + `publint` + `knip`) wired into CI (`check-pack` job) + `prepublishOnly`; and README storage + codec decision matrices. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 544a923..9e7ac5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,6 +122,21 @@ jobs: - name: Run build run: bun run build + check-pack: + name: 📦 Pack validation + needs: [skip-ci, build] + if: needs['skip-ci'].outputs.skip != 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup + uses: ./.github/actions/setup + + - name: Run pack validation + run: bun run check:pack + audit: # Non-blocking — known-CVE visibility only; does not catch supply-chain malware. # Team policy: if this job is red on a dep/lockfile PR, triage before merge @@ -143,7 +158,7 @@ jobs: ci-complete: name: CI complete - needs: [skip-ci, format, lint, typecheck, test, test-dom, build] + needs: [skip-ci, format, lint, typecheck, test, test-dom, build, check-pack] if: always() runs-on: ubuntu-latest steps: @@ -155,7 +170,8 @@ jobs: needs.typecheck.result != 'success' || needs.test.result != 'success' || needs['test-dom'].result != 'success' || - needs.build.result != 'success' + needs.build.result != 'success' || + needs['check-pack'].result != 'success' ) run: exit 1 diff --git a/README.md b/README.md index 464c791..91d48b7 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Each subpath owns its dependency as an **optional peer** — import only the ent | `@stainless-code/persist/backends/secure-store` | `expo-secure-store` | | `@stainless-code/persist/backends/encrypted` | none (web global) | | `@stainless-code/persist/backends/compressed` | none (web global) | +| `@stainless-code/persist/backends/node-fs` | none (Node built-in) | | `@stainless-code/persist/transport/crosstab` | none (web global) | | `@stainless-code/persist/sources/tanstack-store` | `@tanstack/store` | | `@stainless-code/persist/frameworks/react` | `react` | @@ -396,6 +397,7 @@ Persistence middleware for any `getState`/`setState`/`subscribe` store (TanStack | `@stainless-code/persist/backends/secure-store` | `secureStoreStateStorage`, `createSecureStoreStorage` | `expo-secure-store` | | `@stainless-code/persist/backends/encrypted` | `createEncryptedStorage` (AES-GCM WebCrypto) | none (web global) | | `@stainless-code/persist/backends/compressed` | `createCompressedStorage` (gzip/deflate/deflate-raw) | none (web global) | +| `@stainless-code/persist/backends/node-fs` | `nodeFsStateStorage` (one file per key) | none (Node built-in) | | `@stainless-code/persist/transport/crosstab` | `createBroadcastCrossTab` | none (web global) | | `@stainless-code/persist/sources/tanstack-store` | `persistStore`, `persistAtom` | `@tanstack/store` (types only) | | `@stainless-code/persist/frameworks/react` | `useHydrated` React hook | `react` | @@ -468,6 +470,36 @@ persistSource({ getState, setState, subscribe }, opts); // zustand-like, redux, Compose freely: `createStorage(backend, codec, options)` covers every backend × codec cell. **Factory policy:** codec factories take the backend as an argument; a backend earns its own factory only when it needs real adaptation (IndexedDB); everything else composes — no factory-per-combination. +## Choosing a storage + +Pick by sync-vs-async (does it gate UI?), cross-tab needs, and whether you want structured-clone (Set/Map/Date natively). + +| Backend | Sync? | Cross-tab | Structured-clone | Size | Gate UI? | Subpath | +| -------------------- | ----- | --------- | ---------------- | ------------------ | -------- | -------------------------- | +| IndexedDB | ✗ | ✗ | ✓ | large | ✓ | `./backends/idb` | +| AsyncStorage (RN) | ✗ | ✗ | ✗ | large | ✓ | `./backends/async-storage` | +| MMKV (RN) | ✓ | ✗ | ✗ | large | ✗ | `./backends/mmkv` | +| Secure Store (Expo) | ✗ | ✗ | ✗ | ~2KB/key | ✓ | `./backends/secure-store` | +| Node fs | ✗ | ✗ | ✗ | disk | ✓ | `./backends/node-fs` | +| Encrypted (wrapper) | ✗ | inherits | ✗ | inherits | ✓ | `./backends/encrypted` | +| Compressed (wrapper) | ✗ | inherits | ✗ | inherits (smaller) | ✓ | `./backends/compressed` | +| localStorage | ✓ | ✓ | ✗ | ~5MB | ✗ | core `createJSONStorage` | +| sessionStorage | ✓ | ✗ | ✗ | ~5MB | ✗ | core `createJSONStorage` | + +IDB has no storage events — pair `./transport/crosstab` for cross-tab sync. + +## Choosing a codec + +Pick by whether you need Set/Map/Date round-trips, schema-gated persistence, or a structured-clone backend. `identityCodec` only with structured-clone backends (IDB) — never string-wire. + +| Codec | Set/Map/Date | Wire type | Schema validation | For backend | Subpath | +| --------------------- | -------------------- | -------------------------- | ----------------- | --------------------------- | ------------------------ | +| `jsonCodec` | ✗ | string | ✗ | string-wire | core | +| `serovalCodec` | ✓ | string (JSON-shaped) | ✗ | string-wire | `./codecs/seroval` | +| `zodCodec` | ✓ (via schema) | string | ✓ | string-wire | `./codecs/zod` | +| `identityCodec` | ✓ (structured clone) | `StorageValue<S>` (object) | ✗ | structured-clone only (IDB) | core | +| custom `StorageCodec` | yours | yours | yours | any | custom `encode`/`decode` | + ## Recipes ```ts @@ -643,6 +675,20 @@ persistStore(store, { // teardown: persist.destroy(); bridge.close(); ``` +### Node fs (server / SSR / CLI) + +```ts +import { createStorage } from "@stainless-code/persist"; +import { nodeFsStateStorage } from "@stainless-code/persist/backends/node-fs"; +import { serovalCodec } from "@stainless-code/persist/codecs/seroval"; + +// One file per key under ./persist — async (fs.promises); gate UI on useHydrated in SSR +const storage = createStorage<Prefs>( + () => nodeFsStateStorage({ dir: "./.persist" }), + serovalCodec(), +); +``` + Caveats that matter per backend: async backends (IDB) can't settle hydration before first paint → gate UI on `useHydrated` (`@stainless-code/persist/frameworks/react`); `sessionStorage` is per-tab (crossTab is meaningless); `identityCodec` never with string-only backends. ## Writing a framework adapter diff --git a/bun.lock b/bun.lock index 803e57c..18fb34c 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "@stainless-code/persist", "devDependencies": { + "@arethetypeswrong/cli": "^0.18.4", "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.31.0", "@react-native-async-storage/async-storage": "^3.1.1", @@ -21,9 +22,11 @@ "husky": "9.1.7", "idb-keyval": "6.2.6", "jsdom": "29.1.1", + "knip": "^6.24.0", "lint-staged": "17.0.8", "oxfmt": "0.56.0", "oxlint": "1.71.0", + "publint": "^0.3.21", "react": "19.2.7", "react-dom": "19.2.7", "react-native-mmkv": "^4.3.2", @@ -46,6 +49,7 @@ "react-native-mmkv": ">=4.0.0", "seroval": ">=1.0.0", "solid-js": ">=1.6.0", + "svelte": ">=3.0.0", "vue": ">=3.3.0", "zod": ">=3.20.0", }, @@ -58,12 +62,19 @@ "react-native-mmkv", "seroval", "solid-js", + "svelte", "vue", "zod", ], }, }, "packages": { + "@andrewbranch/untar.js": ["@andrewbranch/untar.js@1.0.3", "", {}, "sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw=="], + + "@arethetypeswrong/cli": ["@arethetypeswrong/cli@0.18.4", "", { "dependencies": { "@arethetypeswrong/core": "0.18.4", "chalk": "^4.1.2", "cli-table3": "^0.6.3", "commander": "^10.0.1", "marked": "^9.1.2", "marked-terminal": "^7.1.0", "semver": "^7.5.4" }, "bin": { "attw": "./dist/index.js" } }, "sha512-kNWo6LTzGAuLYPpJ7Sgo63whSUeeSuKMlYx6IBgzs4ONEG807gW4hSSENvpeCHzO2H2wIzG5EFl0OKBbqGBAyA=="], + + "@arethetypeswrong/core": ["@arethetypeswrong/core@0.18.4", "", { "dependencies": { "@andrewbranch/untar.js": "^1.0.3", "@loaderkit/resolve": "^1.0.2", "cjs-module-lexer": "^1.2.3", "fflate": "^0.8.3", "lru-cache": "^11.0.1", "semver": "^7.5.4", "typescript": "5.6.1-rc", "validate-npm-package-name": "^5.0.0" } }, "sha512-M5F0ePyN6h2Z6XxRiyIPqjGbltotXLjR0CKA0uKspsDu0QmgTNYvRb4RSQPMUs2ZXZHCCYpbaZbFbYOXLxCjUA=="], + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="], @@ -204,6 +215,8 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "@braidai/lang": ["@braidai/lang@1.1.2", "", {}, "sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA=="], + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.1", "", { "dependencies": { "@changesets/config": "^3.1.4", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA=="], @@ -244,6 +257,8 @@ "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], + "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], + "@csstools/color-helpers": ["@csstools/color-helpers@6.1.0", "", {}, "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg=="], "@csstools/css-calc": ["@csstools/css-calc@3.2.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg=="], @@ -348,6 +363,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@loaderkit/resolve": ["@loaderkit/resolve@1.0.6", "", { "dependencies": { "@braidai/lang": "^1.0.0" } }, "sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg=="], + "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="], "@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@changesets/types": "^4.0.1", "@manypkg/find-root": "^1.1.0", "fs-extra": "^8.1.0", "globby": "^11.0.0", "read-yaml-file": "^1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="], @@ -360,7 +377,85 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@oxc-project/types": ["@oxc-project/types@0.138.0", "", {}, "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.137.0", "", { "os": "android", "cpu": "arm" }, "sha512-KDs+0VPdEmasOkpuJHW9V5WCF+cvYdMQv2Jd+aJXt+cxIx12NToRQRbXaRwUEDsZw+/jMk81Ve8ZFbjUkJTOwA=="], + + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.137.0", "", { "os": "android", "cpu": "arm64" }, "sha512-WhALNzfy3x/RfC6bsqX+csavuUY0yHHE7XfgPE5M542uhoBZUUoGTPG+nkMbGoG4+gcfss5s7urMyn5QBHu0sw=="], + + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.137.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFPr5hgmNMOMoyPTGtdsK4Ug21RovIPojRMgDDhSp1LtCnc/DkLwGONKjgRjszg677RlGnkYSviQ8hHaUPOVYA=="], + + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.137.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CL5dMm1asqXIDZHg14FLxj3Mc36w8PI7xCWh1uA4is6z8g2XrIILoTcQYOxDbwzuk34RDPX5IAGUxZr6LA9KAg=="], + + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.137.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-79h8rYGnSlKPGWo7mHr2ixO6ea7aW8B0CT965SZ8SLbNnCOH5aOYBTeVXUY6eMvEaiLyWr8Skuiugr5pDYgLGw=="], + + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.137.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ASgmlSimhGyr0lksgVIo6hibz1obnDq4qJbiMX/AzltfgPnanRrzG1Q+23g8ljOHOjv6dsznkUuCYL3gg0sY1Q=="], + + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.137.0", "", { "os": "linux", "cpu": "arm" }, "sha512-AU2J9aa22Sx32wRGnDjybOU9TQXXQUud5sdUi+ZB0XxwM8aToWLweV+yA0wlQm0yIUVqljquqoHCYEq9II8gJQ=="], + + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.137.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-GdEtiG89yMr7XkUGxifgodXEEm2f+xW2f9CpDjlgAnBOwhTmrpQMvhOGobLVKUyzf/qHBXW16smk5zbF3nZU6w=="], + + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.137.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EGJ+Bs8iXx8KBH8DQ5BLoEm5lnHaYjlh4/8j8vFhrr/6z4tqONy5BZDzLpKmmNWlN6Hlc5r8YOuBVHqZ9vRFEQ=="], + + "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.137.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-vzFUQENy/fnbSe5DZWovq6tIBc1uhuMztanSW6rz1e9WdQE4gHwYuD7ZII6JnrJifd1R3RSoqiZbgRFlVL2tYQ=="], + + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.137.0", "", { "os": "linux", "cpu": "none" }, "sha512-SfVI14HBQs9gtLcUD5hTt5hsNbdrqSUNg9S8muN+LhVQ5nf1WwH3hAoK6B9NKgdYgWAQSXFXGiiBedQ4r/BKuw=="], + + "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.137.0", "", { "os": "linux", "cpu": "none" }, "sha512-e7Ppy4FCIFNQxT/ikSeIWFoQ0l+N9vgtRBtLcyZXeolTzApyVoPqEXsYPrcdM/9i0Bwk8knvYd37vaEMxHyi6g=="], + + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.137.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bho5qFwdhqsIFR7gipYEUlqvi3SRrY8sugxXig380MIaakBB1PyU9+7dBiBVScfImTNWhijUxdBwqrprGdq5WA=="], + + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.137.0", "", { "os": "linux", "cpu": "x64" }, "sha512-36mGWtg7PyFzjJwGDkH6/F4o2nIDEoKXLPr/X/lwqklkomQwJJt1I5GJVmGhovUEmgPK5WAeAZMqlFCehwiy9Q=="], + + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.137.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/Jqx6+N7A44n2BdvUr7pXhVr2vFjs6WGH3unZRczwrfiH0H1zY0QwKQMG/dtRiTlKGDKGukznPT8lx84/oEsZg=="], + + "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.137.0", "", { "os": "none", "cpu": "arm64" }, "sha512-9Uj0qHNNl+OgT1UTGwF7ixIXU6T1u2SbMidmgPy/h1h/fl2gRS6YpAxxY1gwHofcWjoTwkoMFd8xs5Vuj6GOFA=="], + + "@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.137.0", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.5" }, "cpu": "none" }, "sha512-gW2vfkytNGgMVADiuzdvOfw0mWG9za20F/1fCJsif5aBMAvWJTSbpIXbIe0XkOe0VENk+PadpQ7cZgUy2sUJcA=="], + + "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.137.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-x+pFANF0yL5uK/6T7lu6SlR5qid6sp//eZXKLq5iNsIE+EQg6EaS8/wsW7E91nXXjpnPhSoMOHXShSVhGRdn8w=="], + + "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.137.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-sQUqym80PFi6McRsIqfJrSu2JrSClEZIXXD+/FjAFoULEKzOPsldIdFBG96xdX8aVMzCNQ9792FPx3MfkEIrFA=="], + + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.137.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2AsevxlvNN4WKxpEn3RtqD5zbqMaXF+T7JXblsP4gVuY+vC9dXS4ED/PwfRCliFqoeisYS3Iro4DHzxr0TEvVA=="], + + "@oxc-project/types": ["@oxc-project/types@0.137.0", "", {}, "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA=="], + + "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.21.3", "", { "os": "android", "cpu": "arm" }, "sha512-eNU11A2WNizh04v3uyaJCootrHIaS0B9aHYXvAvVnPNk4xYSjMUjHnhQ6dewPN2MRYDskV85d1N0Aw0WNWhcyg=="], + + "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.21.3", "", { "os": "android", "cpu": "arm64" }, "sha512-8Q+ZjTLvn2dIcWsrmhdrEihm7q+ag/k+mkry7Z+t0QbbHaVxXQfvH9AewyVMh/WrpEKhQ3DDgx9fYbqeCpeOEw=="], + + "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.21.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wkh0qKZGHXVUDxFw3oA1TXnU2BDYY/r775oJflGeIr8uDPPoN2pk8gijQIzYRT6hoql/lg3+Tx/SaTn9e2/aGg=="], + + "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.21.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-HbNc23FAQYbuyDV2vBWMez4u4mrsm5RAkniGZAWqr6lYZ3N4beeqIb776jzwRl8qL2zRhHVXpUj97X0QgogVzg=="], + + "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.21.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-K6xNsTUPEUdfrn0+kbMq5nOUB5w1C5pavPQngt4TM2FpN91lP0PBe2srSpamb4d69O7h86oAi/qWX/kZNRSjkw=="], + + "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.21.3", "", { "os": "linux", "cpu": "arm" }, "sha512-VcFmOpcpWX1zoEy8M58tR2M9YxM+Z9RuQhqAx5q0CTmrruaP7Gveejg75hzd/5sg5nk9G3aLALEa3hE2FsmmTQ=="], + + "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.21.3", "", { "os": "linux", "cpu": "arm" }, "sha512-quVoxFLBy43hWaQbbDtQNRwAX5vX76mv7n64icAtQcJ3eNgVeblqmkupF/hAneNthdqSlnd1sTjb3aQSaDPaCQ=="], + + "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.21.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ=="], + + "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.21.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw=="], + + "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.21.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A=="], + + "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.21.3", "", { "os": "linux", "cpu": "none" }, "sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og=="], + + "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.21.3", "", { "os": "linux", "cpu": "none" }, "sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw=="], + + "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.21.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q=="], + + "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.21.3", "", { "os": "linux", "cpu": "x64" }, "sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q=="], + + "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.21.3", "", { "os": "linux", "cpu": "x64" }, "sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA=="], + + "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.21.3", "", { "os": "none", "cpu": "arm64" }, "sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg=="], + + "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.21.3", "", { "dependencies": { "@emnapi/core": "1.11.0", "@emnapi/runtime": "1.11.0", "@napi-rs/wasm-runtime": "^1.1.5" }, "cpu": "none" }, "sha512-i8oluoel5kru/j1WNrjmQSiA3GQ7wvIYVR1IwIoZtKogAhya2iub+ZKIeSIkcJOrnzQ18Tzl/F+kL3fYOxZLvA=="], + + "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.21.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-M/8dw8dD6aOs+NlPJax401CZB9I7Aut84isQLgALGGwke4Afvw+/7yYhZb94yXf6t2sPLhQLmSmtSV+2FhsOWg=="], + + "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.21.3", "", { "os": "win32", "cpu": "x64" }, "sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-CSCxi7ovYojgfdPOdUb9T508HKeAdDIKeRGg7x8IZwVJrWz9gVgX7MbUnFqtQAE4QvoNo07mj2JlwnOzJw4qqA=="], @@ -438,6 +533,8 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.71.0", "", { "os": "win32", "cpu": "x64" }, "sha512-D2kyEIPHk/G/wiZLnwTVC/sVst+T/lKldVOjAFpgTIBUAOlry72e5OiapDbDBF4LfJLkN5ypJb/8Eu6yJzkveQ=="], + "@publint/pack": ["@publint/pack@0.1.5", "", { "dependencies": { "tinyexec": "^1.2.4" } }, "sha512-edgyN2pP07uXiP4tJs0s8KVmU8M8i60YPbbI0/WDeok1mIJHRXz+CgD8I0nelwDkoCh3EWL/G5kGfbuHjsdbvw=="], + "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], "@react-native-async-storage/async-storage": ["@react-native-async-storage/async-storage@3.1.1", "", { "dependencies": { "idb": "8.0.3" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-z+PnLz1n6ECKhgoHZHkfc+dijXZEyZnNFSajbtE0NEbsJhmX8x9GlOeiMQIKX2E4DUqPSgfIh4FYBv1M49KgPQ=="], @@ -508,6 +605,8 @@ "@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], + "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="], @@ -624,12 +723,14 @@ "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], - "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="], + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -704,6 +805,8 @@ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], + "chardet": ["chardet@2.2.0", "", {}, "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA=="], "chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="], @@ -712,10 +815,16 @@ "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], + "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], @@ -728,7 +837,7 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], @@ -788,6 +897,8 @@ "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "emojilib": ["emojilib@2.4.0", "", {}, "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="], + "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], @@ -860,10 +971,14 @@ "fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="], + "fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fetch-nodeshim": ["fetch-nodeshim@0.4.10", "", {}, "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "finalhandler": ["finalhandler@1.1.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "on-finished": "~2.3.0", "parseurl": "~1.3.3", "statuses": "~1.5.0", "unpipe": "~1.0.0" } }, "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA=="], @@ -874,6 +989,8 @@ "fontfaceobserver": ["fontfaceobserver@2.3.0", "", {}, "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg=="], + "formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="], + "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], "fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], @@ -888,7 +1005,7 @@ "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], - "get-tsconfig": ["get-tsconfig@5.0.0-beta.5", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ=="], + "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], "getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], @@ -910,6 +1027,8 @@ "hermes-parser": ["hermes-parser@0.36.0", "", { "dependencies": { "hermes-estree": "0.36.0" } }, "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w=="], + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], + "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], "hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], @@ -974,6 +1093,8 @@ "jimp-compact": ["jimp-compact@0.16.1", "", {}, "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], @@ -992,6 +1113,8 @@ "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "knip": ["knip@6.24.0", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.0", "jiti": "^2.7.0", "oxc-parser": "^0.137.0", "oxc-resolver": "11.21.3", "picomatch": "^4.0.4", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^4.0.1", "yaml": "^2.9.0", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-PokLlgeEjLh1rAsB7ts+52wZ37HBr1nDhE6NNONwEaXdeZGCJOkP7ZlIAI2Gtu8xohquzTWy75bc/1diI9shQw=="], + "lan-network": ["lan-network@0.2.1", "", { "bin": { "lan-network": "dist/lan-network-cli.js" } }, "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A=="], "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], @@ -1056,6 +1179,10 @@ "markdown-it": ["markdown-it@14.3.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.5.0", "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw=="], + "marked": ["marked@9.1.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q=="], + + "marked-terminal": ["marked-terminal@7.3.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "ansi-regex": "^6.1.0", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", "cli-table3": "^0.6.5", "node-emoji": "^2.2.0", "supports-hyperlinks": "^3.1.0" }, "peerDependencies": { "marked": ">=1 <16" } }, "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw=="], + "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], @@ -1120,10 +1247,14 @@ "multitars": ["multitars@1.0.0", "", {}, "sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg=="], + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + "node-emoji": ["node-emoji@2.2.0", "", { "dependencies": { "@sindresorhus/is": "^4.6.0", "char-regex": "^1.0.2", "emojilib": "^2.4.0", "skin-tone": "^2.0.0" } }, "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], @@ -1138,6 +1269,8 @@ "ob1": ["ob1@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -1152,6 +1285,10 @@ "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], + "oxc-parser": ["oxc-parser@0.137.0", "", { "dependencies": { "@oxc-project/types": "^0.137.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.137.0", "@oxc-parser/binding-android-arm64": "0.137.0", "@oxc-parser/binding-darwin-arm64": "0.137.0", "@oxc-parser/binding-darwin-x64": "0.137.0", "@oxc-parser/binding-freebsd-x64": "0.137.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.137.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.137.0", "@oxc-parser/binding-linux-arm64-gnu": "0.137.0", "@oxc-parser/binding-linux-arm64-musl": "0.137.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.137.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.137.0", "@oxc-parser/binding-linux-riscv64-musl": "0.137.0", "@oxc-parser/binding-linux-s390x-gnu": "0.137.0", "@oxc-parser/binding-linux-x64-gnu": "0.137.0", "@oxc-parser/binding-linux-x64-musl": "0.137.0", "@oxc-parser/binding-openharmony-arm64": "0.137.0", "@oxc-parser/binding-wasm32-wasi": "0.137.0", "@oxc-parser/binding-win32-arm64-msvc": "0.137.0", "@oxc-parser/binding-win32-ia32-msvc": "0.137.0", "@oxc-parser/binding-win32-x64-msvc": "0.137.0" } }, "sha512-yFImD+WLElJpLKy8llG1qe4DCmMsL18peRp8XP1JKfig/gISbJkglnpDtX2aTmAn10kZF7164HbN2H8QPsXxGg=="], + + "oxc-resolver": ["oxc-resolver@11.21.3", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.21.3", "@oxc-resolver/binding-android-arm64": "11.21.3", "@oxc-resolver/binding-darwin-arm64": "11.21.3", "@oxc-resolver/binding-darwin-x64": "11.21.3", "@oxc-resolver/binding-freebsd-x64": "11.21.3", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.3", "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.3", "@oxc-resolver/binding-linux-arm64-gnu": "11.21.3", "@oxc-resolver/binding-linux-arm64-musl": "11.21.3", "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.3", "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.3", "@oxc-resolver/binding-linux-riscv64-musl": "11.21.3", "@oxc-resolver/binding-linux-s390x-gnu": "11.21.3", "@oxc-resolver/binding-linux-x64-gnu": "11.21.3", "@oxc-resolver/binding-linux-x64-musl": "11.21.3", "@oxc-resolver/binding-openharmony-arm64": "11.21.3", "@oxc-resolver/binding-wasm32-wasi": "11.21.3", "@oxc-resolver/binding-win32-arm64-msvc": "11.21.3", "@oxc-resolver/binding-win32-x64-msvc": "11.21.3" } }, "sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA=="], + "oxfmt": ["oxfmt@0.56.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.56.0", "@oxfmt/binding-android-arm64": "0.56.0", "@oxfmt/binding-darwin-arm64": "0.56.0", "@oxfmt/binding-darwin-x64": "0.56.0", "@oxfmt/binding-freebsd-x64": "0.56.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.56.0", "@oxfmt/binding-linux-arm-musleabihf": "0.56.0", "@oxfmt/binding-linux-arm64-gnu": "0.56.0", "@oxfmt/binding-linux-arm64-musl": "0.56.0", "@oxfmt/binding-linux-ppc64-gnu": "0.56.0", "@oxfmt/binding-linux-riscv64-gnu": "0.56.0", "@oxfmt/binding-linux-riscv64-musl": "0.56.0", "@oxfmt/binding-linux-s390x-gnu": "0.56.0", "@oxfmt/binding-linux-x64-gnu": "0.56.0", "@oxfmt/binding-linux-x64-musl": "0.56.0", "@oxfmt/binding-openharmony-arm64": "0.56.0", "@oxfmt/binding-win32-arm64-msvc": "0.56.0", "@oxfmt/binding-win32-ia32-msvc": "0.56.0", "@oxfmt/binding-win32-x64-msvc": "0.56.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-9Dv0wV3zKiyvhjD7bRKaInKmHQ1sPx3RGOjQkGFJbbdQ16576yf8qhMSO9Q9cvHcs+1NpBsRTkuDDYFFPTJ6gw=="], "oxlint": ["oxlint@1.71.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.71.0", "@oxlint/binding-android-arm64": "1.71.0", "@oxlint/binding-darwin-arm64": "1.71.0", "@oxlint/binding-darwin-x64": "1.71.0", "@oxlint/binding-freebsd-x64": "1.71.0", "@oxlint/binding-linux-arm-gnueabihf": "1.71.0", "@oxlint/binding-linux-arm-musleabihf": "1.71.0", "@oxlint/binding-linux-arm64-gnu": "1.71.0", "@oxlint/binding-linux-arm64-musl": "1.71.0", "@oxlint/binding-linux-ppc64-gnu": "1.71.0", "@oxlint/binding-linux-riscv64-gnu": "1.71.0", "@oxlint/binding-linux-riscv64-musl": "1.71.0", "@oxlint/binding-linux-s390x-gnu": "1.71.0", "@oxlint/binding-linux-x64-gnu": "1.71.0", "@oxlint/binding-linux-x64-musl": "1.71.0", "@oxlint/binding-openharmony-arm64": "1.71.0", "@oxlint/binding-win32-arm64-msvc": "1.71.0", "@oxlint/binding-win32-ia32-msvc": "1.71.0", "@oxlint/binding-win32-x64-msvc": "1.71.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-U1m1X+C0vDj7DC1e13IoZULzEcPczE7UOMTs8VlZGHUEIUaSTZKo5qkPsQEfzpgnQ29Pea/w3Xntk62UCecxZw=="], @@ -1172,6 +1309,8 @@ "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], + "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], @@ -1210,6 +1349,8 @@ "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "publint": ["publint@0.3.21", "", { "dependencies": { "@publint/pack": "^0.1.4", "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "sade": "^1.8.1" }, "bin": { "publint": "src/cli.js" } }, "sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], @@ -1276,6 +1417,8 @@ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], @@ -1314,12 +1457,16 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="], + "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], "slugify": ["slugify@1.6.9", "", {}, "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg=="], + "smol-toml": ["smol-toml@1.7.0", "", {}, "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ=="], + "solid-js": ["solid-js@1.9.14", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ=="], "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], @@ -1352,11 +1499,13 @@ "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + "structured-headers": ["structured-headers@0.4.1", "", {}, "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "supports-hyperlinks": ["supports-hyperlinks@2.3.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA=="], + "supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -1370,6 +1519,10 @@ "terser": ["terser@5.48.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q=="], + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "throat": ["throat@5.0.0", "", {}, "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], @@ -1412,6 +1565,8 @@ "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], + "unbash": ["unbash@4.0.2", "", {}, "sha512-8gwNZ29+0/3zmXw7ToIHZtg6wK37xnniRUdBt7B27xZxaxfgR5tGMaGHT0t0dLtBV9fXE7zurh0s6Z1DHVjfWg=="], + "unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="], "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], @@ -1420,6 +1575,8 @@ "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], + "unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="], + "unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="], "unicode-match-property-value-ecmascript": ["unicode-match-property-value-ecmascript@2.2.1", "", {}, "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg=="], @@ -1450,6 +1607,8 @@ "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], + "walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="], "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], @@ -1496,6 +1655,8 @@ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@arethetypeswrong/core/typescript": ["typescript@5.6.1-rc", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ=="], + "@babel/core/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], "@babel/core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], @@ -1544,6 +1705,10 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], + + "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], + "@quansync/fs/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "@react-native/codegen/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], @@ -1564,7 +1729,9 @@ "babel-preset-expo/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "cli-highlight/parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], + + "cli-highlight/yargs": ["yargs@16.2.2", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w=="], "cli-truncate/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], @@ -1610,6 +1777,8 @@ "markdown-it/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "marked-terminal/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "metro/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], "metro/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], @@ -1638,14 +1807,30 @@ "ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], + "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], + "plist/@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], + "pretty-format/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "publint/package-manager-detector": ["package-manager-detector@1.7.0", "", {}, "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ=="], + + "react-native/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "react-native/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], "read-yaml-file/js-yaml": ["js-yaml@3.15.0", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog=="], + "rolldown/@oxc-project/types": ["@oxc-project/types@0.138.0", "", {}, "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA=="], + "rolldown-plugin-dts/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], + "rolldown-plugin-dts/get-tsconfig": ["get-tsconfig@5.0.0-beta.5", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ=="], + "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], @@ -1656,10 +1841,14 @@ "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "svelte/aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], "terminal-link/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], + "terminal-link/supports-hyperlinks": ["supports-hyperlinks@2.3.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA=="], + "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "tsdown/cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], @@ -1682,9 +1871,9 @@ "@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - "@expo/cli/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "@expo/cli/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - "@expo/cli/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "@expo/cli/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "@expo/metro-config/@babel/generator/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], @@ -1692,18 +1881,24 @@ "babel-preset-expo/@babel/generator/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "cli-highlight/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], + + "cli-highlight/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], - "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "expo/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "expo/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "jest-validate/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "jest-validate/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -1718,8 +1913,6 @@ "log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], - "log-update/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "log-update/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -1746,6 +1939,8 @@ "ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], + "react-native/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "react-native/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], @@ -1754,9 +1949,7 @@ "terminal-link/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], - "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "cli-highlight/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "log-symbols/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], diff --git a/docs/architecture.md b/docs/architecture.md index cb21d85..aa1ba9f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,6 +27,7 @@ Persistence is bound to a structural `PersistableSource` (`getState` / `setState | `@stainless-code/persist/backends/secure-store` | `adapters/backends/secure-store` | `expo-secure-store` | | `@stainless-code/persist/backends/encrypted` | `adapters/backends/encrypted` | none (web global) | | `@stainless-code/persist/backends/compressed` | `adapters/backends/compressed` | none (web global) | +| `@stainless-code/persist/backends/node-fs` | `adapters/backends/node-fs` | none (Node built-in) | | `@stainless-code/persist/transport/crosstab` | `adapters/transport/crosstab` | none (web global) | | `@stainless-code/persist/sources/tanstack-store` | `adapters/sources/tanstack-store` | `@tanstack/store` (types only) | | `@stainless-code/persist/frameworks/react` | `adapters/frameworks/react` | `react` | @@ -44,7 +45,7 @@ No barrel — importing a subpath is the dependency opt-in. Each subpath entry o - **`core/`** — the zero-dep engine (`persist-core.ts`, `hydration.ts`) plus `index.ts` (the `.` entry that re-exports both). Nothing in `core/` imports an adapter. - **`adapters/<seam>/`** — opt-in entries that own an optional peer and import only from `core/`: - `codecs/` — `StorageCodec` adapters (seroval, zod) - - `backends/` — `StateStorage` adapters + wrappers (idb, async-storage, mmkv, secure-store, encrypted, compressed) + - `backends/` — `StateStorage` adapters + wrappers (idb, async-storage, mmkv, secure-store, encrypted, compressed, node-fs) - `transport/` — `CrossTabEventTarget` adapters (crosstab — BroadcastChannel bridge) - `sources/` — `PersistableSource` adapters (tanstack-store) - `frameworks/` — `HydrationSignal` framework adapters (react, solid, vue, svelte, svelte-store) diff --git a/knip.json b/knip.json new file mode 100644 index 0000000..da8a285 --- /dev/null +++ b/knip.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + "entry": [ + "scripts/*.ts", + "tests-dom/*.test.tsx", + "vitest.config.ts", + "tsdown.config.ts", + "lint-staged.config.js" + ] +} diff --git a/package.json b/package.json index 62d0a27..186a85c 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/stainless-code/persist.git" + "url": "git+https://github.com/stainless-code/persist.git" }, "files": [ "dist", @@ -80,6 +80,10 @@ "types": "./dist/backends/compressed.d.mts", "import": "./dist/backends/compressed.mjs" }, + "./backends/node-fs": { + "types": "./dist/backends/node-fs.d.mts", + "import": "./dist/backends/node-fs.mjs" + }, "./transport/crosstab": { "types": "./dist/transport/crosstab.d.mts", "import": "./dist/transport/crosstab.mjs" @@ -116,6 +120,7 @@ "build": "tsdown", "check": "bun run build && bun run --parallel format:check lint:ci test test:dom typecheck", "check-updates": "bun update -i --latest", + "check:pack": "attw --pack . --profile esm-only && publint && knip", "clean": "git clean -xdf -e .env", "docs:api": "typedoc", "fix": "bun run lint:fix && bun run format", @@ -132,7 +137,7 @@ "lint:fix": "bun run lint --fix", "lint:fix:changes": "bun scripts/run-on-changed-files.ts lint:fix", "prepare": "husky || true", - "prepublishOnly": "bun run check && bun run intent:validate", + "prepublishOnly": "bun run check && bun run intent:validate && bun run check:pack", "release": "changeset publish", "test": "bun test ./src", "test:dom": "vitest run", @@ -140,6 +145,7 @@ "version": "changeset version && bun scripts/sync-skill-versions.ts && bun run format CHANGELOG.md" }, "devDependencies": { + "@arethetypeswrong/cli": "^0.18.4", "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.31.0", "@react-native-async-storage/async-storage": "^3.1.1", @@ -156,9 +162,11 @@ "husky": "9.1.7", "idb-keyval": "6.2.6", "jsdom": "29.1.1", + "knip": "^6.24.0", "lint-staged": "17.0.8", "oxfmt": "0.56.0", "oxlint": "1.71.0", + "publint": "^0.3.21", "react": "19.2.7", "react-dom": "19.2.7", "react-native-mmkv": "^4.3.2", diff --git a/src/adapters/backends/node-fs.test.ts b/src/adapters/backends/node-fs.test.ts new file mode 100644 index 0000000..ba450e3 --- /dev/null +++ b/src/adapters/backends/node-fs.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "bun:test"; +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { createStorage, persistSource } from "../../core/persist-core"; +import type { PersistableSource } from "../../core/persist-core"; +import { serovalCodec } from "../codecs/seroval"; +import { nodeFsStateStorage } from "./node-fs"; + +async function tempDir(): Promise<string> { + return mkdtemp(join(tmpdir(), "persist-nodefs-")); +} + +function createMockSource<T>(initial: T): PersistableSource<T> & { state: T } { + let state = initial; + const listeners = new Set<() => void>(); + + return { + get state() { + return state; + }, + getState: () => state, + setState: (updater) => { + state = updater(state); + listeners.forEach((listener) => listener()); + }, + subscribe: (listener) => { + listeners.add(listener); + return { + unsubscribe: () => listeners.delete(listener), + }; + }, + }; +} + +function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { + return new Promise<void>((resolve, reject) => { + let ticks = 0; + const tick = () => { + if (hasHydrated()) { + resolve(); + return; + } + // Bounded: a hydration regression fails loudly here instead of hanging + // the suite until the runner's opaque timeout. + if (++ticks > maxTicks) { + reject(new Error("waitForHydration: never hydrated")); + return; + } + // node:fs I/O settles on macrotasks — microtasks alone never yield here. + setTimeout(tick, 0); + }; + tick(); + }); +} + +describe("nodeFsStateStorage", () => { + it("round-trips: setItem then getItem returns the value; removeItem then getItem → null", async () => { + const dir = await tempDir(); + try { + const storage = nodeFsStateStorage({ dir }); + await storage.setItem("k", "v"); + expect(await storage.getItem("k")).toBe("v"); + await storage.removeItem("k"); + expect(await storage.getItem("k")).toBeNull(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("getItem returns null for a missing key (no throw)", async () => { + const dir = await tempDir(); + try { + const storage = nodeFsStateStorage({ dir }); + expect(await storage.getItem("missing")).toBeNull(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("setItem creates the dir if it does not exist", async () => { + const base = await tempDir(); + const dir = join(base, "nested", "persist"); + try { + const storage = nodeFsStateStorage({ dir }); + await storage.setItem("k", "v"); + expect(await readFile(join(dir, "k"), "utf8")).toBe("v"); + expect(await storage.getItem("k")).toBe("v"); + } finally { + await rm(base, { recursive: true, force: true }); + } + }); + + it("keys with unsafe chars are sanitized to filename-safe segments", async () => { + const dir = await tempDir(); + try { + const storage = nodeFsStateStorage({ dir }); + await storage.setItem("app:prefs:v1", "prefs"); + const files = await readdir(dir); + expect(files).toContain("app_prefs_v1"); + expect(await storage.getItem("app:prefs:v1")).toBe("prefs"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("composes with createStorage + persistSource end-to-end", async () => { + const dir = await tempDir(); + try { + const name = "node-fs-persist"; + const storage = createStorage<{ count: number }>( + () => nodeFsStateStorage({ dir }), + serovalCodec(), + )!; + + const source = createMockSource({ count: 0 }); + const persist = persistSource(source, { name, storage }); + await waitForHydration(persist.hasHydrated); + + source.setState(() => ({ count: 7 })); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const stored = await storage.getItem(name); + expect(stored?.state.count).toBe(7); + + const source2 = createMockSource({ count: 0 }); + const persist2 = persistSource(source2, { + name, + storage, + skipHydration: true, + }); + await persist2.rehydrate(); + expect(source2.state.count).toBe(7); + + persist.destroy(); + persist2.destroy(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file( + new URL("./node-fs.ts", import.meta.url), + ).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((m) => m[1]); + for (const imp of relativeImports) { + expect(imp).toMatch(/^\.\.\/\.\.\/core\//); + } + }); +}); diff --git a/src/adapters/backends/node-fs.ts b/src/adapters/backends/node-fs.ts new file mode 100644 index 0000000..c280ad4 --- /dev/null +++ b/src/adapters/backends/node-fs.ts @@ -0,0 +1,51 @@ +// Node fs storage backend — no peer dep (node:fs is a Node built-in). One file per key; for server/SSR/CLI. +import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import type { StateStorage } from "../../core/persist-core"; + +export interface NodeFsStorageOptions { + /** Directory holding one file per persisted key (created lazily on first write). */ + dir: string; +} + +/** + * Node `fs` storage backend — one file per key under `dir` (async `fs.promises`). + * Keys are sanitized to filename-safe segments (`app:prefs:v1` → `app_prefs_v1`). + * Compose with `createStorage(() => nodeFsStateStorage({ dir }), codec)`. + * + * @example + * ```ts + * import { nodeFsStateStorage } from "@stainless-code/persist/backends/node-fs"; + * const storage = createStorage<Prefs>(() => nodeFsStateStorage({ dir: "./.persist" }), serovalCodec()); + * ``` + */ +export function nodeFsStateStorage( + options: NodeFsStorageOptions, +): StateStorage<string> { + const { dir } = options; + + const pathFor = (name: string) => + join(dir, name.replace(/[^a-zA-Z0-9._-]/g, "_")); + + return { + getItem: async (name) => { + try { + return await readFile(pathFor(name), "utf8"); + } catch { + return null; + } + }, + setItem: async (name, value) => { + await mkdir(dir, { recursive: true }); + await writeFile(pathFor(name), value, "utf8"); + }, + removeItem: async (name) => { + try { + await unlink(pathFor(name)); + } catch { + /* missing file — nothing to remove */ + } + }, + }; +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 0e59be0..36fd0d6 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ "backends/secure-store": "src/adapters/backends/secure-store.ts", "backends/encrypted": "src/adapters/backends/encrypted.ts", "backends/compressed": "src/adapters/backends/compressed.ts", + "backends/node-fs": "src/adapters/backends/node-fs.ts", "transport/crosstab": "src/adapters/transport/crosstab.ts", "sources/tanstack-store": "src/adapters/sources/tanstack-store.ts", "frameworks/react": "src/adapters/frameworks/react.ts", diff --git a/typedoc.json b/typedoc.json index 680fd05..2c8f760 100644 --- a/typedoc.json +++ b/typedoc.json @@ -10,6 +10,7 @@ "src/adapters/backends/secure-store.ts", "src/adapters/backends/encrypted.ts", "src/adapters/backends/compressed.ts", + "src/adapters/backends/node-fs.ts", "src/adapters/transport/crosstab.ts", "src/adapters/sources/tanstack-store.ts", "src/adapters/frameworks/react.ts", From 46203ee2a959cc9ac3067147ca90c080d5cd8a58 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sat, 4 Jul 2026 21:21:19 +0300 Subject: [PATCH 22/77] =?UTF-8?q?docs(audit):=20mark=20#16,=20#18,=20#19?= =?UTF-8?q?=20=E2=9C=85=20in=20the=20ROI=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/audits/2026-07-04-docs-adapters-roi.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/audits/2026-07-04-docs-adapters-roi.md b/docs/audits/2026-07-04-docs-adapters-roi.md index 953bed9..0b9f8fc 100644 --- a/docs/audits/2026-07-04-docs-adapters-roi.md +++ b/docs/audits/2026-07-04-docs-adapters-roi.md @@ -129,10 +129,10 @@ Ordered by ROI = impact ÷ effort. **Effort:** S / M / L. **Status as of 2026-07 | 13 | TanStack Query persister bridge (`persistQueryClient`-shaped). JSDoc cites Query as reference design (`persist-core.ts:12,263`). Flagship integration. | M | | ✅ 14 | Migration/porting guide — option mapping + conceptual diff vs zustand-persist / redux-persist / query-persist-client / pinia-persist. | S | | ✅ 15 | Comparison table across the 4 incumbents. README paragraph → table. | S | -| 16 | Storage & codec decision matrices — lift + expand the skill's 4-row version to consumer docs. | S | +| ✅ 16 | Storage & codec decision matrices — lift + expand the skill's 4-row version to consumer docs. | S | | ✅ 17 | `CompressionStream` codec — native API, ~S now; pairs with binary `TRaw`. | M | -| 18 | Node `fs` storage adapter — trivial `StateStorage`; unblocks server/SSR/CLI. | S | -| 19 | Pack-validation + semver gate in CI — `attw --pack` + `knip` + `publint`. Prevents shipping a broken `exports` map. | S | +| ✅ 18 | Node `fs` storage adapter — trivial `StateStorage`; unblocks server/SSR/CLI. | S | +| ✅ 19 | Pack-validation + semver gate in CI — `attw --pack` + `knip` + `publint`. Prevents shipping a broken `exports` map. | S | ### Tier 3 — Maturity & polish (medium impact, medium effort) From a88030e1be64a643eaa6bfa3919888781b63b336 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sat, 4 Jul 2026 21:22:08 +0300 Subject: [PATCH 23/77] chore(package): pin pack-validation devDeps (attw/knip/publint) to exact versions --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 186a85c..8a3bcaf 100644 --- a/package.json +++ b/package.json @@ -145,7 +145,7 @@ "version": "changeset version && bun scripts/sync-skill-versions.ts && bun run format CHANGELOG.md" }, "devDependencies": { - "@arethetypeswrong/cli": "^0.18.4", + "@arethetypeswrong/cli": "0.18.4", "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.31.0", "@react-native-async-storage/async-storage": "^3.1.1", @@ -162,11 +162,11 @@ "husky": "9.1.7", "idb-keyval": "6.2.6", "jsdom": "29.1.1", - "knip": "^6.24.0", + "knip": "6.24.0", "lint-staged": "17.0.8", "oxfmt": "0.56.0", "oxlint": "1.71.0", - "publint": "^0.3.21", + "publint": "0.3.21", "react": "19.2.7", "react-dom": "19.2.7", "react-native-mmkv": "^4.3.2", From ab1da3df09c1d8f038663ebbd2522b015f0442fe Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sat, 4 Jul 2026 21:31:10 +0300 Subject: [PATCH 24/77] chore(codemap): dogfood @stainless-code/codemap for agent structural queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .codemap/.gitignore | 8 ++++++++ .cursor/mcp.json | 8 ++++++++ 2 files changed, 16 insertions(+) create mode 100644 .codemap/.gitignore create mode 100644 .cursor/mcp.json diff --git a/.codemap/.gitignore b/.codemap/.gitignore new file mode 100644 index 0000000..b5133a3 --- /dev/null +++ b/.codemap/.gitignore @@ -0,0 +1,8 @@ +# Managed by codemap — overwritten on next run. +# Generated artifacts only; user-authored config (config.*, recipes/) stays tracked. +index.db +index.db-shm +index.db-wal +index.lock +errors.log +audit-cache/ diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..b58abfb --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "codemap": { + "command": "bunx", + "args": ["codemap", "mcp", "--watch", "--root", "${workspaceFolder}"] + } + } +} From 3b0bb9318ea4167ab3807e1015f7a5f87a1934c3 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sat, 4 Jul 2026 21:34:28 +0300 Subject: [PATCH 25/77] chore(codemap): dogfood @stainless-code/codemap for agent structural queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .agents/rules/codemap.md | 25 ++ .agents/skills/codemap/SKILL.md | 18 ++ .cursor/mcp.json | 8 +- .cursor/rules/codemap.mdc | 1 + .cursor/skills/codemap/SKILL.md | 1 + bun.lock | 437 ++++++++++++++++++++++++++------ package.json | 1 + 7 files changed, 416 insertions(+), 75 deletions(-) create mode 100644 .agents/rules/codemap.md create mode 100644 .agents/skills/codemap/SKILL.md create mode 120000 .cursor/rules/codemap.mdc create mode 120000 .cursor/skills/codemap/SKILL.md diff --git a/.agents/rules/codemap.md b/.agents/rules/codemap.md new file mode 100644 index 0000000..077dc76 --- /dev/null +++ b/.agents/rules/codemap.md @@ -0,0 +1,25 @@ +--- +alwaysApply: true +--- + +<!-- codemap-init:managed --> + +# Codemap + +This project is indexed by **Codemap** — a local SQLite index of structure (symbols, imports, exports, components, dependencies, markers, scopes, references, bindings, call graphs, CSS variables, coverage). + +**Before** answering structural questions (where is X defined, who imports Y, what does Z export, list components / hooks / deprecated symbols, trace dependency or call graphs), query the index — don't grep: + +```bash +codemap query --json "<SQL>" # or `codemap query --recipe <id>` for prebuilt patterns +``` + +Full rule (today's version, served by the installed binary): + +- **CLI:** `codemap rule` +- **MCP:** read resource `codemap://rule` +- **HTTP:** `GET /resources/{encoded-uri}` against `codemap serve` + +If `codemap` prints a pointer-protocol warning on startup, re-run `codemap agents init --force` to refresh this template. + +<!-- codemap-pointer-version: 1 --> diff --git a/.agents/skills/codemap/SKILL.md b/.agents/skills/codemap/SKILL.md new file mode 100644 index 0000000..73b51b2 --- /dev/null +++ b/.agents/skills/codemap/SKILL.md @@ -0,0 +1,18 @@ +--- +name: codemap +description: Query codebase structure via SQLite instead of scanning files. Use when exploring code, finding where symbols are defined, tracing who imports what, listing components / hooks / CSS variables / deprecated symbols, walking dependency or call graphs, or auditing structural changes on a PR. +--- + +<!-- codemap-init:managed --> + +# Codemap skill + +Full content is served live by the installed `codemap` CLI, so version bumps carry today's reference automatically — no `agents init` re-run needed. + +- **CLI:** `codemap skill` → full markdown +- **MCP:** read resource `codemap://skill` +- **HTTP:** `GET /resources/{encoded-uri}` against `codemap serve` + +If `codemap` prints a pointer-protocol warning on startup, re-run `codemap agents init --force` to refresh this template. + +<!-- codemap-pointer-version: 1 --> diff --git a/.cursor/mcp.json b/.cursor/mcp.json index b58abfb..06972e9 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -2,7 +2,13 @@ "mcpServers": { "codemap": { "command": "bunx", - "args": ["codemap", "mcp", "--watch", "--root", "${workspaceFolder}"] + "args": [ + "@stainless-code/codemap@latest", + "mcp", + "--watch", + "--root", + "${workspaceFolder}" + ] } } } diff --git a/.cursor/rules/codemap.mdc b/.cursor/rules/codemap.mdc new file mode 120000 index 0000000..07d46b4 --- /dev/null +++ b/.cursor/rules/codemap.mdc @@ -0,0 +1 @@ +../../.agents/rules/codemap.md \ No newline at end of file diff --git a/.cursor/skills/codemap/SKILL.md b/.cursor/skills/codemap/SKILL.md new file mode 120000 index 0000000..9e39e2d --- /dev/null +++ b/.cursor/skills/codemap/SKILL.md @@ -0,0 +1 @@ +../../../.agents/skills/codemap/SKILL.md \ No newline at end of file diff --git a/bun.lock b/bun.lock index 18fb34c..b9a9245 100644 --- a/bun.lock +++ b/bun.lock @@ -5,10 +5,11 @@ "": { "name": "@stainless-code/persist", "devDependencies": { - "@arethetypeswrong/cli": "^0.18.4", + "@arethetypeswrong/cli": "0.18.4", "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.31.0", "@react-native-async-storage/async-storage": "^3.1.1", + "@stainless-code/codemap": "0.11.1", "@tanstack/intent": "0.3.4", "@tanstack/store": "0.11.0", "@testing-library/dom": "10.4.1", @@ -22,11 +23,11 @@ "husky": "9.1.7", "idb-keyval": "6.2.6", "jsdom": "29.1.1", - "knip": "^6.24.0", + "knip": "6.24.0", "lint-staged": "17.0.8", "oxfmt": "0.56.0", "oxlint": "1.71.0", - "publint": "^0.3.21", + "publint": "0.3.21", "react": "19.2.7", "react-dom": "19.2.7", "react-native-mmkv": "^4.3.2", @@ -257,6 +258,10 @@ "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], + "@clack/core": ["@clack/core@1.4.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw=="], + + "@clack/prompts": ["@clack/prompts@1.5.1", "", { "dependencies": { "@clack/core": "1.4.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw=="], + "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], "@csstools/color-helpers": ["@csstools/color-helpers@6.1.0", "", {}, "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg=="], @@ -343,6 +348,8 @@ "@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.23.0", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.23.0", "@shikijs/langs": "^3.23.0", "@shikijs/themes": "^3.23.0", "@shikijs/types": "^3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], "@isaacs/ttlcache": ["@isaacs/ttlcache@1.4.1", "", {}, "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA=="], @@ -369,6 +376,8 @@ "@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@changesets/types": "^4.0.1", "@manypkg/find-root": "^1.1.0", "fs-extra": "^8.1.0", "globby": "^11.0.0", "read-yaml-file": "^1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -377,85 +386,85 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.137.0", "", { "os": "android", "cpu": "arm" }, "sha512-KDs+0VPdEmasOkpuJHW9V5WCF+cvYdMQv2Jd+aJXt+cxIx12NToRQRbXaRwUEDsZw+/jMk81Ve8ZFbjUkJTOwA=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.136.0", "", { "os": "android", "cpu": "arm" }, "sha512-/ZpzhDW9dc5fNhK5HE0kY1340eD/Iorq6CN1XJxizC7tX9o8riFKjaRqccUAc2u/ieAnS7RG8jqeAUnKDMDwBg=="], - "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.137.0", "", { "os": "android", "cpu": "arm64" }, "sha512-WhALNzfy3x/RfC6bsqX+csavuUY0yHHE7XfgPE5M542uhoBZUUoGTPG+nkMbGoG4+gcfss5s7urMyn5QBHu0sw=="], + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.136.0", "", { "os": "android", "cpu": "arm64" }, "sha512-ijS3rH3YDsozxGMbAU0jFS1O5BFd0ntJjLikwvW8PYf7lw10KKPaJsxRRx6AwXN1z/hpOZxZvp2JcLixegdbqA=="], - "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.137.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFPr5hgmNMOMoyPTGtdsK4Ug21RovIPojRMgDDhSp1LtCnc/DkLwGONKjgRjszg677RlGnkYSviQ8hHaUPOVYA=="], + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.136.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-G/B5abfNFxl6WHswWL0aXmT/N/K9uHv3Rlazw8ZRkzxZyvOy/qX7z33m7Sjj2S1Djwwx/6v5Lv9YCm+YHBO+tA=="], - "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.137.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CL5dMm1asqXIDZHg14FLxj3Mc36w8PI7xCWh1uA4is6z8g2XrIILoTcQYOxDbwzuk34RDPX5IAGUxZr6LA9KAg=="], + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.136.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-J6WqlYE5d+2xB/Jkei2EKf19KIS5wCakFGL5iz2+1SbxzZTvrvsM7QfvhsE2eGtSMKFfKZRvNQ1FF1u2tjzDew=="], - "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.137.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-79h8rYGnSlKPGWo7mHr2ixO6ea7aW8B0CT965SZ8SLbNnCOH5aOYBTeVXUY6eMvEaiLyWr8Skuiugr5pDYgLGw=="], + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.136.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-fT7TZkWkkK/wkhEQnIzlyAFw2OEqfO7IBtHUI9zoLI17ZT27+YphJl6w3h2+CyboSjARKklVzUw7SRcBNtgQFg=="], - "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.137.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ASgmlSimhGyr0lksgVIo6hibz1obnDq4qJbiMX/AzltfgPnanRrzG1Q+23g8ljOHOjv6dsznkUuCYL3gg0sY1Q=="], + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.136.0", "", { "os": "linux", "cpu": "arm" }, "sha512-KEPfcr6fOF9W3ymMSeh3cv6XWatTOLOcjzAPSxLT8zx/uvFBUagKHCuapVbRT8v96wvOaorjSeuVR+tdEPkU6g=="], - "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.137.0", "", { "os": "linux", "cpu": "arm" }, "sha512-AU2J9aa22Sx32wRGnDjybOU9TQXXQUud5sdUi+ZB0XxwM8aToWLweV+yA0wlQm0yIUVqljquqoHCYEq9II8gJQ=="], + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.136.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ywdKiqEJ38xhYeAZaJ5LKGEsDulyeiJGUxEETEUnzHGcxqZIj70IJl/mFPhQkILZdYT8hs7HzzU9aQtJnau9xw=="], - "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.137.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-GdEtiG89yMr7XkUGxifgodXEEm2f+xW2f9CpDjlgAnBOwhTmrpQMvhOGobLVKUyzf/qHBXW16smk5zbF3nZU6w=="], + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.136.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-N9bBGX7nECdwf68sgJFvw/5EkjL0rU5dC6diON+/hue8TZ7oOWy6FEhsL3h/u6nmRUTbkbH2Z26pAQBMDYNKyg=="], - "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.137.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EGJ+Bs8iXx8KBH8DQ5BLoEm5lnHaYjlh4/8j8vFhrr/6z4tqONy5BZDzLpKmmNWlN6Hlc5r8YOuBVHqZ9vRFEQ=="], + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.136.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WGWNXomIxf/TPvStv6MOGjqKpLha0KUTpjOEFqILD/M8YuCvABWnuXAIoE5Vgxul6/xzlsKYg+LYdx7m07/jXg=="], - "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.137.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-vzFUQENy/fnbSe5DZWovq6tIBc1uhuMztanSW6rz1e9WdQE4gHwYuD7ZII6JnrJifd1R3RSoqiZbgRFlVL2tYQ=="], + "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.136.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-X1w/nk3UNphji7kmr93doIrA4EauW8bv4gktYWT5NyRjQ24KqBY3sJsPeekGnGxrH2rHfPkUOHez96XGZwnXBQ=="], - "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.137.0", "", { "os": "linux", "cpu": "none" }, "sha512-SfVI14HBQs9gtLcUD5hTt5hsNbdrqSUNg9S8muN+LhVQ5nf1WwH3hAoK6B9NKgdYgWAQSXFXGiiBedQ4r/BKuw=="], + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.136.0", "", { "os": "linux", "cpu": "none" }, "sha512-RGEEU6fUJwIbBNqXBlUGlDdi1zNSHAmoYMRwFWa3avEByfREU9jRVqBQvdQxMyR6JMpm6DJ5ozeKx304m1AoqA=="], - "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.137.0", "", { "os": "linux", "cpu": "none" }, "sha512-e7Ppy4FCIFNQxT/ikSeIWFoQ0l+N9vgtRBtLcyZXeolTzApyVoPqEXsYPrcdM/9i0Bwk8knvYd37vaEMxHyi6g=="], + "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.136.0", "", { "os": "linux", "cpu": "none" }, "sha512-WrO3nMKgUngVc9AZmpNpfnVDfNwZEjgJ8nx7G42fqRVmHF3ECuAlmZ4RHgc12SNWarwwwjV3QR8QSQ7ejCiMjA=="], - "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.137.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bho5qFwdhqsIFR7gipYEUlqvi3SRrY8sugxXig380MIaakBB1PyU9+7dBiBVScfImTNWhijUxdBwqrprGdq5WA=="], + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.136.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-0IqzbprlPLXcBPXmpQj0nSSw1ZeAj+6x7wdPOHxrsn4Ukd0X7Ag64hdMz8Mll/pLeiISJH4EaHL7lfCevV6o2A=="], - "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.137.0", "", { "os": "linux", "cpu": "x64" }, "sha512-36mGWtg7PyFzjJwGDkH6/F4o2nIDEoKXLPr/X/lwqklkomQwJJt1I5GJVmGhovUEmgPK5WAeAZMqlFCehwiy9Q=="], + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.136.0", "", { "os": "linux", "cpu": "x64" }, "sha512-SRRFFxVFz2xXegKVFATuw2Hjqg1urM6P92WHJuztf1HNJf+TiVthaXfxfrBzbNzeF8jAIhHofPJmfAUMBN9DWQ=="], - "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.137.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/Jqx6+N7A44n2BdvUr7pXhVr2vFjs6WGH3unZRczwrfiH0H1zY0QwKQMG/dtRiTlKGDKGukznPT8lx84/oEsZg=="], + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.136.0", "", { "os": "linux", "cpu": "x64" }, "sha512-b1NjVGClFSd1ThRU9mvtM3pUXw42gfJraJmh3wLkvBk7xcWVL3HmibpgcVj4d+AttCtgUtQZRaiJYDqkBlOS2A=="], - "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.137.0", "", { "os": "none", "cpu": "arm64" }, "sha512-9Uj0qHNNl+OgT1UTGwF7ixIXU6T1u2SbMidmgPy/h1h/fl2gRS6YpAxxY1gwHofcWjoTwkoMFd8xs5Vuj6GOFA=="], + "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.136.0", "", { "os": "none", "cpu": "arm64" }, "sha512-wd66GmRagy3W1633ezZWNkTWyJV26lbVWpTj1x/WVjZ/vz7iEyjKl2Emja1dh/Ro3/OQpuWCaYSuazlcHmeUlg=="], - "@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.137.0", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.5" }, "cpu": "none" }, "sha512-gW2vfkytNGgMVADiuzdvOfw0mWG9za20F/1fCJsif5aBMAvWJTSbpIXbIe0XkOe0VENk+PadpQ7cZgUy2sUJcA=="], + "@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.136.0", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.5" }, "cpu": "none" }, "sha512-WdGagp+TtF2BRywdfg3UWMGdFtpTlFVYv6GCnArZtyllaVEXmI6O/Rk1XEJA3lDCL1kShFh2jtaXu5MDnUUW5Q=="], - "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.137.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-x+pFANF0yL5uK/6T7lu6SlR5qid6sp//eZXKLq5iNsIE+EQg6EaS8/wsW7E91nXXjpnPhSoMOHXShSVhGRdn8w=="], + "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.136.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-L3NaDvdbLtISDHlxwoVbBa1KEYpiWMIyr8vViVZREtnz4ASsgWu1Uha+FDZg4gjxZiy+sjlXth+f9Xuqjk0jig=="], - "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.137.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-sQUqym80PFi6McRsIqfJrSu2JrSClEZIXXD+/FjAFoULEKzOPsldIdFBG96xdX8aVMzCNQ9792FPx3MfkEIrFA=="], + "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.136.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-ej++rp+ea9mvBeC1iH6Z7He5efztnwmnl7rq411A7Ya5lJQKAUsPpI8HzHlZZz/9YCSRCiHBlgK+nHkEJFihPw=="], - "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.137.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2AsevxlvNN4WKxpEn3RtqD5zbqMaXF+T7JXblsP4gVuY+vC9dXS4ED/PwfRCliFqoeisYS3Iro4DHzxr0TEvVA=="], + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.136.0", "", { "os": "win32", "cpu": "x64" }, "sha512-YZucBNVBuoWpGEBJEqFTsQMaIIEaIZUPgxJxQJzFwamg9M8xo3Bw4vDDxDccwv34i9rcaKdoEGdeYye/ljHMDw=="], - "@oxc-project/types": ["@oxc-project/types@0.137.0", "", {}, "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA=="], + "@oxc-project/types": ["@oxc-project/types@0.136.0", "", {}, "sha512-39Al/B3v9esnHCX7S8l9Se2+s2tb9b2jcMd+bZ2L659VG73kNyGPpPrL5Zi/p0ty7p4pTTU2/Dd+g27hv94XCg=="], - "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.21.3", "", { "os": "android", "cpu": "arm" }, "sha512-eNU11A2WNizh04v3uyaJCootrHIaS0B9aHYXvAvVnPNk4xYSjMUjHnhQ6dewPN2MRYDskV85d1N0Aw0WNWhcyg=="], + "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.20.0", "", { "os": "android", "cpu": "arm" }, "sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg=="], - "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.21.3", "", { "os": "android", "cpu": "arm64" }, "sha512-8Q+ZjTLvn2dIcWsrmhdrEihm7q+ag/k+mkry7Z+t0QbbHaVxXQfvH9AewyVMh/WrpEKhQ3DDgx9fYbqeCpeOEw=="], + "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.20.0", "", { "os": "android", "cpu": "arm64" }, "sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q=="], - "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.21.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wkh0qKZGHXVUDxFw3oA1TXnU2BDYY/r775oJflGeIr8uDPPoN2pk8gijQIzYRT6hoql/lg3+Tx/SaTn9e2/aGg=="], + "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.20.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ=="], - "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.21.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-HbNc23FAQYbuyDV2vBWMez4u4mrsm5RAkniGZAWqr6lYZ3N4beeqIb776jzwRl8qL2zRhHVXpUj97X0QgogVzg=="], + "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.20.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg=="], - "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.21.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-K6xNsTUPEUdfrn0+kbMq5nOUB5w1C5pavPQngt4TM2FpN91lP0PBe2srSpamb4d69O7h86oAi/qWX/kZNRSjkw=="], + "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.20.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ=="], - "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.21.3", "", { "os": "linux", "cpu": "arm" }, "sha512-VcFmOpcpWX1zoEy8M58tR2M9YxM+Z9RuQhqAx5q0CTmrruaP7Gveejg75hzd/5sg5nk9G3aLALEa3hE2FsmmTQ=="], + "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg=="], - "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.21.3", "", { "os": "linux", "cpu": "arm" }, "sha512-quVoxFLBy43hWaQbbDtQNRwAX5vX76mv7n64icAtQcJ3eNgVeblqmkupF/hAneNthdqSlnd1sTjb3aQSaDPaCQ=="], + "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.20.0", "", { "os": "linux", "cpu": "arm" }, "sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg=="], - "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.21.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ=="], + "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.20.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg=="], - "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.21.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw=="], + "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.20.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw=="], - "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.21.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A=="], + "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.20.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ=="], - "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.21.3", "", { "os": "linux", "cpu": "none" }, "sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og=="], + "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.20.0", "", { "os": "linux", "cpu": "none" }, "sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw=="], - "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.21.3", "", { "os": "linux", "cpu": "none" }, "sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw=="], + "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.20.0", "", { "os": "linux", "cpu": "none" }, "sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg=="], - "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.21.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q=="], + "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.20.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g=="], - "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.21.3", "", { "os": "linux", "cpu": "x64" }, "sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q=="], + "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.20.0", "", { "os": "linux", "cpu": "x64" }, "sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g=="], - "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.21.3", "", { "os": "linux", "cpu": "x64" }, "sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA=="], + "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.20.0", "", { "os": "linux", "cpu": "x64" }, "sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ=="], - "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.21.3", "", { "os": "none", "cpu": "arm64" }, "sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg=="], + "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.20.0", "", { "os": "none", "cpu": "arm64" }, "sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ=="], - "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.21.3", "", { "dependencies": { "@emnapi/core": "1.11.0", "@emnapi/runtime": "1.11.0", "@napi-rs/wasm-runtime": "^1.1.5" }, "cpu": "none" }, "sha512-i8oluoel5kru/j1WNrjmQSiA3GQ7wvIYVR1IwIoZtKogAhya2iub+ZKIeSIkcJOrnzQ18Tzl/F+kL3fYOxZLvA=="], + "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.20.0", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg=="], - "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.21.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-M/8dw8dD6aOs+NlPJax401CZB9I7Aut84isQLgALGGwke4Afvw+/7yYhZb94yXf6t2sPLhQLmSmtSV+2FhsOWg=="], + "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.20.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA=="], - "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.21.3", "", { "os": "win32", "cpu": "x64" }, "sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q=="], + "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.20.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-CSCxi7ovYojgfdPOdUb9T508HKeAdDIKeRGg7x8IZwVJrWz9gVgX7MbUnFqtQAE4QvoNo07mj2JlwnOzJw4qqA=="], @@ -607,6 +616,8 @@ "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], + "@stainless-code/codemap": ["@stainless-code/codemap@0.11.1", "", { "dependencies": { "@clack/prompts": "1.5.1", "@modelcontextprotocol/sdk": "1.29.0", "better-sqlite3": "12.11.1", "chokidar": "5.0.0", "lightningcss": "1.32.0", "oxc-parser": "0.136.0", "oxc-resolver": "11.20.0", "package-manager-detector": "1.6.0", "tinyglobby": "0.2.17", "zod": "4.4.3" }, "bin": { "codemap": "dist/index.mjs" } }, "sha512-SZ5rnjq7P1m10hC40fET8Crr2vojwJSzOvNE+pJXhBY7D37U+Lb+ry1a9i7lLfOdgd1LEOkrDohbAzoWVxT03g=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="], @@ -709,7 +720,7 @@ "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], - "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], @@ -717,6 +728,10 @@ "agent-cli-detector": ["agent-cli-detector@0.1.2", "", { "bin": { "agent-cli-detector": "dist/cli.js" } }, "sha512-qdZ/9JFORtTKJNhT/IczMeEfEUbUU0K5umYeiIQHX+AjHs+Y9SXVzSgaYlpZeyNMrvuh2HpZiOTpvS57iPfBkQ=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "anser": ["anser@1.4.10", "", {}, "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww=="], "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], @@ -771,12 +786,20 @@ "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], + "better-sqlite3": ["better-sqlite3@12.11.1", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA=="], + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + "bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="], "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], @@ -789,6 +812,8 @@ "bser": ["bser@2.1.1", "", { "dependencies": { "node-int64": "^0.4.0" } }, "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ=="], + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], @@ -797,6 +822,10 @@ "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], "caniuse-lite": ["caniuse-lite@1.0.30001800", "", {}, "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA=="], @@ -809,6 +838,10 @@ "chardet": ["chardet@2.2.0", "", {}, "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA=="], + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + "chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="], "chromium-edge-launcher": ["chromium-edge-launcher@0.3.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^1.0.4" } }, "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA=="], @@ -845,10 +878,20 @@ "connect": ["connect@3.7.0", "", { "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", "parseurl": "~1.3.3", "utils-merge": "1.0.1" } }, "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "core-js-compat": ["core-js-compat@3.49.0", "", { "dependencies": { "browserslist": "^4.28.1" } }, "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], @@ -863,6 +906,10 @@ "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], @@ -891,6 +938,8 @@ "dts-resolver": ["dts-resolver@3.0.0", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "electron-to-chromium": ["electron-to-chromium@1.5.387", "", {}, "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ=="], @@ -903,6 +952,8 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], @@ -911,10 +962,14 @@ "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], "es-module-lexer": ["es-module-lexer@2.3.0", "", {}, "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw=="], + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], @@ -935,6 +990,12 @@ "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], "expo": ["expo@57.0.2", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "^57.0.4", "@expo/config": "~57.0.2", "@expo/config-plugins": "~57.0.2", "@expo/devtools": "~57.0.0", "@expo/dom-webview": "~57.0.0", "@expo/fingerprint": "^0.20.2", "@expo/local-build-cache-provider": "^57.0.2", "@expo/log-box": "^57.0.0", "@expo/metro": "~56.0.0", "@expo/metro-config": "~57.0.3", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~57.0.1", "expo-asset": "~57.0.3", "expo-constants": "~57.0.3", "expo-file-system": "~57.0.0", "expo-font": "~57.0.0", "expo-keep-awake": "~57.0.0", "expo-modules-autolinking": "~57.0.4", "expo-modules-core": "~57.0.2", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" }, "peerDependencies": { "@expo/metro-runtime": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-web": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/metro-runtime", "react-dom", "react-native-web", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-QmyNQJNFJb/I6bQYpxl39jqyhCSlFXtiwBCyCFl3a7a18NZ8pHsVHTvLdRIXFI/bNXdCm/g7JMXoJB4eFKLBmg=="], @@ -961,10 +1022,24 @@ "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + "extendable-error": ["extendable-error@0.1.7", "", {}, "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + + "fast-uri": ["fast-uri@3.1.3", "", {}, "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg=="], + + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fb-dotslash": ["fb-dotslash@0.5.8", "", { "bin": { "dotslash": "bin/dotslash" } }, "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA=="], @@ -979,9 +1054,11 @@ "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - "finalhandler": ["finalhandler@1.1.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "on-finished": "~2.3.0", "parseurl": "~1.3.3", "statuses": "~1.5.0", "unpipe": "~1.0.0" } }, "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], @@ -991,7 +1068,11 @@ "formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="], - "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], "fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], @@ -1005,20 +1086,30 @@ "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], "getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "hermes-compiler": ["hermes-compiler@250829098.0.14", "", {}, "sha512-5meXwsZxgiqFaJjNzwjzI9IyUkuGGBisu+z9BvQWmGVpjH6nz11hgqkyxe4dl8UAdyIV4lTbz91+Dlnjz0VxqA=="], @@ -1029,6 +1120,8 @@ "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], + "hono": ["hono@4.12.27", "", {}, "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q=="], + "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], "hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], @@ -1049,6 +1142,8 @@ "idb-keyval": ["idb-keyval@6.2.6", "", {}, "sha512-FY64UEhw+5liMzMQ1R9Mw6AF0+wyBrg1CIA1z4CjI/EvT5ty/SvQcWZgd8s9sgaNhX10Y8UzScTh89tEAls5nA=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], @@ -1057,8 +1152,14 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], @@ -1073,6 +1174,8 @@ "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], "is-subdir": ["is-subdir@1.2.0", "", { "dependencies": { "better-path-resolve": "1.0.0" } }, "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw=="], @@ -1095,6 +1198,8 @@ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], @@ -1105,6 +1210,10 @@ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], @@ -1185,12 +1294,18 @@ "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="], + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], @@ -1235,12 +1350,18 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -1251,7 +1372,11 @@ "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], - "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "node-abi": ["node-abi@3.94.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g=="], "node-emoji": ["node-emoji@2.2.0", "", { "dependencies": { "@sindresorhus/is": "^4.6.0", "char-regex": "^1.0.2", "emojilib": "^2.4.0", "skin-tone": "^2.0.0" } }, "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw=="], @@ -1271,12 +1396,16 @@ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], @@ -1285,9 +1414,9 @@ "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], - "oxc-parser": ["oxc-parser@0.137.0", "", { "dependencies": { "@oxc-project/types": "^0.137.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.137.0", "@oxc-parser/binding-android-arm64": "0.137.0", "@oxc-parser/binding-darwin-arm64": "0.137.0", "@oxc-parser/binding-darwin-x64": "0.137.0", "@oxc-parser/binding-freebsd-x64": "0.137.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.137.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.137.0", "@oxc-parser/binding-linux-arm64-gnu": "0.137.0", "@oxc-parser/binding-linux-arm64-musl": "0.137.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.137.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.137.0", "@oxc-parser/binding-linux-riscv64-musl": "0.137.0", "@oxc-parser/binding-linux-s390x-gnu": "0.137.0", "@oxc-parser/binding-linux-x64-gnu": "0.137.0", "@oxc-parser/binding-linux-x64-musl": "0.137.0", "@oxc-parser/binding-openharmony-arm64": "0.137.0", "@oxc-parser/binding-wasm32-wasi": "0.137.0", "@oxc-parser/binding-win32-arm64-msvc": "0.137.0", "@oxc-parser/binding-win32-ia32-msvc": "0.137.0", "@oxc-parser/binding-win32-x64-msvc": "0.137.0" } }, "sha512-yFImD+WLElJpLKy8llG1qe4DCmMsL18peRp8XP1JKfig/gISbJkglnpDtX2aTmAn10kZF7164HbN2H8QPsXxGg=="], + "oxc-parser": ["oxc-parser@0.136.0", "", { "dependencies": { "@oxc-project/types": "^0.136.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.136.0", "@oxc-parser/binding-android-arm64": "0.136.0", "@oxc-parser/binding-darwin-arm64": "0.136.0", "@oxc-parser/binding-darwin-x64": "0.136.0", "@oxc-parser/binding-freebsd-x64": "0.136.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.136.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.136.0", "@oxc-parser/binding-linux-arm64-gnu": "0.136.0", "@oxc-parser/binding-linux-arm64-musl": "0.136.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.136.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.136.0", "@oxc-parser/binding-linux-riscv64-musl": "0.136.0", "@oxc-parser/binding-linux-s390x-gnu": "0.136.0", "@oxc-parser/binding-linux-x64-gnu": "0.136.0", "@oxc-parser/binding-linux-x64-musl": "0.136.0", "@oxc-parser/binding-openharmony-arm64": "0.136.0", "@oxc-parser/binding-wasm32-wasi": "0.136.0", "@oxc-parser/binding-win32-arm64-msvc": "0.136.0", "@oxc-parser/binding-win32-ia32-msvc": "0.136.0", "@oxc-parser/binding-win32-x64-msvc": "0.136.0" } }, "sha512-ElnU+WQBWrosTiF58ALoYYhUUhKomHBDm5jkI2PUiY+hJnOK5Yh1jyLOHOHJpWapeI8tXW3cd4uotp6EgrlBeA=="], - "oxc-resolver": ["oxc-resolver@11.21.3", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.21.3", "@oxc-resolver/binding-android-arm64": "11.21.3", "@oxc-resolver/binding-darwin-arm64": "11.21.3", "@oxc-resolver/binding-darwin-x64": "11.21.3", "@oxc-resolver/binding-freebsd-x64": "11.21.3", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.3", "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.3", "@oxc-resolver/binding-linux-arm64-gnu": "11.21.3", "@oxc-resolver/binding-linux-arm64-musl": "11.21.3", "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.3", "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.3", "@oxc-resolver/binding-linux-riscv64-musl": "11.21.3", "@oxc-resolver/binding-linux-s390x-gnu": "11.21.3", "@oxc-resolver/binding-linux-x64-gnu": "11.21.3", "@oxc-resolver/binding-linux-x64-musl": "11.21.3", "@oxc-resolver/binding-openharmony-arm64": "11.21.3", "@oxc-resolver/binding-wasm32-wasi": "11.21.3", "@oxc-resolver/binding-win32-arm64-msvc": "11.21.3", "@oxc-resolver/binding-win32-x64-msvc": "11.21.3" } }, "sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA=="], + "oxc-resolver": ["oxc-resolver@11.20.0", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.20.0", "@oxc-resolver/binding-android-arm64": "11.20.0", "@oxc-resolver/binding-darwin-arm64": "11.20.0", "@oxc-resolver/binding-darwin-x64": "11.20.0", "@oxc-resolver/binding-freebsd-x64": "11.20.0", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.20.0", "@oxc-resolver/binding-linux-arm-musleabihf": "11.20.0", "@oxc-resolver/binding-linux-arm64-gnu": "11.20.0", "@oxc-resolver/binding-linux-arm64-musl": "11.20.0", "@oxc-resolver/binding-linux-ppc64-gnu": "11.20.0", "@oxc-resolver/binding-linux-riscv64-gnu": "11.20.0", "@oxc-resolver/binding-linux-riscv64-musl": "11.20.0", "@oxc-resolver/binding-linux-s390x-gnu": "11.20.0", "@oxc-resolver/binding-linux-x64-gnu": "11.20.0", "@oxc-resolver/binding-linux-x64-musl": "11.20.0", "@oxc-resolver/binding-openharmony-arm64": "11.20.0", "@oxc-resolver/binding-wasm32-wasi": "11.20.0", "@oxc-resolver/binding-win32-arm64-msvc": "11.20.0", "@oxc-resolver/binding-win32-x64-msvc": "11.20.0" } }, "sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g=="], "oxfmt": ["oxfmt@0.56.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.56.0", "@oxfmt/binding-android-arm64": "0.56.0", "@oxfmt/binding-darwin-arm64": "0.56.0", "@oxfmt/binding-darwin-x64": "0.56.0", "@oxfmt/binding-freebsd-x64": "0.56.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.56.0", "@oxfmt/binding-linux-arm-musleabihf": "0.56.0", "@oxfmt/binding-linux-arm64-gnu": "0.56.0", "@oxfmt/binding-linux-arm64-musl": "0.56.0", "@oxfmt/binding-linux-ppc64-gnu": "0.56.0", "@oxfmt/binding-linux-riscv64-gnu": "0.56.0", "@oxfmt/binding-linux-riscv64-musl": "0.56.0", "@oxfmt/binding-linux-s390x-gnu": "0.56.0", "@oxfmt/binding-linux-x64-gnu": "0.56.0", "@oxfmt/binding-linux-x64-musl": "0.56.0", "@oxfmt/binding-openharmony-arm64": "0.56.0", "@oxfmt/binding-win32-arm64-msvc": "0.56.0", "@oxfmt/binding-win32-ia32-msvc": "0.56.0", "@oxfmt/binding-win32-x64-msvc": "0.56.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-9Dv0wV3zKiyvhjD7bRKaInKmHQ1sPx3RGOjQkGFJbbdQ16576yf8qhMSO9Q9cvHcs+1NpBsRTkuDDYFFPTJ6gw=="], @@ -1321,6 +1450,8 @@ "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -1331,12 +1462,16 @@ "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "plist": ["plist@3.1.1", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA=="], "pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], @@ -1349,12 +1484,18 @@ "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "publint": ["publint@0.3.21", "", { "dependencies": { "@publint/pack": "^0.1.4", "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "sade": "^1.8.1" }, "bin": { "publint": "src/cli.js" } }, "sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], "queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="], @@ -1363,6 +1504,10 @@ "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="], @@ -1381,6 +1526,10 @@ "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="], @@ -1415,6 +1564,8 @@ "rolldown-plugin-dts": ["rolldown-plugin-dts@0.26.0", "", { "dependencies": { "@babel/generator": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0", "@babel/parser": "^8.0.0", "ast-kit": "^3.0.0", "birpc": "^4.0.0", "dts-resolver": "^3.0.0", "get-tsconfig": "5.0.0-beta.5", "obug": "^2.1.3" }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", "@typescript/native-preview": ">=7.0.0-dev.20260325.1", "rolldown": "^1.0.0", "typescript": "^5.0.0 || ^6.0.0", "vue-tsc": "~3.2.0 || ~3.3.0" }, "optionalPeers": ["@ts-macro/tsc", "@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-e+kEPtUiDES0htk5iqkSeF4EzAV7R+vugGB44iPDuw1Kw9E+WyL1VG7PaV0IIjGHLiacztMBcMTyrr8ON9CT1Q=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], @@ -1431,7 +1582,7 @@ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "serialize-error": ["serialize-error@2.1.0", "", {}, "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw=="], @@ -1439,7 +1590,7 @@ "seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="], - "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], @@ -1449,10 +1600,22 @@ "shell-quote": ["shell-quote@1.9.0", "", {}, "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA=="], + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "simple-plist": ["simple-plist@1.3.1", "", { "dependencies": { "bplist-creator": "0.1.0", "bplist-parser": "0.3.1", "plist": "^3.0.5" } }, "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], @@ -1495,6 +1658,8 @@ "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], @@ -1513,6 +1678,10 @@ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "tar-fs": ["tar-fs@2.1.5", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + "term-size": ["term-size@2.2.1", "", {}, "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg=="], "terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="], @@ -1557,8 +1726,12 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + "type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="], + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + "typedoc": ["typedoc@0.28.19", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.1.1", "minimatch": "^10.2.5", "yaml": "^2.8.3" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], @@ -1589,6 +1762,8 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], "uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="], @@ -1629,6 +1804,8 @@ "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], "xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="], @@ -1655,6 +1832,8 @@ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@arethetypeswrong/core/typescript": ["typescript@5.6.1-rc", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ=="], "@babel/core/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], @@ -1683,8 +1862,12 @@ "@babel/traverse/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@expo/cli/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + "@expo/cli/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "@expo/cli/send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], + "@expo/cli/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "@expo/cli/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], @@ -1705,14 +1888,18 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], - "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "@quansync/fs/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "@react-native/codegen/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@react-native/dev-middleware/serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], + + "@stainless-code/codemap/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + "@vue/compiler-core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], @@ -1723,12 +1910,12 @@ "@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "babel-preset-expo/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "cli-highlight/parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], "cli-highlight/yargs": ["yargs@16.2.2", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w=="], @@ -1745,18 +1932,12 @@ "connect/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "connect/finalhandler": ["finalhandler@1.1.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "on-finished": "~2.3.0", "parseurl": "~1.3.3", "statuses": "~1.5.0", "unpipe": "~1.0.0" } }, "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA=="], + "expo/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], "expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], - "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - - "finalhandler/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], - - "finalhandler/on-finished": ["on-finished@2.3.0", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww=="], - - "finalhandler/statuses": ["statuses@1.5.0", "", {}, "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA=="], - "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -1765,6 +1946,10 @@ "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "knip/oxc-parser": ["oxc-parser@0.137.0", "", { "dependencies": { "@oxc-project/types": "^0.137.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.137.0", "@oxc-parser/binding-android-arm64": "0.137.0", "@oxc-parser/binding-darwin-arm64": "0.137.0", "@oxc-parser/binding-darwin-x64": "0.137.0", "@oxc-parser/binding-freebsd-x64": "0.137.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.137.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.137.0", "@oxc-parser/binding-linux-arm64-gnu": "0.137.0", "@oxc-parser/binding-linux-arm64-musl": "0.137.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.137.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.137.0", "@oxc-parser/binding-linux-riscv64-musl": "0.137.0", "@oxc-parser/binding-linux-s390x-gnu": "0.137.0", "@oxc-parser/binding-linux-x64-gnu": "0.137.0", "@oxc-parser/binding-linux-x64-musl": "0.137.0", "@oxc-parser/binding-openharmony-arm64": "0.137.0", "@oxc-parser/binding-wasm32-wasi": "0.137.0", "@oxc-parser/binding-win32-arm64-msvc": "0.137.0", "@oxc-parser/binding-win32-ia32-msvc": "0.137.0", "@oxc-parser/binding-win32-x64-msvc": "0.137.0" } }, "sha512-yFImD+WLElJpLKy8llG1qe4DCmMsL18peRp8XP1JKfig/gISbJkglnpDtX2aTmAn10kZF7164HbN2H8QPsXxGg=="], + + "knip/oxc-resolver": ["oxc-resolver@11.21.3", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.21.3", "@oxc-resolver/binding-android-arm64": "11.21.3", "@oxc-resolver/binding-darwin-arm64": "11.21.3", "@oxc-resolver/binding-darwin-x64": "11.21.3", "@oxc-resolver/binding-freebsd-x64": "11.21.3", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.3", "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.3", "@oxc-resolver/binding-linux-arm64-gnu": "11.21.3", "@oxc-resolver/binding-linux-arm64-musl": "11.21.3", "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.3", "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.3", "@oxc-resolver/binding-linux-riscv64-musl": "11.21.3", "@oxc-resolver/binding-linux-s390x-gnu": "11.21.3", "@oxc-resolver/binding-linux-x64-gnu": "11.21.3", "@oxc-resolver/binding-linux-x64-musl": "11.21.3", "@oxc-resolver/binding-openharmony-arm64": "11.21.3", "@oxc-resolver/binding-wasm32-wasi": "11.21.3", "@oxc-resolver/binding-win32-arm64-msvc": "11.21.3", "@oxc-resolver/binding-win32-x64-msvc": "11.21.3" } }, "sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA=="], + "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "log-symbols/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], @@ -1783,8 +1968,6 @@ "metro/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "metro/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - "metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], "metro/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="], @@ -1817,6 +2000,8 @@ "publint/package-manager-detector": ["package-manager-detector@1.7.0", "", {}, "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ=="], + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "react-native/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], @@ -1831,8 +2016,6 @@ "rolldown-plugin-dts/get-tsconfig": ["get-tsconfig@5.0.0-beta.5", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ=="], - "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -1853,6 +2036,8 @@ "tsdown/cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "unconfig-core/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -1871,13 +2056,23 @@ "@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], + "@expo/cli/accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "@expo/cli/accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + "@expo/cli/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "@expo/cli/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "@expo/cli/send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "@expo/cli/send/fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + "@expo/metro-config/@babel/generator/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@react-native/dev-middleware/serve-static/send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], "babel-preset-expo/@babel/generator/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], @@ -1891,16 +2086,100 @@ "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "connect/finalhandler/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], + + "connect/finalhandler/on-finished": ["on-finished@2.3.0", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww=="], + + "connect/finalhandler/statuses": ["statuses@1.5.0", "", {}, "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA=="], + "expo/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "expo/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], - "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "jest-validate/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "jest-validate/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "knip/oxc-parser/@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.137.0", "", { "os": "android", "cpu": "arm" }, "sha512-KDs+0VPdEmasOkpuJHW9V5WCF+cvYdMQv2Jd+aJXt+cxIx12NToRQRbXaRwUEDsZw+/jMk81Ve8ZFbjUkJTOwA=="], + + "knip/oxc-parser/@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.137.0", "", { "os": "android", "cpu": "arm64" }, "sha512-WhALNzfy3x/RfC6bsqX+csavuUY0yHHE7XfgPE5M542uhoBZUUoGTPG+nkMbGoG4+gcfss5s7urMyn5QBHu0sw=="], + + "knip/oxc-parser/@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.137.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFPr5hgmNMOMoyPTGtdsK4Ug21RovIPojRMgDDhSp1LtCnc/DkLwGONKjgRjszg677RlGnkYSviQ8hHaUPOVYA=="], + + "knip/oxc-parser/@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.137.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CL5dMm1asqXIDZHg14FLxj3Mc36w8PI7xCWh1uA4is6z8g2XrIILoTcQYOxDbwzuk34RDPX5IAGUxZr6LA9KAg=="], + + "knip/oxc-parser/@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.137.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-79h8rYGnSlKPGWo7mHr2ixO6ea7aW8B0CT965SZ8SLbNnCOH5aOYBTeVXUY6eMvEaiLyWr8Skuiugr5pDYgLGw=="], + + "knip/oxc-parser/@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.137.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ASgmlSimhGyr0lksgVIo6hibz1obnDq4qJbiMX/AzltfgPnanRrzG1Q+23g8ljOHOjv6dsznkUuCYL3gg0sY1Q=="], + + "knip/oxc-parser/@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.137.0", "", { "os": "linux", "cpu": "arm" }, "sha512-AU2J9aa22Sx32wRGnDjybOU9TQXXQUud5sdUi+ZB0XxwM8aToWLweV+yA0wlQm0yIUVqljquqoHCYEq9II8gJQ=="], + + "knip/oxc-parser/@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.137.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-GdEtiG89yMr7XkUGxifgodXEEm2f+xW2f9CpDjlgAnBOwhTmrpQMvhOGobLVKUyzf/qHBXW16smk5zbF3nZU6w=="], + + "knip/oxc-parser/@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.137.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EGJ+Bs8iXx8KBH8DQ5BLoEm5lnHaYjlh4/8j8vFhrr/6z4tqONy5BZDzLpKmmNWlN6Hlc5r8YOuBVHqZ9vRFEQ=="], + + "knip/oxc-parser/@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.137.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-vzFUQENy/fnbSe5DZWovq6tIBc1uhuMztanSW6rz1e9WdQE4gHwYuD7ZII6JnrJifd1R3RSoqiZbgRFlVL2tYQ=="], + + "knip/oxc-parser/@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.137.0", "", { "os": "linux", "cpu": "none" }, "sha512-SfVI14HBQs9gtLcUD5hTt5hsNbdrqSUNg9S8muN+LhVQ5nf1WwH3hAoK6B9NKgdYgWAQSXFXGiiBedQ4r/BKuw=="], + + "knip/oxc-parser/@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.137.0", "", { "os": "linux", "cpu": "none" }, "sha512-e7Ppy4FCIFNQxT/ikSeIWFoQ0l+N9vgtRBtLcyZXeolTzApyVoPqEXsYPrcdM/9i0Bwk8knvYd37vaEMxHyi6g=="], + + "knip/oxc-parser/@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.137.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bho5qFwdhqsIFR7gipYEUlqvi3SRrY8sugxXig380MIaakBB1PyU9+7dBiBVScfImTNWhijUxdBwqrprGdq5WA=="], + + "knip/oxc-parser/@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.137.0", "", { "os": "linux", "cpu": "x64" }, "sha512-36mGWtg7PyFzjJwGDkH6/F4o2nIDEoKXLPr/X/lwqklkomQwJJt1I5GJVmGhovUEmgPK5WAeAZMqlFCehwiy9Q=="], + + "knip/oxc-parser/@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.137.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/Jqx6+N7A44n2BdvUr7pXhVr2vFjs6WGH3unZRczwrfiH0H1zY0QwKQMG/dtRiTlKGDKGukznPT8lx84/oEsZg=="], + + "knip/oxc-parser/@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.137.0", "", { "os": "none", "cpu": "arm64" }, "sha512-9Uj0qHNNl+OgT1UTGwF7ixIXU6T1u2SbMidmgPy/h1h/fl2gRS6YpAxxY1gwHofcWjoTwkoMFd8xs5Vuj6GOFA=="], + + "knip/oxc-parser/@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.137.0", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.5" }, "cpu": "none" }, "sha512-gW2vfkytNGgMVADiuzdvOfw0mWG9za20F/1fCJsif5aBMAvWJTSbpIXbIe0XkOe0VENk+PadpQ7cZgUy2sUJcA=="], + + "knip/oxc-parser/@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.137.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-x+pFANF0yL5uK/6T7lu6SlR5qid6sp//eZXKLq5iNsIE+EQg6EaS8/wsW7E91nXXjpnPhSoMOHXShSVhGRdn8w=="], + + "knip/oxc-parser/@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.137.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-sQUqym80PFi6McRsIqfJrSu2JrSClEZIXXD+/FjAFoULEKzOPsldIdFBG96xdX8aVMzCNQ9792FPx3MfkEIrFA=="], + + "knip/oxc-parser/@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.137.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2AsevxlvNN4WKxpEn3RtqD5zbqMaXF+T7JXblsP4gVuY+vC9dXS4ED/PwfRCliFqoeisYS3Iro4DHzxr0TEvVA=="], + + "knip/oxc-parser/@oxc-project/types": ["@oxc-project/types@0.137.0", "", {}, "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA=="], + + "knip/oxc-resolver/@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.21.3", "", { "os": "android", "cpu": "arm" }, "sha512-eNU11A2WNizh04v3uyaJCootrHIaS0B9aHYXvAvVnPNk4xYSjMUjHnhQ6dewPN2MRYDskV85d1N0Aw0WNWhcyg=="], + + "knip/oxc-resolver/@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.21.3", "", { "os": "android", "cpu": "arm64" }, "sha512-8Q+ZjTLvn2dIcWsrmhdrEihm7q+ag/k+mkry7Z+t0QbbHaVxXQfvH9AewyVMh/WrpEKhQ3DDgx9fYbqeCpeOEw=="], + + "knip/oxc-resolver/@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.21.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wkh0qKZGHXVUDxFw3oA1TXnU2BDYY/r775oJflGeIr8uDPPoN2pk8gijQIzYRT6hoql/lg3+Tx/SaTn9e2/aGg=="], + + "knip/oxc-resolver/@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.21.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-HbNc23FAQYbuyDV2vBWMez4u4mrsm5RAkniGZAWqr6lYZ3N4beeqIb776jzwRl8qL2zRhHVXpUj97X0QgogVzg=="], + + "knip/oxc-resolver/@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.21.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-K6xNsTUPEUdfrn0+kbMq5nOUB5w1C5pavPQngt4TM2FpN91lP0PBe2srSpamb4d69O7h86oAi/qWX/kZNRSjkw=="], + + "knip/oxc-resolver/@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.21.3", "", { "os": "linux", "cpu": "arm" }, "sha512-VcFmOpcpWX1zoEy8M58tR2M9YxM+Z9RuQhqAx5q0CTmrruaP7Gveejg75hzd/5sg5nk9G3aLALEa3hE2FsmmTQ=="], + + "knip/oxc-resolver/@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.21.3", "", { "os": "linux", "cpu": "arm" }, "sha512-quVoxFLBy43hWaQbbDtQNRwAX5vX76mv7n64icAtQcJ3eNgVeblqmkupF/hAneNthdqSlnd1sTjb3aQSaDPaCQ=="], + + "knip/oxc-resolver/@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.21.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ=="], + + "knip/oxc-resolver/@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.21.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw=="], + + "knip/oxc-resolver/@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.21.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A=="], + + "knip/oxc-resolver/@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.21.3", "", { "os": "linux", "cpu": "none" }, "sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og=="], + + "knip/oxc-resolver/@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.21.3", "", { "os": "linux", "cpu": "none" }, "sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw=="], + + "knip/oxc-resolver/@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.21.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q=="], + + "knip/oxc-resolver/@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.21.3", "", { "os": "linux", "cpu": "x64" }, "sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q=="], + + "knip/oxc-resolver/@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.21.3", "", { "os": "linux", "cpu": "x64" }, "sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA=="], + + "knip/oxc-resolver/@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.21.3", "", { "os": "none", "cpu": "arm64" }, "sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg=="], + + "knip/oxc-resolver/@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.21.3", "", { "dependencies": { "@emnapi/core": "1.11.0", "@emnapi/runtime": "1.11.0", "@napi-rs/wasm-runtime": "^1.1.5" }, "cpu": "none" }, "sha512-i8oluoel5kru/j1WNrjmQSiA3GQ7wvIYVR1IwIoZtKogAhya2iub+ZKIeSIkcJOrnzQ18Tzl/F+kL3fYOxZLvA=="], + + "knip/oxc-resolver/@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.21.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-M/8dw8dD6aOs+NlPJax401CZB9I7Aut84isQLgALGGwke4Afvw+/7yYhZb94yXf6t2sPLhQLmSmtSV+2FhsOWg=="], + + "knip/oxc-resolver/@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.21.3", "", { "os": "win32", "cpu": "x64" }, "sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q=="], + "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "log-symbols/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], @@ -1921,8 +2200,6 @@ "metro-transform-plugins/@babel/generator/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "metro/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "metro/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="], "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], @@ -1945,12 +2222,22 @@ "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "terminal-link/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], + "@expo/cli/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "@expo/cli/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "@react-native/dev-middleware/serve-static/send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "@react-native/dev-middleware/serve-static/send/fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + "cli-highlight/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "knip/oxc-resolver/@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], + + "knip/oxc-resolver/@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], + "log-symbols/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "log-symbols/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], @@ -1965,6 +2252,8 @@ "ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "@react-native/dev-middleware/serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "log-symbols/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], diff --git a/package.json b/package.json index 8a3bcaf..b7da867 100644 --- a/package.json +++ b/package.json @@ -149,6 +149,7 @@ "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.31.0", "@react-native-async-storage/async-storage": "^3.1.1", + "@stainless-code/codemap": "0.11.1", "@tanstack/intent": "0.3.4", "@tanstack/store": "0.11.0", "@testing-library/dom": "10.4.1", From 684fcac7095898747242ea46d892c8dc96463b0f Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sat, 4 Jul 2026 21:35:54 +0300 Subject: [PATCH 26/77] chore(codemap): add VS Code / Copilot MCP + copilot-instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .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) --- .cursor/mcp.json | 8 +------- .github/copilot-instructions.md | 13 +++++++++++++ .vscode/mcp.json | 9 +++++++++ 3 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .vscode/mcp.json diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 06972e9..b58abfb 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -2,13 +2,7 @@ "mcpServers": { "codemap": { "command": "bunx", - "args": [ - "@stainless-code/codemap@latest", - "mcp", - "--watch", - "--root", - "${workspaceFolder}" - ] + "args": ["codemap", "mcp", "--watch", "--root", "${workspaceFolder}"] } } } diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..c283634 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,13 @@ +<!-- codemap-pointer:begin --> + +# Codemap — GitHub Copilot custom instructions + +This project uses [Codemap](https://github.com/stainless-code/codemap) — a structural SQLite index for AI agents. + +- **Skill:** `.agents/skills/codemap/SKILL.md` +- **CLI:** `codemap` to index, `codemap query "SELECT …"` for SQL +- **Rules:** `.agents/rules/` + +See [GitHub Docs: custom instructions for Copilot](https://docs.github.com/copilot/customizing-copilot/adding-custom-instructions-for-github-copilot). + +<!-- codemap-pointer:end --> diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..5fb9a2f --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,9 @@ +{ + "servers": { + "codemap": { + "type": "stdio", + "command": "bunx", + "args": ["codemap", "mcp", "--watch", "--root", "${workspaceFolder}"] + } + } +} From b63e8dfec3799a914b9efb76952f3ad81f3db0bc Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sat, 4 Jul 2026 21:37:42 +0300 Subject: [PATCH 27/77] chore(codemap): remove copilot-instructions.md --- .github/copilot-instructions.md | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index c283634..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,13 +0,0 @@ -<!-- codemap-pointer:begin --> - -# Codemap — GitHub Copilot custom instructions - -This project uses [Codemap](https://github.com/stainless-code/codemap) — a structural SQLite index for AI agents. - -- **Skill:** `.agents/skills/codemap/SKILL.md` -- **CLI:** `codemap` to index, `codemap query "SELECT …"` for SQL -- **Rules:** `.agents/rules/` - -See [GitHub Docs: custom instructions for Copilot](https://docs.github.com/copilot/customizing-copilot/adding-custom-instructions-for-github-copilot). - -<!-- codemap-pointer:end --> From 2ce7df91134e44bbdad02f9ac2ed2a82ae813406 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sat, 4 Jul 2026 21:42:57 +0300 Subject: [PATCH 28/77] docs: authoring-discipline audit fixes across the full PR diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .agents/README.md | 6 +----- .agents/rules/agents-tier-system.md | 2 +- .../skills/improve-codebase-architecture/LANGUAGE.md | 2 +- .../improve-codebase-architecture/REFERENCE.md | 2 +- .agents/skills/pr-comment-fact-check/WORKFLOW.md | 8 ++++---- .changeset/encrypted-compressed.md | 2 +- .changeset/node-fs-pack-gate.md | 2 +- .changeset/rn-storage-adapters.md | 4 ++-- .changeset/solid-vue-hydration.md | 2 +- .changeset/svelte-hydration.md | 2 +- README.md | 8 +++++--- docs/roadmap.md | 12 ++++++------ tests-dom/react.test.tsx | 2 +- 13 files changed, 26 insertions(+), 28 deletions(-) diff --git a/.agents/README.md b/.agents/README.md index 7f9a1f0..b65d67c 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -24,11 +24,7 @@ Source of truth for AI agent configuration. Cursor consumes via symlinks in `.cu ## Inventory -9 rules + 18 skills. Discover on disk via `ls` + the frontmatter audit in [`agents-tier-system`](rules/agents-tier-system.md) — no hardcoded name lists. - -**Rules** — 7 Tier-1 (always-on): `agents-first-convention`, `tracer-bullets`, `no-bypass-hooks`, `verify-after-each-step`, `authoring-discipline`, `concise-reporting`, `architecture-priming`; plus `lessons.md`. 2 Tier-2 (globs): `agents-tier-system`, `docs-governance-priming`. - -**Skills** — `writing-great-skills` (meta vocabulary), `grilling` + `grill-me` + `grill-with-docs` (design stress-test), `teach` (multi-session learning), `ask-agents` (user-only router), `improve-codebase-architecture` (seam/boundary plans), `domain-modeling` (ubiquitous language), `docs-governance` + `docs-lifecycle-sweep` (docs lifecycle), `agents-tier-system` (tier assignments), `authoring-discipline` (prose depth), `verify-after-each-step` (per-file checks), `writing-agents-config` (persist deltas), `harden-pr` (branch-to-pristine), `diagnosing-bugs` (hard-bug loop), `tdd` (red-green-refactor), `pr-comment-fact-check` (reviewer/bot triage). +Discover on disk via `ls` + the frontmatter audit in [`agents-tier-system`](rules/agents-tier-system.md) — no hardcoded name lists. ## Conventions diff --git a/.agents/rules/agents-tier-system.md b/.agents/rules/agents-tier-system.md index d527abd..ae077f0 100644 --- a/.agents/rules/agents-tier-system.md +++ b/.agents/rules/agents-tier-system.md @@ -12,7 +12,7 @@ alwaysApply: false Three attachment modes: **always-on** (`alwaysApply: true`), **auto-attached** (`globs:`), **intent** (`description:` only). -**Tier 1 budget:** owner-set; currently 7 always-on rules + `lessons.md` (≤250 lines total). The frontmatter audit is the source of truth — **no hardcoded Tier 1 name lists** in rule, skill, or README. Audit: +**Tier 1 budget:** owner-set; currently 8 always-on rules + `lessons.md` (≤250 lines total). The frontmatter audit is the source of truth — **no hardcoded Tier 1 name lists** in rule, skill, or README. Audit: ```bash for f in .agents/rules/*.md .agents/lessons.md; do diff --git a/.agents/skills/improve-codebase-architecture/LANGUAGE.md b/.agents/skills/improve-codebase-architecture/LANGUAGE.md index 43d4cc1..9ce415e 100644 --- a/.agents/skills/improve-codebase-architecture/LANGUAGE.md +++ b/.agents/skills/improve-codebase-architecture/LANGUAGE.md @@ -8,7 +8,7 @@ Shared vocabulary for every suggestion this skill makes. Use these terms exactly **Module** Anything with an interface and an implementation. Deliberately scale-agnostic — applies equally to a function, a class, a file with a public surface, or a subpath entry. -_Avoid_: unit, component, service. ("Component" conflicts with React component; use "Module" even when the module is `use-hydrated.ts`.) +_Avoid_: unit, component, service. ("Component" conflicts with React component; use "Module" even when the module is `src/adapters/frameworks/react.ts`.) _Examples in this repo_: a single factory (`createStorage`); a subpath entry (`src/adapters/backends/idb.ts`); the core entry (`src/core/persist-core.ts` + `src/core/hydration.ts` behind `src/core/index.ts`); a codec (`src/adapters/codecs/seroval.ts`). **Interface** diff --git a/.agents/skills/improve-codebase-architecture/REFERENCE.md b/.agents/skills/improve-codebase-architecture/REFERENCE.md index e5906b7..5aef871 100644 --- a/.agents/skills/improve-codebase-architecture/REFERENCE.md +++ b/.agents/skills/improve-codebase-architecture/REFERENCE.md @@ -64,7 +64,7 @@ This repo runs **oxlint only** (no ESLint, no `eslint-plugin-boundaries`). Archi oxlint resolves the **nearest** `.oxlintrc.json` for each file and **does not auto-merge with parents**. That has three concrete consequences: 1. **Always set `extends`.** Every nested config must extend a parent config that ultimately reaches the repo-root `.oxlintrc.json`, otherwise baseline plugins/rules silently disappear for the files it owns. -2. **The `!` negation in `files` does not work** in oxlint. A `files: ["**", "!persist-idb/**"]` override matches `persist-idb` files too, which silently shadows any `persist-idb`-specific rule defined in another override. +2. **The `!` negation in `files` does not work** in oxlint. A `files: ["**", "!backends/idb/**"]` override matches `backends/idb` files too, which silently shadows any `backends/idb`-specific rule defined in another override. 3. **Same rule key in two `overrides[]` matching the same file → later replaces earlier.** Patterns do not merge across overrides. Combine all applicable patterns into a single `no-restricted-imports` rule per scope. Because of (2) and (3), the cleanest pattern is **one config file per scope** — the repo-root config plus a deeper config for any `src/` subfolder that needs different rules. Each leaf `extends` its parent and re-declares any rules it wants to carry alongside its own. diff --git a/.agents/skills/pr-comment-fact-check/WORKFLOW.md b/.agents/skills/pr-comment-fact-check/WORKFLOW.md index 91d45d8..2b13148 100644 --- a/.agents/skills/pr-comment-fact-check/WORKFLOW.md +++ b/.agents/skills/pr-comment-fact-check/WORKFLOW.md @@ -77,9 +77,9 @@ Output a triage table grouped by verdict, not by file: ## ❌ Incorrect / hallucinated (N) — push back -| # | File:line | Claim | Why wrong | -| --- | ---------------------- | -------------------------------- | ------------------------------------------------------------ | -| 2 | persist-tanstack.ts:13 | "useEffect should depend on key" | key is stable from persistSource options; no useEffect here. | +| # | File:line | Claim | Why wrong | +| --- | ----------------------------------------- | -------------------------------- | ------------------------------------------------------------ | +| 2 | src/adapters/sources/tanstack-store.ts:13 | "useEffect should depend on key" | key is stable from persistSource options; no useEffect here. | ## ⚠️ Partially correct (N) @@ -118,7 +118,7 @@ If the repo's branch protection requires **all conversations resolved to merge** 2. Wait one review-cycle for the reviewer to escalate or concede. For bot reviewers, "one cycle" is one push that triggers a re-review; for humans, give it ≥1 working day unless the merge is time-sensitive. 3. **Resolve the thread regardless** — the rebuttal lives in the thread body for the next reviewer pass; the merge gate cannot be held hostage to a bot's silence. Reviewer-pushback evidence is preserved (`gh api repos/{owner}/{repo}/pulls/{number}/comments` returns the full thread including resolved ones). -Counterbalance the "evidence-in-the-body" trade-off (future reviewers won't see resolved threads by default): drop a one-line summary of contested rebuttals into the **PR description** (e.g. `## Pushed back on (resolved): #2 persist-tanstack.ts:13 — no useEffect here; #5 PersistStorage.raw typing — deliberate unknown`). +Counterbalance the "evidence-in-the-body" trade-off (future reviewers won't see resolved threads by default): drop a one-line summary of contested rebuttals into the **PR description** (e.g. `## Pushed back on (resolved): #2 src/adapters/sources/tanstack-store.ts:13 — no useEffect here; #5 PersistStorage.raw typing — deliberate unknown`). The exception applies to `❌ hallucinated` and `⚠️ partial — needs a call` rows. The other rows already resolve by default. diff --git a/.changeset/encrypted-compressed.md b/.changeset/encrypted-compressed.md index 75f22c2..8be27d2 100644 --- a/.changeset/encrypted-compressed.md +++ b/.changeset/encrypted-compressed.md @@ -7,6 +7,6 @@ Add two zero-dep storage wrappers over the `StateStorage` seam — both async we - `./backends/encrypted` — `createEncryptedStorage(getStorage, { key })`: AES-GCM via WebCrypto (`crypto.subtle`). Each stored value is `base64(iv).base64(ciphertext)`; the AES-GCM auth tag means a wrong key or tampered ciphertext throws on decrypt → persist-core's corrupt-payload path returns `null` (or `clearCorruptOnFailure` removes the key). Returns `undefined` when `crypto.subtle` is unavailable so `createStorage` collapses to the no-op `PersistApi`. - `./backends/compressed` — `createCompressedStorage(getStorage, { format? })`: native `CompressionStream`/`DecompressionStream` (`gzip` | `deflate` | `deflate-raw`, default `gzip`); output is base64 so it stays string-wire. Returns `undefined` when the stream APIs are unavailable. Stacks with `createEncryptedStorage` (compress-then-encrypt is the standard order). -**Design note:** encryption + compression are backend **wrappers**, not sync `StorageCodec`s, because `crypto.subtle` and the stream APIs are async and the `StorageCodec` seam is sync. The codec serializes the envelope (sync); the wrapper encrypts/compresses the serialized string (async). Co-located tests (round-trip, ciphertext-not-plaintext, wrong-key-fails / compression-ratio, missing-key, formats, persistSource end-to-end, availability guard, dependency isolation). +**Design note:** encryption + compression are backend **wrappers**, not sync `StorageCodec`s, because `crypto.subtle` and the stream APIs are async and the `StorageCodec` seam is sync. The codec serializes the envelope (sync); the wrapper encrypts/compresses the serialized string (async). Also adds a README comparison table vs zustand-persist / redux-persist / @tanstack/query-persist-client / pinia-persist, and a migration guide with option-mapping tables + port snippets for each incumbent. diff --git a/.changeset/node-fs-pack-gate.md b/.changeset/node-fs-pack-gate.md index 490ad90..caea9e1 100644 --- a/.changeset/node-fs-pack-gate.md +++ b/.changeset/node-fs-pack-gate.md @@ -2,6 +2,6 @@ "@stainless-code/persist": minor --- -Add `./backends/node-fs` — `nodeFsStateStorage({ dir })`, an async `StateStorage<string>` over Node `fs.promises` (one file per key under `dir`). No peer dep (`node:fs` is a Node built-in). Keys are sanitized to filename-safe segments (`app:prefs:v1` → `app_prefs_v1`); missing files map to `null` (no throw); the dir is created lazily on first write. Compose with `createStorage(() => nodeFsStateStorage({ dir }), codec)`. Unblocks server / SSR / CLI persistence. Co-located tests (round-trip, missing-key, dir creation, filename sanitization, persistSource end-to-end, dependency isolation). +Add `./backends/node-fs` — `nodeFsStateStorage({ dir })`, an async `StateStorage<string>` over Node `fs.promises` (one file per key under `dir`). No peer dep (`node:fs` is a Node built-in). Keys are sanitized to filename-safe segments (`app:prefs:v1` → `app_prefs_v1`); missing files map to `null` (no throw); the dir is created lazily on first write. Compose with `createStorage(() => nodeFsStateStorage({ dir }), codec)`. Unblocks server / SSR / CLI persistence. Also adds a pack-validation + semver gate (`check:pack`: `@arethetypeswrong/cli` + `publint` + `knip`) wired into CI (`check-pack` job) + `prepublishOnly`; and README storage + codec decision matrices. diff --git a/.changeset/rn-storage-adapters.md b/.changeset/rn-storage-adapters.md index 31ca8d1..622a53f 100644 --- a/.changeset/rn-storage-adapters.md +++ b/.changeset/rn-storage-adapters.md @@ -2,10 +2,10 @@ "@stainless-code/persist": minor --- -Add 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): +Add three React Native storage subpaths over the `StateStorage` seam, mirroring the `./backends/idb` template (own subpath, optional peer, no cross-entry value imports): - `./backends/async-storage` (peer `@react-native-async-storage/async-storage >=1.0.0`) — `asyncStorageStateStorage` / `createAsyncStorage`. Fully async, string-wire; `useHydrated` gating mandatory. Accepts a custom instance (`getLegacyStorage()`, `createAsyncStorage(name)`) to namespace. - `./backends/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. Pair `encryptionKey` for secrets-at-rest. - `./backends/secure-store` (peer `expo-secure-store >=12.0.0`) — `secureStoreStateStorage` / `createSecureStoreStorage`. OS keychain/keystore, async, **~2KB value limit per key** — for small secrets (auth tokens), not large state; pair `partialize` to persist a tiny slice. -All three compose via `createJSONStorage` (jsonCodec default); swap codecs with `createStorage(backend, codec)`. Tests use `mock.module` to fake each peer (Map-backed) — validates shape, not the real RN runtime. +All three compose via `createJSONStorage` (jsonCodec default); swap codecs with `createStorage(backend, codec)`. diff --git a/.changeset/solid-vue-hydration.md b/.changeset/solid-vue-hydration.md index 29e1567..357bf58 100644 --- a/.changeset/solid-vue-hydration.md +++ b/.changeset/solid-vue-hydration.md @@ -7,4 +7,4 @@ Add `./frameworks/solid` and `./frameworks/vue` hydration subpaths — `useHydra - `./frameworks/solid` (peer `solid-js >=1.6.0`): returns a Solid `Accessor<boolean>` via `from`; the subscription is owned by the reactive scope and cleaned up on scope dispose. Uses the `from(producer, initialValue)` overload so the accessor is `Accessor<boolean>` (not `boolean | undefined`); reads `isHydrated()` for the initial value (pull-model signal — no initial notification). - `./frameworks/vue` (peer `vue >=3.3.0`): returns a Vue `Ref<boolean>` via `shallowRef`; subscription cleaned up via `onScopeDispose` — call inside `setup()` or an `effectScope()`. -Both render `true` on the server (the no-op `PersistApi` is always-hydrated, so the signal is `true` server-side) — matching the `HydrationSignal` adapter contract. Each ships as its own subpath with the peer as optional, no cross-entry value imports (isolation test included). +Both render `true` on the server (the no-op `PersistApi` is always-hydrated, so the signal is `true` server-side) — matching the `HydrationSignal` adapter contract. Each ships as its own subpath with the peer as optional, no cross-entry value imports. diff --git a/.changeset/svelte-hydration.md b/.changeset/svelte-hydration.md index b5bbd1c..6003101 100644 --- a/.changeset/svelte-hydration.md +++ b/.changeset/svelte-hydration.md @@ -7,4 +7,4 @@ Add Svelte hydration adapters over the `HydrationSignal` seam, covering both pre - `./frameworks/svelte` (peer `svelte >=5.0.0`) — `hydratedRune(signal)` via `svelte/reactivity` `createSubscriber`. Returns `{ readonly current: boolean }`; read `current` inside a reactive context (`$derived`/`$effect`/component/`{#if}`). Subscription owned by the reactive context, cleaned up on context 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 (for users who prefer the store API). Subscription tied to the store's subscriber lifecycle. -Both render `true` on the server (no-op `PersistApi` is always-hydrated) — matching the `HydrationSignal` adapter contract. Each is its own subpath with `svelte` optional, no cross-entry value imports (isolation test included). Co-located tests: the store adapter is fully tested in bun (`svelte/store` works standalone — value, subscriber updates, signal subscribe/unsubscribe lifecycle); the runes adapter pins the value contract (the reactive auto-update needs a Svelte component context — `createSubscriber`'s start is a no-op without an owner — and rides on the `HydrationSignal` contract pinned in `core/hydration.test.ts`, same philosophy as the React `use-hydrated` bun test). +Both render `true` on the server (no-op `PersistApi` is always-hydrated) — matching the `HydrationSignal` adapter contract. Each is its own subpath with `svelte` optional, no cross-entry value imports. diff --git a/README.md b/README.md index 91d48b7..63ebdbb 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,13 @@ Jump to what you need — - [What does "hydration-aware" mean?](#what-does-hydration-aware-mean) - [IndexedDB + React, end to end](#indexeddb--react-end-to-end) - [Relationship to TanStack Persist / zustand persist](#relationship-to-tanstack-persist--zustand-persist) +- [Comparison with other persist libraries](#comparison-with-other-persist-libraries) +- [Migrating from …](#migrating-from-) - Extensibility guide - [Entry points (one subpath = one optional peer)](#entry-points-one-subpath--one-optional-peer) - [The three seams](#the-three-seams) + - [Choosing a storage](#choosing-a-storage) + - [Choosing a codec](#choosing-a-codec) - [Recipes](#recipes) - [Writing a framework adapter](#writing-a-framework-adapter) - [Lifecycle in one paragraph](#lifecycle-in-one-paragraph) @@ -172,9 +176,7 @@ Every row is a seam or lifecycle concern — not a roadmap item. `@stainless-cod | Schema validation (codec) | ✓ | ✗ | ✗ | ✗ | ✗ | | Framework hydration adapters | ✓ | ✗ | ✗ | ✓ | ✗ | -**Differentiator:** `@stainless-code/persist` is the only library here with built-in cross-tab sync, a schema-validation codec, and a fully store-agnostic source (`PersistableSource`) — and the only one that scores ✓ on every row. The closest peer, `@tanstack/query-persist-client`, matches on the hydration signal, `retryWrite` (shrink-or-give-up), `throttleMs`, `maxAge`/`buster`, and framework adapters — but is query-cache-bound (not store-agnostic), exposes no codec seam on its `Persister` interface, and ships no versioned `migrate`, cross-tab sync, or schema validation. - -_Cells verified against each library's source on 2026-07-04: `pmndrs/zustand`, `rt2zz/redux-persist`, `TanStack/query`, `prazdevs/pinia-plugin-persistedstate`._ +**Differentiator:** `@stainless-code/persist` is the only library here with built-in cross-tab sync, a schema-validation codec, and a fully store-agnostic source (`PersistableSource`). The closest peer, `@tanstack/query-persist-client`, matches on the hydration signal, `retryWrite` (shrink-or-give-up), `throttleMs`, `maxAge`/`buster`, and framework adapters — but is query-cache-bound (not store-agnostic), exposes no codec seam on its `Persister` interface, and ships no versioned `migrate`, cross-tab sync, or schema validation. ## Migrating from … diff --git a/docs/roadmap.md b/docs/roadmap.md index 228030b..d49ef49 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap -Forward-looking plans only — **not** a mirror of `src/`. **Doc index:** [README.md](./README.md). **Design / seams:** [architecture.md](./architecture.md). Shipped features (core, codecs, backends, TanStack adapters, React hook) live in `src/` and the root [README.md](../README.md) — not enumerated here. +Forward-looking plans only — **not** a mirror of `src/`. **Doc index:** [README.md](./README.md). **Design / seams:** [architecture.md](./architecture.md). Shipped features (core, codecs, backends, TanStack adapters, React/Solid/Vue/Svelte hydration adapters) live in `src/` and the root [README.md](../README.md) — not enumerated here. --- @@ -12,11 +12,11 @@ Forward-looking plans only — **not** a mirror of `src/`. **Doc index:** [READM ## Strategy -| Layer | Role | -| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Core** (`persist-core` + `hydration`) | Zero-dep middleware: `persistSource`, `createStorage`, codecs, registry, hydration signal. No value imports (gate-test enforced). | -| **Codec / backend subpaths** | Own their optional peer (`seroval`, `idb-keyval`); compose into `createStorage`. | -| **Framework adapters** | One entry per framework (`./sources/tanstack-store`, `./frameworks/react`); each adapter is ~20 lines over `HydrationSignal` — the same shape scales to Svelte / Solid / Vue. | +| Layer | Role | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Core** (`persist-core` + `hydration`) | Zero-dep middleware: `persistSource`, `createStorage`, codecs, registry, hydration signal. No value imports (gate-test enforced). | +| **Codec / backend subpaths** | Own their optional peer (`seroval`, `idb-keyval`); compose into `createStorage`. | +| **Framework adapters** | One entry per framework (`./sources/tanstack-store`, `./frameworks/react`); each adapter is ~20 lines over `HydrationSignal`. React/Solid/Vue/Svelte shipped. | ## Non-goals (v1) diff --git a/tests-dom/react.test.tsx b/tests-dom/react.test.tsx index fe8987d..9bfd368 100644 --- a/tests-dom/react.test.tsx +++ b/tests-dom/react.test.tsx @@ -11,7 +11,7 @@ import type { PersistableSource, StateStorage } from "../src/core/persist-core"; /** * Framework-matrix tests for the React `useHydrated` reactivity path — the * `useSyncExternalStore` rerender + cleanup wiring that `bun:test` can't - * exercise (no DOM, no client renderer). The bun suite (`src/use-hydrated.test.ts`) + * exercise (no DOM, no client renderer). The bun suite (`src/adapters/frameworks/react.test.ts`) * pins SSR safety + snapshot values; this suite pins that a real client * renderer actually rerenders on the hydration flip and detaches on unmount. */ From bff513f5de81f47d0e04b379c1c7dc8a937789bc Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sat, 4 Jul 2026 21:44:07 +0300 Subject: [PATCH 29/77] docs(audit): add Angular-signals (#33) + Preact (#34) framework adapters to Tier 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/audits/2026-07-04-docs-adapters-roi.md | 27 +++++++++++---------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/audits/2026-07-04-docs-adapters-roi.md b/docs/audits/2026-07-04-docs-adapters-roi.md index 0b9f8fc..6a9f6df 100644 --- a/docs/audits/2026-07-04-docs-adapters-roi.md +++ b/docs/audits/2026-07-04-docs-adapters-roi.md @@ -121,18 +121,20 @@ Ordered by ROI = impact ÷ effort. **Effort:** S / M / L. **Status as of 2026-07 ### Tier 2 — Build out the surface (high impact, medium effort) -| # | Action | Effort | -| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -| ✅ 10 | Encryption-at-rest codec (WebCrypto + codec). Headline "custom codec" example in JSDoc (`persist-core.ts:69`, `persist-idb.ts:52`, `skill:155`) with no shipped impl. | M | -| 11 | `examples/` monorepo workspace — runnable `tanstack-idb-react`, `tanstack-localstorage-react`, `nextjs-ssr`, `react-native-mmkv`. No runnable demo today. | M | -| 12 | Docs site (VitePress / Astro Starlight) — split wall-of-text README into Getting Started → Adapters → Recipes → Adapter authoring → Reference; host `docs/api/` under it. | M | -| 13 | TanStack Query persister bridge (`persistQueryClient`-shaped). JSDoc cites Query as reference design (`persist-core.ts:12,263`). Flagship integration. | M | -| ✅ 14 | Migration/porting guide — option mapping + conceptual diff vs zustand-persist / redux-persist / query-persist-client / pinia-persist. | S | -| ✅ 15 | Comparison table across the 4 incumbents. README paragraph → table. | S | -| ✅ 16 | Storage & codec decision matrices — lift + expand the skill's 4-row version to consumer docs. | S | -| ✅ 17 | `CompressionStream` codec — native API, ~S now; pairs with binary `TRaw`. | M | -| ✅ 18 | Node `fs` storage adapter — trivial `StateStorage`; unblocks server/SSR/CLI. | S | -| ✅ 19 | Pack-validation + semver gate in CI — `attw --pack` + `knip` + `publint`. Prevents shipping a broken `exports` map. | S | +| # | Action | Effort | +| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| ✅ 10 | Encryption-at-rest codec (WebCrypto + codec). Headline "custom codec" example in JSDoc (`persist-core.ts:69`, `persist-idb.ts:52`, `skill:155`) with no shipped impl. | M | +| 11 | `examples/` monorepo workspace — runnable `tanstack-idb-react`, `tanstack-localstorage-react`, `nextjs-ssr`, `react-native-mmkv`. No runnable demo today. | M | +| 12 | Docs site (VitePress / Astro Starlight) — split wall-of-text README into Getting Started → Adapters → Recipes → Adapter authoring → Reference; host `docs/api/` under it. | M | +| 13 | TanStack Query persister bridge (`persistQueryClient`-shaped). JSDoc cites Query as reference design (`persist-core.ts:12,263`). Flagship integration. | M | +| ✅ 14 | Migration/porting guide — option mapping + conceptual diff vs zustand-persist / redux-persist / query-persist-client / pinia-persist. | S | +| ✅ 15 | Comparison table across the 4 incumbents. README paragraph → table. | S | +| ✅ 16 | Storage & codec decision matrices — lift + expand the skill's 4-row version to consumer docs. | S | +| ✅ 17 | `CompressionStream` codec — native API, ~S now; pairs with binary `TRaw`. | M | +| ✅ 18 | Node `fs` storage adapter — trivial `StateStorage`; unblocks server/SSR/CLI. | S | +| ✅ 19 | Pack-validation + semver gate in CI — `attw --pack` + `knip` + `publint`. Prevents shipping a broken `exports` map. | S | +| 33 | Angular-signals hydration adapter — `signal` + `effect`-based gate over `HydrationSignal`; peer `@angular/core`. Svelte/Solid/Vue shipped; Angular is the remaining framework adapter. | S | +| 34 | Preact hydration adapter — near-clone of `./frameworks/react` (`useSyncExternalStore`); peer `preact`. | S | ### Tier 3 — Maturity & polish (medium impact, medium effort) @@ -144,7 +146,6 @@ Ordered by ROI = impact ÷ effort. **Effort:** S / M / L. **Status as of 2026-07 | 23 | Bundle-size badge + `size-limit` gate; advertise zero-dep core. | S | | 24 | `packageManager` pin + TS consumer range + Node/React/TanStack compat table. | S | | 25 | `zustand`/`jotai`/`valtio`/`mobx`/signals source adapters — decide ship-as-subpath vs recipe first. | S each | -| 26 | Preact + Angular-signals hydration adapters. | S each | | 27 | FAQ / troubleshooting page — lift from `skill:141` + "why does my UI flash" + "IDB cross-tab isn't syncing" + "quota exceeded". | S | ### Tier 4 — Strategic bets (high impact, high effort) From 7a04fd8a772e5488e9de43db4b6564c900c4b3ab Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sat, 4 Jul 2026 21:51:35 +0300 Subject: [PATCH 30/77] feat(frameworks): add Angular-signals + Preact hydration adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ✅. --- .changeset/angular-preact-hydration.md | 10 +++ README.md | 4 + bun.lock | 8 ++ docs/architecture.md | 4 +- docs/audits/2026-07-04-docs-adapters-roi.md | 4 +- package.json | 18 ++++ src/adapters/frameworks/angular.test.ts | 96 +++++++++++++++++++++ src/adapters/frameworks/angular.ts | 35 ++++++++ src/adapters/frameworks/preact.test.ts | 58 +++++++++++++ src/adapters/frameworks/preact.ts | 32 +++++++ tsdown.config.ts | 4 + typedoc.json | 4 +- 12 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 .changeset/angular-preact-hydration.md create mode 100644 src/adapters/frameworks/angular.test.ts create mode 100644 src/adapters/frameworks/angular.ts create mode 100644 src/adapters/frameworks/preact.test.ts create mode 100644 src/adapters/frameworks/preact.ts diff --git a/.changeset/angular-preact-hydration.md b/.changeset/angular-preact-hydration.md new file mode 100644 index 0000000..5fa43b0 --- /dev/null +++ b/.changeset/angular-preact-hydration.md @@ -0,0 +1,10 @@ +--- +"@stainless-code/persist": minor +--- + +Add `./frameworks/angular` and `./frameworks/preact` hydration adapters over the `HydrationSignal` seam — completing the framework adapter set (React, Solid, Vue, Svelte, Angular, Preact). + +- `./frameworks/angular` (peer `@angular/core >=17.0.0`): `useHydrated(signal)` returns a readonly `Signal<boolean>` via Angular `signal()` + `effect()`. Call inside a component's injection context (`effect()` requires it); subscription cleaned up via `onCleanup`. `@if (hydrated())` in templates. +- `./frameworks/preact` (peer `preact >=10.19.0`): `useHydrated(signal)` returns `{ hydrated: boolean }` via `useSyncExternalStore` (preact/compat). Near-clone of `./frameworks/react`. `@ts-expect-error` on the 3-arg call (Preact types omit `getServerSnapshot`; runtime ignores it). + +Both render `true` on the server (no-op `PersistApi`). Each is its own subpath with the peer optional, no cross-entry value imports. Angular tested with a mocked `@angular/core` (signal/effect/onCleanup stub — no injection context needed); Preact tested with a mocked `preact/compat` (snapshot-only stub). Both pin the value contract; the reactive auto-update rides on the `HydrationSignal` contract pinned in `core/hydration.test.ts`. diff --git a/README.md b/README.md index 63ebdbb..10db01d 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ Each subpath owns its dependency as an **optional peer** — import only the ent | `@stainless-code/persist/frameworks/vue` | `vue` | | `@stainless-code/persist/frameworks/svelte` | `svelte` | | `@stainless-code/persist/frameworks/svelte-store` | `svelte` | +| `@stainless-code/persist/frameworks/angular` | `@angular/core` | +| `@stainless-code/persist/frameworks/preact` | `preact` | ```bash # only when you use the matching entry @@ -407,6 +409,8 @@ Persistence middleware for any `getState`/`setState`/`subscribe` store (TanStack | `@stainless-code/persist/frameworks/vue` | `useHydrated` (Vue `Ref<boolean>`) | `vue` | | `@stainless-code/persist/frameworks/svelte` | `hydratedRune` (Svelte 5 runes `current`) | `svelte` (>=5) | | `@stainless-code/persist/frameworks/svelte-store` | `hydratedStore` (Svelte `Readable<boolean>`) | `svelte` (>=3) | +| `@stainless-code/persist/frameworks/angular` | `useHydrated` (Angular `Signal<boolean>`) | `@angular/core` (>=17) | +| `@stainless-code/persist/frameworks/preact` | `useHydrated` (Preact `{ hydrated: boolean }`) | `preact` (>=10.19) | No barrel — importing a subpath is the dependency opt-in. diff --git a/bun.lock b/bun.lock index b9a9245..616c512 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "@stainless-code/persist", "devDependencies": { + "@angular/core": "^22.0.5", "@arethetypeswrong/cli": "0.18.4", "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.31.0", @@ -27,6 +28,7 @@ "lint-staged": "17.0.8", "oxfmt": "0.56.0", "oxlint": "1.71.0", + "preact": "^10.29.4", "publint": "0.3.21", "react": "19.2.7", "react-dom": "19.2.7", @@ -72,6 +74,8 @@ "packages": { "@andrewbranch/untar.js": ["@andrewbranch/untar.js@1.0.3", "", {}, "sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw=="], + "@angular/core": ["@angular/core@22.0.5", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/compiler": "22.0.5", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" }, "optionalPeers": ["@angular/compiler", "zone.js"] }, "sha512-8cc6CLC1u7kNHpUZA15tak0WPHAZTF16DPslXxDdWbw/WIlDGPsNdTW8Nsaeb/G45mLvBcMwcZpOC70R6iRzmA=="], + "@arethetypeswrong/cli": ["@arethetypeswrong/cli@0.18.4", "", { "dependencies": { "@arethetypeswrong/core": "0.18.4", "chalk": "^4.1.2", "cli-table3": "^0.6.3", "commander": "^10.0.1", "marked": "^9.1.2", "marked-terminal": "^7.1.0", "semver": "^7.5.4" }, "bin": { "attw": "./dist/index.js" } }, "sha512-kNWo6LTzGAuLYPpJ7Sgo63whSUeeSuKMlYx6IBgzs4ONEG807gW4hSSENvpeCHzO2H2wIzG5EFl0OKBbqGBAyA=="], "@arethetypeswrong/core": ["@arethetypeswrong/core@0.18.4", "", { "dependencies": { "@andrewbranch/untar.js": "^1.0.3", "@loaderkit/resolve": "^1.0.2", "cjs-module-lexer": "^1.2.3", "fflate": "^0.8.3", "lru-cache": "^11.0.1", "semver": "^7.5.4", "typescript": "5.6.1-rc", "validate-npm-package-name": "^5.0.0" } }, "sha512-M5F0ePyN6h2Z6XxRiyIPqjGbltotXLjR0CKA0uKspsDu0QmgTNYvRb4RSQPMUs2ZXZHCCYpbaZbFbYOXLxCjUA=="], @@ -1470,6 +1474,8 @@ "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], + "preact": ["preact@10.29.4", "", {}, "sha512-GMpwh9+NJ8tSmqwIaVyFRQkiKfBEzQ+k7r7tle4W+kaJ+7wJiB9hFz9BixAomMtenPPSBfM4bZhXozGxhf0uFQ=="], + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], @@ -1568,6 +1574,8 @@ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], diff --git a/docs/architecture.md b/docs/architecture.md index aa1ba9f..541c531 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,6 +35,8 @@ Persistence is bound to a structural `PersistableSource` (`getState` / `setState | `@stainless-code/persist/frameworks/vue` | `adapters/frameworks/vue` | `vue` | | `@stainless-code/persist/frameworks/svelte` | `adapters/frameworks/svelte` | `svelte` (>=5 runes) | | `@stainless-code/persist/frameworks/svelte-store` | `adapters/frameworks/svelte-store` | `svelte` (>=3 store) | +| `@stainless-code/persist/frameworks/angular` | `adapters/frameworks/angular` | `@angular/core` (>=17) | +| `@stainless-code/persist/frameworks/preact` | `adapters/frameworks/preact` | `preact` (>=10.19) | No barrel — importing a subpath is the dependency opt-in. Each subpath entry owns its peer dep, which stays external in the build (`tsdown.config.ts` `neverBundle`) so consumers tree-shake cleanly. @@ -48,7 +50,7 @@ No barrel — importing a subpath is the dependency opt-in. Each subpath entry o - `backends/` — `StateStorage` adapters + wrappers (idb, async-storage, mmkv, secure-store, encrypted, compressed, node-fs) - `transport/` — `CrossTabEventTarget` adapters (crosstab — BroadcastChannel bridge) - `sources/` — `PersistableSource` adapters (tanstack-store) - - `frameworks/` — `HydrationSignal` framework adapters (react, solid, vue, svelte, svelte-store) + - `frameworks/` — `HydrationSignal` framework adapters (react, solid, vue, svelte, svelte-store, angular, preact) A per-entry self-check test pins the invariant: every adapter's relative imports resolve into `core/` (no cross-adapter coupling). `dist/` mirrors `src/` (`dist/<seam>/<name>.mjs` via tsdown's record-form `entry` keyed by `<seam>/<name>`) — src folder → tsdown key → dist path → subpath, all 1:1. diff --git a/docs/audits/2026-07-04-docs-adapters-roi.md b/docs/audits/2026-07-04-docs-adapters-roi.md index 6a9f6df..8671be5 100644 --- a/docs/audits/2026-07-04-docs-adapters-roi.md +++ b/docs/audits/2026-07-04-docs-adapters-roi.md @@ -133,8 +133,8 @@ Ordered by ROI = impact ÷ effort. **Effort:** S / M / L. **Status as of 2026-07 | ✅ 17 | `CompressionStream` codec — native API, ~S now; pairs with binary `TRaw`. | M | | ✅ 18 | Node `fs` storage adapter — trivial `StateStorage`; unblocks server/SSR/CLI. | S | | ✅ 19 | Pack-validation + semver gate in CI — `attw --pack` + `knip` + `publint`. Prevents shipping a broken `exports` map. | S | -| 33 | Angular-signals hydration adapter — `signal` + `effect`-based gate over `HydrationSignal`; peer `@angular/core`. Svelte/Solid/Vue shipped; Angular is the remaining framework adapter. | S | -| 34 | Preact hydration adapter — near-clone of `./frameworks/react` (`useSyncExternalStore`); peer `preact`. | S | +| ✅ 33 | Angular-signals hydration adapter — `signal` + `effect`-based gate over `HydrationSignal`; peer `@angular/core`. Svelte/Solid/Vue shipped; Angular is the remaining framework adapter. | S | +| ✅ 34 | Preact hydration adapter — near-clone of `./frameworks/react` (`useSyncExternalStore`); peer `preact`. | S | ### Tier 3 — Maturity & polish (medium impact, medium effort) diff --git a/package.json b/package.json index b7da867..5922c2b 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,14 @@ "./frameworks/svelte-store": { "types": "./dist/frameworks/svelte-store.d.mts", "import": "./dist/frameworks/svelte-store.mjs" + }, + "./frameworks/angular": { + "types": "./dist/frameworks/angular.d.mts", + "import": "./dist/frameworks/angular.mjs" + }, + "./frameworks/preact": { + "types": "./dist/frameworks/preact.d.mts", + "import": "./dist/frameworks/preact.mjs" } }, "publishConfig": { @@ -145,6 +153,7 @@ "version": "changeset version && bun scripts/sync-skill-versions.ts && bun run format CHANGELOG.md" }, "devDependencies": { + "@angular/core": "22.0.5", "@arethetypeswrong/cli": "0.18.4", "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.31.0", @@ -167,6 +176,7 @@ "lint-staged": "17.0.8", "oxfmt": "0.56.0", "oxlint": "1.71.0", + "preact": "10.29.4", "publint": "0.3.21", "react": "19.2.7", "react-dom": "19.2.7", @@ -182,10 +192,12 @@ "zod": "^4.4.3" }, "peerDependencies": { + "@angular/core": ">=17.0.0", "@react-native-async-storage/async-storage": ">=1.0.0", "@tanstack/store": ">=0.10.0", "expo-secure-store": ">=12.0.0", "idb-keyval": ">=4.0.0", + "preact": ">=10.19.0", "react": "^18.0.0 || ^19.0.0", "react-native-mmkv": ">=4.0.0", "seroval": ">=1.0.0", @@ -213,6 +225,12 @@ "svelte": { "optional": true }, + "@angular/core": { + "optional": true + }, + "preact": { + "optional": true + }, "vue": { "optional": true }, diff --git a/src/adapters/frameworks/angular.test.ts b/src/adapters/frameworks/angular.test.ts new file mode 100644 index 0000000..d26630e --- /dev/null +++ b/src/adapters/frameworks/angular.test.ts @@ -0,0 +1,96 @@ +import { beforeAll, describe, expect, it, mock } from "bun:test"; + +// Minimal Angular signals stub — signal() + effect() without an injection +// context. effect() runs immediately + registers cleanup via its callback arg. +const cleanups: Array<() => void> = []; + +mock.module("@angular/core", () => ({ + signal: <T>(initial: T) => { + let value = initial; + const sig = Object.assign(() => value, { + set(v: T) { + value = v; + }, + update(fn: (prev: T) => T) { + value = fn(value); + }, + asReadonly() { + return sig; + }, + }); + return sig; + }, + effect: (fn: (onCleanup: (cleanup: () => void) => void) => void) => { + fn((cleanup) => { + cleanups.push(cleanup); + }); + }, +})); + +let useHydrated: typeof import("./angular").useHydrated; + +beforeAll(async () => { + ({ useHydrated } = await import("./angular")); +}); + +function createFakeSignal() { + let hydrated = false; + const listeners = new Set<() => void>(); + return { + subscribeHydrated: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + isHydrated: () => hydrated, + set: (value: boolean) => { + hydrated = value; + listeners.forEach((l) => l()); + }, + listenerCount: () => listeners.size, + }; +} + +function runCleanups() { + for (const cleanup of cleanups.splice(0)) { + cleanup(); + } +} + +describe("useHydrated", () => { + it("returns an always-true signal for a null/undefined signal", () => { + expect(useHydrated(null)()).toBe(true); + expect(useHydrated(undefined)()).toBe(true); + }); + + it("current mirrors isHydrated() on each access", () => { + const signal = createFakeSignal(); + const hydrated = useHydrated(signal); + expect(hydrated()).toBe(false); + signal.set(true); + expect(hydrated()).toBe(true); + signal.set(false); + expect(hydrated()).toBe(false); + }); + + it("cleans up on context destroy", () => { + const signal = createFakeSignal(); + useHydrated(signal); + expect(signal.listenerCount()).toBe(1); + runCleanups(); + expect(signal.listenerCount()).toBe(0); + }); +}); + +describe("angular dependency isolation", () => { + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file( + new URL("./angular.ts", import.meta.url), + ).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); + } + }); +}); diff --git a/src/adapters/frameworks/angular.ts b/src/adapters/frameworks/angular.ts new file mode 100644 index 0000000..17d0816 --- /dev/null +++ b/src/adapters/frameworks/angular.ts @@ -0,0 +1,35 @@ +// Angular signals hydration adapter — peer `@angular/core` >=17.0.0 (signals). +import { effect, signal } from "@angular/core"; +import type { Signal } from "@angular/core"; + +import type { HydrationSignal } from "../../core/hydration"; + +const alwaysTrue = signal(true); + +/** + * Mount a `HydrationSignal` into Angular signals. Returns a readonly + * `Signal<boolean>` — read it in a template or `computed`/`effect`. Call + * inside a component's injection context (`effect()` requires it); the + * subscription is cleaned up on context destroy. Null/undefined signal → + * always `true`. Renders `true` on the server. + * + * @example + * ```ts + * // in a component + * hydrated = useHydrated(prefsHydration); + * // template: @if (hydrated()) { <Prefs /> } @else { <Skeleton /> } + * ``` + */ +export function useHydrated( + signalSource: HydrationSignal | null | undefined, +): Signal<boolean> { + if (!signalSource) return alwaysTrue; + const hydrated = signal(signalSource.isHydrated()); + effect((onCleanup) => { + const unsubscribe = signalSource.subscribeHydrated(() => { + hydrated.set(signalSource.isHydrated()); + }); + onCleanup(unsubscribe); + }); + return hydrated.asReadonly(); +} diff --git a/src/adapters/frameworks/preact.test.ts b/src/adapters/frameworks/preact.test.ts new file mode 100644 index 0000000..d08c33a --- /dev/null +++ b/src/adapters/frameworks/preact.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, mock } from "bun:test"; + +mock.module("preact/compat", () => ({ + useSyncExternalStore: ( + _subscribe: (listener: () => void) => () => void, + getSnapshot: () => boolean, + ) => getSnapshot(), +})); + +const { useHydrated } = await import("./preact"); + +function createFakeSignal() { + let hydrated = false; + const listeners = new Set<() => void>(); + return { + subscribeHydrated: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + isHydrated: () => hydrated, + set: (value: boolean) => { + hydrated = value; + listeners.forEach((l) => l()); + }, + listenerCount: () => listeners.size, + }; +} + +describe("useHydrated", () => { + it("returns hydrated=true for a null signal", () => { + expect(useHydrated(null).hydrated).toBe(true); + }); + + it("returns hydrated=true for an undefined signal", () => { + expect(useHydrated(undefined).hydrated).toBe(true); + }); + + it("current mirrors isHydrated()", () => { + const signal = createFakeSignal(); + expect(useHydrated(signal).hydrated).toBe(false); + signal.set(true); + expect(useHydrated(signal).hydrated).toBe(true); + }); +}); + +describe("preact dependency isolation", () => { + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file( + new URL("./preact.ts", import.meta.url), + ).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((m) => m[1]); + for (const imp of relativeImports) { + expect(imp).toMatch(/^\.\.\/\.\.\/core\//); + } + }); +}); diff --git a/src/adapters/frameworks/preact.ts b/src/adapters/frameworks/preact.ts new file mode 100644 index 0000000..1ce50ab --- /dev/null +++ b/src/adapters/frameworks/preact.ts @@ -0,0 +1,32 @@ +// Preact hydration adapter — peer `preact` >=10.19.0 (useSyncExternalStore via preact/compat). +import { useSyncExternalStore } from "preact/compat"; + +import type { HydrationSignal } from "../../core/hydration"; + +export interface UseHydratedResult { + hydrated: boolean; +} + +const noopSubscribe: (listener: () => void) => () => void = () => () => {}; +const alwaysTrue: () => boolean = () => true; + +/** + * Mount a `HydrationSignal` into Preact via `useSyncExternalStore` (preact/compat). + * Returns `{ hydrated }` — gate UI on it. Null/undefined signal → `hydrated: true`. + * Renders `true` on the server. + * + * @example + * ```ts + * const { hydrated } = useHydrated(prefsHydration); + * if (!hydrated) return <Skeleton />; + * ``` + */ +export function useHydrated( + signal: HydrationSignal | null | undefined, +): UseHydratedResult { + const subscribe = signal?.subscribeHydrated ?? noopSubscribe; + const getSnapshot = signal?.isHydrated ?? alwaysTrue; + // @ts-expect-error preact/compat types omit getServerSnapshot (SSR third arg) + const hydrated = useSyncExternalStore(subscribe, getSnapshot, alwaysTrue); + return { hydrated }; +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 36fd0d6..462b270 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -26,6 +26,8 @@ export default defineConfig({ "frameworks/vue": "src/adapters/frameworks/vue.ts", "frameworks/svelte": "src/adapters/frameworks/svelte.ts", "frameworks/svelte-store": "src/adapters/frameworks/svelte-store.ts", + "frameworks/angular": "src/adapters/frameworks/angular.ts", + "frameworks/preact": "src/adapters/frameworks/preact.ts", }, outDir, format: "esm", @@ -40,6 +42,8 @@ export default defineConfig({ "zod", "solid-js", "svelte", + "@angular/core", + "preact", "vue", "@react-native-async-storage/async-storage", "react-native-mmkv", diff --git a/typedoc.json b/typedoc.json index 2c8f760..feb7c4a 100644 --- a/typedoc.json +++ b/typedoc.json @@ -17,7 +17,9 @@ "src/adapters/frameworks/solid.ts", "src/adapters/frameworks/vue.ts", "src/adapters/frameworks/svelte.ts", - "src/adapters/frameworks/svelte-store.ts" + "src/adapters/frameworks/svelte-store.ts", + "src/adapters/frameworks/angular.ts", + "src/adapters/frameworks/preact.ts" ], "out": "docs/api", "name": "@stainless-code/persist", From 66f3abcb72547edf0767d9c5cda612e2393d3b64 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sat, 4 Jul 2026 21:52:16 +0300 Subject: [PATCH 31/77] docs(plans): hydrate pitch with Angular + Preact framework adapters --- docs/plans/upstream-tanstack-pitch.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/upstream-tanstack-pitch.md b/docs/plans/upstream-tanstack-pitch.md index c002aae..f311e39 100644 --- a/docs/plans/upstream-tanstack-pitch.md +++ b/docs/plans/upstream-tanstack-pitch.md @@ -6,13 +6,13 @@ Hi — sharing [`@stainless-code/persist`](https://github.com/stainless-code/persist), a hydration-aware persistence middleware for any reactive store. Published under stainless-code, extracted, test-covered. It could serve as a reference or a contribution toward TanStack Persist; happy to fold any of it upstream. Quick tour. -**1. What it is.** A middleware over a _structural_ `PersistableSource` (`getState`/`setState`/`subscribe`) — not a store-specific wrapper. `persistSource(source, options)` persists anything reactive; `persistStore`/`persistAtom` are thin adapters for `@tanstack/store`. Unlike a passive `StoragePersister` (dump/load a blob), the middleware owns the lifecycle — hydrate-on-create, subscribe-writes gated until hydrated, trailing throttle, teardown — so the store stays a plain store and persistence is a sidekick. Three seams (`StateStorage` × `StorageCodec` × `PersistableSource`) make every backend × codec cell a one-line composition; the surface is exercised across codecs (seroval, zod), backends (IndexedDB, AsyncStorage, MMKV, SecureStore, encrypted, compressed), a BroadcastChannel cross-tab transport, TanStack store sources, and framework hydration adapters (React/Solid/Vue/Svelte) — so the shape is proven to scale, not sketched. ([README § Extensibility guide](https://github.com/stainless-code/persist#extensibility-guide)) +**1. What it is.** A middleware over a _structural_ `PersistableSource` (`getState`/`setState`/`subscribe`) — not a store-specific wrapper. `persistSource(source, options)` persists anything reactive; `persistStore`/`persistAtom` are thin adapters for `@tanstack/store`. Unlike a passive `StoragePersister` (dump/load a blob), the middleware owns the lifecycle — hydrate-on-create, subscribe-writes gated until hydrated, trailing throttle, teardown — so the store stays a plain store and persistence is a sidekick. Three seams (`StateStorage` × `StorageCodec` × `PersistableSource`) make every backend × codec cell a one-line composition; the surface is exercised across codecs (seroval, zod), backends (IndexedDB, AsyncStorage, MMKV, SecureStore, encrypted, compressed), a BroadcastChannel cross-tab transport, TanStack store sources, and framework hydration adapters (React/Solid/Vue/Svelte/Angular/Preact) — so the shape is proven to scale, not sketched. ([README § Extensibility guide](https://github.com/stainless-code/persist#extensibility-guide)) **2. Sync vs async — one API.** No split into two APIs. `getItem` returning a native `Promise` switches the read path to async; sync backends stay sync and hydration settles before first paint (no flash, no `Suspense`). Async backends ride the same `getItem` Promise branch; UI gates on a hydration signal. `instanceof Promise` (not thenable duck-typing) so a stored value with a `then` field is never mistaken for a pending read. ([architecture § Sync vs async](https://github.com/stainless-code/persist/blob/main/docs/architecture.md#sync-vs-async)) **3. Where it lands vs `@tanstack/query-persist-client`.** Fact-checked against query's source. **Parity** — query has these too: `buster`, `maxAge`, `throttleTime` (`throttleMs`), `retry` (`retryWrite` shrink-or-give-up), a hydration gate (`useIsRestoring` / `PersistQueryClientProvider`), and framework hydration adapters (it ships React/Solid/Svelte/Angular/Preact). **Beyond** — a store-agnostic source (query is cache-bound), a codec seam independent of the backend (query couples `serialize`/`deserialize` to the persister factory), versioned `migrate` (query has only `buster`, no `version`), cross-tab sync, and schema validation (a `zod` codec). One deliberate divergence: `maxAge` is opt-in, not default-on — prefs shouldn't silently expire. ([README § Comparison](https://github.com/stainless-code/persist#comparison-with-other-persist-libraries)) -**4. How it could merge.** `StateStorage` + `StorageCodec` drop in; `persistSource` is the store-agnostic core a `persistStore`/`persistQuery`/`persistForm` family can share; `HydrationSignal` is the framework-agnostic subscribe target — React (`useSyncExternalStore`), Solid (`from`), Vue (`shallowRef` + `onScopeDispose`), and Svelte (runes `createSubscriber`, plus a `readable` store variant for Svelte 4) mount it. Contributable: the core + the TanStack adapter + the framework hydration adapters. No barrel — one subpath per optional peer, categorized to mirror the source (`./codecs/…`, `./backends/…`, `./transport/…`, `./sources/…`, `./frameworks/…`) — so the dep opt-in is the import. +**4. How it could merge.** `StateStorage` + `StorageCodec` drop in; `persistSource` is the store-agnostic core a `persistStore`/`persistQuery`/`persistForm` family can share; `HydrationSignal` is the framework-agnostic subscribe target — React (`useSyncExternalStore`), Solid (`from`), Vue (`shallowRef` + `onScopeDispose`), Svelte (runes `createSubscriber`, plus a `readable` store variant for Svelte 4), Angular (`signal` + `effect`), and Preact (`useSyncExternalStore` via preact/compat) mount it. Contributable: the core + the TanStack adapter + the framework hydration adapters. No barrel — one subpath per optional peer, categorized to mirror the source (`./codecs/…`, `./backends/…`, `./transport/…`, `./sources/…`, `./frameworks/…`) — so the dep opt-in is the import. **5. Links.** Repo: <https://github.com/stainless-code/persist>. README: [quick start](https://github.com/stainless-code/persist#quick-start) · [extensibility guide](https://github.com/stainless-code/persist#extensibility-guide) · [comparison vs zustand/redux/query/pinia](https://github.com/stainless-code/persist#comparison-with-other-persist-libraries) · [migration guide](https://github.com/stainless-code/persist#migrating-from-) · [recipes](https://github.com/stainless-code/persist#recipes). Architecture: [docs/architecture.md](https://github.com/stainless-code/persist/blob/main/docs/architecture.md). From acc7f9d505523c7577938ca8645ff17471e06ef8 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sat, 4 Jul 2026 21:55:56 +0300 Subject: [PATCH 32/77] =?UTF-8?q?docs:=20authoring-discipline=20audit=20(i?= =?UTF-8?q?ncremental)=20=E2=80=94=20Angular=20+=20Preact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/angular-preact-hydration.md | 8 +++--- docs/audits/2026-07-04-docs-adapters-roi.md | 28 ++++++++++----------- src/adapters/frameworks/angular.ts | 8 +++--- src/adapters/frameworks/preact.ts | 5 ++-- 4 files changed, 22 insertions(+), 27 deletions(-) diff --git a/.changeset/angular-preact-hydration.md b/.changeset/angular-preact-hydration.md index 5fa43b0..0ecb69e 100644 --- a/.changeset/angular-preact-hydration.md +++ b/.changeset/angular-preact-hydration.md @@ -2,9 +2,7 @@ "@stainless-code/persist": minor --- -Add `./frameworks/angular` and `./frameworks/preact` hydration adapters over the `HydrationSignal` seam — completing the framework adapter set (React, Solid, Vue, Svelte, Angular, Preact). +Add `./frameworks/angular` and `./frameworks/preact` hydration adapters over the `HydrationSignal` seam. -- `./frameworks/angular` (peer `@angular/core >=17.0.0`): `useHydrated(signal)` returns a readonly `Signal<boolean>` via Angular `signal()` + `effect()`. Call inside a component's injection context (`effect()` requires it); subscription cleaned up via `onCleanup`. `@if (hydrated())` in templates. -- `./frameworks/preact` (peer `preact >=10.19.0`): `useHydrated(signal)` returns `{ hydrated: boolean }` via `useSyncExternalStore` (preact/compat). Near-clone of `./frameworks/react`. `@ts-expect-error` on the 3-arg call (Preact types omit `getServerSnapshot`; runtime ignores it). - -Both render `true` on the server (no-op `PersistApi`). Each is its own subpath with the peer optional, no cross-entry value imports. Angular tested with a mocked `@angular/core` (signal/effect/onCleanup stub — no injection context needed); Preact tested with a mocked `preact/compat` (snapshot-only stub). Both pin the value contract; the reactive auto-update rides on the `HydrationSignal` contract pinned in `core/hydration.test.ts`. +- `./frameworks/angular` (peer `@angular/core >=17.0.0`): `useHydrated(signal)` → readonly `Signal<boolean>`. Call inside a component's injection context (`effect()` requires it). +- `./frameworks/preact` (peer `preact >=10.19.0`): `useHydrated(signal)` → `{ hydrated }` via `useSyncExternalStore` (preact/compat). `@ts-expect-error` on the 3-arg call (Preact types omit `getServerSnapshot`; runtime ignores it). diff --git a/docs/audits/2026-07-04-docs-adapters-roi.md b/docs/audits/2026-07-04-docs-adapters-roi.md index 8671be5..680d195 100644 --- a/docs/audits/2026-07-04-docs-adapters-roi.md +++ b/docs/audits/2026-07-04-docs-adapters-roi.md @@ -121,20 +121,20 @@ Ordered by ROI = impact ÷ effort. **Effort:** S / M / L. **Status as of 2026-07 ### Tier 2 — Build out the surface (high impact, medium effort) -| # | Action | Effort | -| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -| ✅ 10 | Encryption-at-rest codec (WebCrypto + codec). Headline "custom codec" example in JSDoc (`persist-core.ts:69`, `persist-idb.ts:52`, `skill:155`) with no shipped impl. | M | -| 11 | `examples/` monorepo workspace — runnable `tanstack-idb-react`, `tanstack-localstorage-react`, `nextjs-ssr`, `react-native-mmkv`. No runnable demo today. | M | -| 12 | Docs site (VitePress / Astro Starlight) — split wall-of-text README into Getting Started → Adapters → Recipes → Adapter authoring → Reference; host `docs/api/` under it. | M | -| 13 | TanStack Query persister bridge (`persistQueryClient`-shaped). JSDoc cites Query as reference design (`persist-core.ts:12,263`). Flagship integration. | M | -| ✅ 14 | Migration/porting guide — option mapping + conceptual diff vs zustand-persist / redux-persist / query-persist-client / pinia-persist. | S | -| ✅ 15 | Comparison table across the 4 incumbents. README paragraph → table. | S | -| ✅ 16 | Storage & codec decision matrices — lift + expand the skill's 4-row version to consumer docs. | S | -| ✅ 17 | `CompressionStream` codec — native API, ~S now; pairs with binary `TRaw`. | M | -| ✅ 18 | Node `fs` storage adapter — trivial `StateStorage`; unblocks server/SSR/CLI. | S | -| ✅ 19 | Pack-validation + semver gate in CI — `attw --pack` + `knip` + `publint`. Prevents shipping a broken `exports` map. | S | -| ✅ 33 | Angular-signals hydration adapter — `signal` + `effect`-based gate over `HydrationSignal`; peer `@angular/core`. Svelte/Solid/Vue shipped; Angular is the remaining framework adapter. | S | -| ✅ 34 | Preact hydration adapter — near-clone of `./frameworks/react` (`useSyncExternalStore`); peer `preact`. | S | +| # | Action | Effort | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| ✅ 10 | Encryption-at-rest codec (WebCrypto + codec). Headline "custom codec" example in JSDoc (`persist-core.ts:69`, `persist-idb.ts:52`, `skill:155`) with no shipped impl. | M | +| 11 | `examples/` monorepo workspace — runnable `tanstack-idb-react`, `tanstack-localstorage-react`, `nextjs-ssr`, `react-native-mmkv`. No runnable demo today. | M | +| 12 | Docs site (VitePress / Astro Starlight) — split wall-of-text README into Getting Started → Adapters → Recipes → Adapter authoring → Reference; host `docs/api/` under it. | M | +| 13 | TanStack Query persister bridge (`persistQueryClient`-shaped). JSDoc cites Query as reference design (`persist-core.ts:12,263`). Flagship integration. | M | +| ✅ 14 | Migration/porting guide — option mapping + conceptual diff vs zustand-persist / redux-persist / query-persist-client / pinia-persist. | S | +| ✅ 15 | Comparison table across the 4 incumbents. README paragraph → table. | S | +| ✅ 16 | Storage & codec decision matrices — lift + expand the skill's 4-row version to consumer docs. | S | +| ✅ 17 | `CompressionStream` codec — native API, ~S now; pairs with binary `TRaw`. | M | +| ✅ 18 | Node `fs` storage adapter — trivial `StateStorage`; unblocks server/SSR/CLI. | S | +| ✅ 19 | Pack-validation + semver gate in CI — `attw --pack` + `knip` + `publint`. Prevents shipping a broken `exports` map. | S | +| ✅ 33 | Angular-signals hydration adapter — `signal` + `effect`-based gate over `HydrationSignal`; peer `@angular/core`. | S | +| ✅ 34 | Preact hydration adapter — near-clone of `./frameworks/react` (`useSyncExternalStore`); peer `preact`. | S | ### Tier 3 — Maturity & polish (medium impact, medium effort) diff --git a/src/adapters/frameworks/angular.ts b/src/adapters/frameworks/angular.ts index 17d0816..359fe15 100644 --- a/src/adapters/frameworks/angular.ts +++ b/src/adapters/frameworks/angular.ts @@ -7,14 +7,12 @@ import type { HydrationSignal } from "../../core/hydration"; const alwaysTrue = signal(true); /** - * Mount a `HydrationSignal` into Angular signals. Returns a readonly - * `Signal<boolean>` — read it in a template or `computed`/`effect`. Call - * inside a component's injection context (`effect()` requires it); the - * subscription is cleaned up on context destroy. Null/undefined signal → - * always `true`. Renders `true` on the server. + * Mount a `HydrationSignal` into Angular signals. Call inside a component's + * injection context (`effect()` requires it). * * @example * ```ts + * import { useHydrated } from "@stainless-code/persist/frameworks/angular"; * // in a component * hydrated = useHydrated(prefsHydration); * // template: @if (hydrated()) { <Prefs /> } @else { <Skeleton /> } diff --git a/src/adapters/frameworks/preact.ts b/src/adapters/frameworks/preact.ts index 1ce50ab..24fb297 100644 --- a/src/adapters/frameworks/preact.ts +++ b/src/adapters/frameworks/preact.ts @@ -11,12 +11,11 @@ const noopSubscribe: (listener: () => void) => () => void = () => () => {}; const alwaysTrue: () => boolean = () => true; /** - * Mount a `HydrationSignal` into Preact via `useSyncExternalStore` (preact/compat). - * Returns `{ hydrated }` — gate UI on it. Null/undefined signal → `hydrated: true`. - * Renders `true` on the server. + * Mount a `HydrationSignal` into Preact. * * @example * ```ts + * import { useHydrated } from "@stainless-code/persist/frameworks/preact"; * const { hydrated } = useHydrated(prefsHydration); * if (!hydrated) return <Skeleton />; * ``` From 18209b0f6d77781eff1d461f76acb7390dc5645c Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sat, 4 Jul 2026 22:05:04 +0300 Subject: [PATCH 33/77] feat: bundle-size gate (#23) + packageManager/compat (#24) + FAQ (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 ✅. --- .github/workflows/ci.yml | 31 +++++++- .size-limit.json | 20 +++++ README.md | 38 ++++++++++ bun.lock | 82 ++++++++++++++++++++- docs/audits/2026-07-04-docs-adapters-roi.md | 18 ++--- package.json | 8 +- 6 files changed, 181 insertions(+), 16 deletions(-) create mode 100644 .size-limit.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e7ac5c..6482a68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,6 +137,21 @@ jobs: - name: Run pack validation run: bun run check:pack + size: + name: 📏 Bundle size + needs: [skip-ci, build] + if: needs['skip-ci'].outputs.skip != 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup + uses: ./.github/actions/setup + + - name: Run size limit + run: bun run size + audit: # Non-blocking — known-CVE visibility only; does not catch supply-chain malware. # Team policy: if this job is red on a dep/lockfile PR, triage before merge @@ -158,7 +173,18 @@ jobs: ci-complete: name: CI complete - needs: [skip-ci, format, lint, typecheck, test, test-dom, build, check-pack] + needs: + [ + skip-ci, + format, + lint, + typecheck, + test, + test-dom, + build, + check-pack, + size, + ] if: always() runs-on: ubuntu-latest steps: @@ -171,7 +197,8 @@ jobs: needs.test.result != 'success' || needs['test-dom'].result != 'success' || needs.build.result != 'success' || - needs['check-pack'].result != 'success' + needs['check-pack'].result != 'success' || + needs.size.result != 'success' ) run: exit 1 diff --git a/.size-limit.json b/.size-limit.json new file mode 100644 index 0000000..0296123 --- /dev/null +++ b/.size-limit.json @@ -0,0 +1,20 @@ +[ + { + "name": "core", + "path": "dist/core/index.mjs", + "limit": "2.5 KB", + "gzip": true + }, + { + "name": "react", + "path": "dist/frameworks/react.mjs", + "limit": "2 KB", + "gzip": true + }, + { + "name": "seroval", + "path": "dist/codecs/seroval.mjs", + "limit": "2 KB", + "gzip": true + } +] diff --git a/README.md b/README.md index 10db01d..43808b0 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Hydration-aware persistence middleware for any reactive store — storage × codec seams, TanStack Store adapters, and a React hydration hook. Store-agnostic via a structural `PersistableSource`; every "can it do X?" is a one-line composition instead of a feature request. +[![core size](https://img.shields.io/size-limit/label/gzip/.size-limit.json/core/stainless-code/persist)](https://github.com/stainless-code/persist/blob/main/.size-limit.json) + Jump to what you need — - [Install](#install) @@ -19,6 +21,8 @@ Jump to what you need — - [Recipes](#recipes) - [Writing a framework adapter](#writing-a-framework-adapter) - [Lifecycle in one paragraph](#lifecycle-in-one-paragraph) +- [Compatibility](#compatibility) +- [FAQ](#faq) - [API reference](#api-reference) ## Install @@ -728,6 +732,40 @@ export function hydratedRune(signal: HydrationSignal | null) { `persistSource` hydrates on create (skip with `skipHydration`; `rehydrate()` is awaitable), subscribe-writes on every `setState` (gated until hydrated; optional trailing `throttleMs`), and tears down completely via `destroy()` — required for non-singleton stores. Failures route to `onError` with a phase (`write`/`hydrate`/`migrate`/`crossTab`); the console fallback is dev-only. Payloads carry `version` (→ `migrate`), `timestamp` (→ `maxAge`), and `buster`; `retryWrite` implements shrink-or-give-up on quota errors with a write-generation guard so stale retries never clobber newer state. +## Compatibility + +| Runtime / dep | Supported range | +| ------------------------- | ----------------------- | +| `@stainless-code/persist` | 0.1.1 | +| Node | ^20.19.0 \|\| >=22.12.0 | +| Bun | >=1.0.0 | +| TypeScript | >=5.0 | +| React | ^18.0.0 \|\| ^19.0.0 | +| @tanstack/store | >=0.10.0 | + +## FAQ + +**Why does my UI flash?** +You're on an async backend (IndexedDB, AsyncStorage, SecureStore, Node fs) without gating on hydration. The store holds its constructor default until the read settles — wrong theme, empty filters, then a snap to persisted state. Gate persisted-dependent UI with your framework adapter: [`useHydrated`](#what-does-hydration-aware-mean) (`@stainless-code/persist/frameworks/react`), `hydratedRune` (`@stainless-code/persist/frameworks/svelte`), or `hydratedStore` (`@stainless-code/persist/frameworks/svelte-store`). Sync backends (`localStorage`, MMKV) at module load need no gate. Don't manually defer writes before hydration — the middleware already gates writes until hydrated; double-gating drops legitimate updates. + +**IDB cross-tab isn't syncing** +IndexedDB fires no `storage` events — `crossTab: true` alone does nothing on this backend. Import `@stainless-code/persist/transport/crosstab` (`createBroadcastCrossTab`), pass `bridge.crossTabEventTarget` as `crossTabEventTarget`, and wrap storage with `bridge.wrap(...)`. See [Cross-tab over IndexedDB](#cross-tab-over-indexeddb). + +**Quota exceeded / storage full** +Pass `retryWrite: ({ state, errorCount }) => ...` — return a smaller state to retry, or `undefined` to give up (last error routes to `onError`). Use `errorCount` as the aggressiveness dial. The write-generation guard ensures stale retries never clobber newer state. See [retryWrite — shrink-or-give-up on quota](#retrywrite--shrink-or-give-up-on-quota). + +**Set / Map / Date don't round-trip** +Default `jsonCodec` is plain JSON — rich types silently degrade. String-wire backends (`localStorage`, AsyncStorage): `@stainless-code/persist/codecs/seroval` (`serovalCodec` / `createSerovalStorage`). Structured-clone: `@stainless-code/persist/backends/idb` (`createIdbStorage` — no codec needed). Never pair `identityCodec` with a string-only backend. See [Choosing a codec](#choosing-a-codec). + +**How do I clear all persisted keys on logout?** +Create one `createPersistRegistry()` from core; pass `registry` to every `persistStore` / `persistSource`. On logout: `await registry.clearAll()` — wipes every registered key in one shot. See [Clear-all on logout](#clear-all-on-logout). + +**How do I encrypt at rest?** +Wrap any backend with `@stainless-code/persist/backends/encrypted` (`createEncryptedStorage`) — WebCrypto is async, so encryption lives at the storage seam, not in a sync codec: `createStorage(() => createEncryptedStorage(() => localStorage, { key }), serovalCodec())`. See [Recipes](#recipes) for compress-then-encrypt stacks. + +**The store is not a singleton — how do I clean up?** +Call `persist.destroy()` on unmount (e.g. `useEffect` cleanup). It detaches the source subscription, removes the cross-tab listener, unregisters from any `registry`, and flushes pending throttled writes. See [IndexedDB + React, end to end](#indexeddb--react-end-to-end) (non-singleton stores). + ## API reference Full type-level reference is generated by TypeDoc — not hosted yet; build locally: `bun run docs:api`, then open `docs/api/index.html`. The authoritative contract for each entry is its JSDoc (hover in your editor). diff --git a/bun.lock b/bun.lock index 616c512..c0dc152 100644 --- a/bun.lock +++ b/bun.lock @@ -5,11 +5,12 @@ "": { "name": "@stainless-code/persist", "devDependencies": { - "@angular/core": "^22.0.5", + "@angular/core": "22.0.5", "@arethetypeswrong/cli": "0.18.4", "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.31.0", "@react-native-async-storage/async-storage": "^3.1.1", + "@size-limit/preset-small-lib": "^12.1.0", "@stainless-code/codemap": "0.11.1", "@tanstack/intent": "0.3.4", "@tanstack/store": "0.11.0", @@ -28,12 +29,13 @@ "lint-staged": "17.0.8", "oxfmt": "0.56.0", "oxlint": "1.71.0", - "preact": "^10.29.4", + "preact": "10.29.4", "publint": "0.3.21", "react": "19.2.7", "react-dom": "19.2.7", "react-native-mmkv": "^4.3.2", "seroval": "1.5.4", + "size-limit": "^12.1.0", "solid-js": "^1.9.14", "svelte": "^5.56.4", "tsdown": "0.22.3", @@ -44,10 +46,12 @@ "zod": "^4.4.3", }, "peerDependencies": { + "@angular/core": ">=17.0.0", "@react-native-async-storage/async-storage": ">=1.0.0", "@tanstack/store": ">=0.10.0", "expo-secure-store": ">=12.0.0", "idb-keyval": ">=4.0.0", + "preact": ">=10.19.0", "react": "^18.0.0 || ^19.0.0", "react-native-mmkv": ">=4.0.0", "seroval": ">=1.0.0", @@ -57,10 +61,12 @@ "zod": ">=3.20.0", }, "optionalPeers": [ + "@angular/core", "@react-native-async-storage/async-storage", "@tanstack/store", "expo-secure-store", "idb-keyval", + "preact", "react", "react-native-mmkv", "seroval", @@ -286,6 +292,58 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], "@expo/cli": ["@expo/cli@57.0.4", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~57.0.2", "@expo/config-plugins": "~57.0.2", "@expo/devcert": "^1.2.1", "@expo/env": "~2.4.1", "@expo/image-utils": "^0.11.1", "@expo/inline-modules": "^0.1.1", "@expo/json-file": "^11.0.0", "@expo/log-box": "^57.0.0", "@expo/metro": "~56.0.0", "@expo/metro-config": "~57.0.3", "@expo/metro-file-map": "^57.0.0", "@expo/osascript": "^2.7.0", "@expo/package-manager": "^1.13.0", "@expo/plist": "^0.8.0", "@expo/prebuild-config": "^57.0.4", "@expo/require-utils": "^57.0.1", "@expo/router-server": "^57.0.1", "@expo/schema-utils": "^57.0.1", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.86.0", "accepts": "^1.3.8", "agent-cli-detector": "^0.1.2", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^57.0.0", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-7d+YW9PdGqgNI4dh9FTv+ZNE2xu1jV8xREDgl/7jiKNcOKdgby6ZAXufZX7iRotyxyu8fwjzETKMg7MkmYLJ8A=="], @@ -620,6 +678,12 @@ "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], + "@size-limit/esbuild": ["@size-limit/esbuild@12.1.0", "", { "dependencies": { "esbuild": "^0.28.0", "nanoid": "^5.1.7" }, "peerDependencies": { "size-limit": "12.1.0" } }, "sha512-Um6MVrX+05kIxI4+zk0ZByG9dA/Th1f+sfGc571D95BnCPc90/pl2+2OdsQuOyoWEbeAMqfcTKo0v07i+E65Vw=="], + + "@size-limit/file": ["@size-limit/file@12.1.0", "", { "peerDependencies": { "size-limit": "12.1.0" } }, "sha512-eGwDcIufnNnvJRzv3liDOn6MAOGgmOTUdpeGQ2KuRTlgIgO54AJH1ilvktlJc6PIjNfwpYY0dOGyap1QgM1swQ=="], + + "@size-limit/preset-small-lib": ["@size-limit/preset-small-lib@12.1.0", "", { "dependencies": { "@size-limit/esbuild": "12.1.0", "@size-limit/file": "12.1.0", "size-limit": "12.1.0" } }, "sha512-TVVQ/iuHbaGtHJrjur5s4XKYEyGk0nIwUAqhuzhKPbTyV9nYOH/laDelQ4vg3cGmm8sayRx998wxEdnwM/Yewg=="], + "@stainless-code/codemap": ["@stainless-code/codemap@0.11.1", "", { "dependencies": { "@clack/prompts": "1.5.1", "@modelcontextprotocol/sdk": "1.29.0", "better-sqlite3": "12.11.1", "chokidar": "5.0.0", "lightningcss": "1.32.0", "oxc-parser": "0.136.0", "oxc-resolver": "11.20.0", "package-manager-detector": "1.6.0", "tinyglobby": "0.2.17", "zod": "4.4.3" }, "bin": { "codemap": "dist/index.mjs" } }, "sha512-SZ5rnjq7P1m10hC40fET8Crr2vojwJSzOvNE+pJXhBY7D37U+Lb+ry1a9i7lLfOdgd1LEOkrDohbAzoWVxT03g=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -824,6 +888,8 @@ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "bytes-iec": ["bytes-iec@3.1.1", "", {}, "sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -974,6 +1040,8 @@ "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], @@ -1258,6 +1326,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + "linkify-it": ["linkify-it@5.0.2", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q=="], "lint-staged": ["lint-staged@17.0.8", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA=="], @@ -1374,7 +1444,9 @@ "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], - "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + "nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="], + + "nanospinner": ["nanospinner@1.2.2", "", { "dependencies": { "picocolors": "^1.1.1" } }, "sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA=="], "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], @@ -1628,6 +1700,8 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "size-limit": ["size-limit@12.1.0", "", { "dependencies": { "bytes-iec": "^3.1.1", "lilconfig": "^3.1.3", "nanospinner": "^1.2.2", "picocolors": "^1.1.1", "tinyglobby": "^0.2.16" }, "peerDependencies": { "jiti": "^2.0.0" }, "optionalPeers": ["jiti"], "bin": { "size-limit": "bin.js" } }, "sha512-VnDS2fycANrJFVPQwjaD+h+hkISY7EB3LsPsYWje4lBCjQwwsZLxjwwRwVJKHrcj2ZqyG+DdXykWm9mbZklZrw=="], + "skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="], "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], @@ -2002,6 +2076,8 @@ "plist/@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], + "postcss/nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + "pretty-format/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], diff --git a/docs/audits/2026-07-04-docs-adapters-roi.md b/docs/audits/2026-07-04-docs-adapters-roi.md index 680d195..4597b6a 100644 --- a/docs/audits/2026-07-04-docs-adapters-roi.md +++ b/docs/audits/2026-07-04-docs-adapters-roi.md @@ -138,15 +138,15 @@ Ordered by ROI = impact ÷ effort. **Effort:** S / M / L. **Status as of 2026-07 ### Tier 3 — Maturity & polish (medium impact, medium effort) -| # | Action | Effort | -| --- | ------------------------------------------------------------------------------------------------------------------------------- | ------ | -| 20 | npm provenance + signing in `release.yml` (`id-token: write` + `--provenance`). | S | -| 21 | Test matrix — real browser (Playwright) + Safari + SSR-framework (Next.js hydration). Today jsdom only. | M | -| 22 | Coverage gate. Suite is excellent but unmeasured. | S | -| 23 | Bundle-size badge + `size-limit` gate; advertise zero-dep core. | S | -| 24 | `packageManager` pin + TS consumer range + Node/React/TanStack compat table. | S | -| 25 | `zustand`/`jotai`/`valtio`/`mobx`/signals source adapters — decide ship-as-subpath vs recipe first. | S each | -| 27 | FAQ / troubleshooting page — lift from `skill:141` + "why does my UI flash" + "IDB cross-tab isn't syncing" + "quota exceeded". | S | +| # | Action | Effort | +| ----- | ------------------------------------------------------------------------------------------------------------------------------- | ------ | +| 20 | npm provenance + signing in `release.yml` (`id-token: write` + `--provenance`). | S | +| 21 | Test matrix — real browser (Playwright) + Safari + SSR-framework (Next.js hydration). Today jsdom only. | M | +| 22 | Coverage gate. Suite is excellent but unmeasured. | S | +| ✅ 23 | Bundle-size badge + `size-limit` gate; advertise zero-dep core. | S | +| ✅ 24 | `packageManager` pin + TS consumer range + Node/React/TanStack compat table. | S | +| 25 | `zustand`/`jotai`/`valtio`/`mobx`/signals source adapters — decide ship-as-subpath vs recipe first. | S each | +| ✅ 27 | FAQ / troubleshooting page — lift from `skill:141` + "why does my UI flash" + "IDB cross-tab isn't syncing" + "quota exceeded". | S | ### Tier 4 — Strategic bets (high impact, high effort) diff --git a/package.json b/package.json index 5922c2b..9fee4de 100644 --- a/package.json +++ b/package.json @@ -145,8 +145,9 @@ "lint:fix": "bun run lint --fix", "lint:fix:changes": "bun scripts/run-on-changed-files.ts lint:fix", "prepare": "husky || true", - "prepublishOnly": "bun run check && bun run intent:validate && bun run check:pack", + "prepublishOnly": "bun run check && bun run intent:validate && bun run check:pack && bun run size", "release": "changeset publish", + "size": "size-limit", "test": "bun test ./src", "test:dom": "vitest run", "typecheck": "tsgo --noEmit", @@ -158,6 +159,7 @@ "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.31.0", "@react-native-async-storage/async-storage": "^3.1.1", + "@size-limit/preset-small-lib": "12.1.0", "@stainless-code/codemap": "0.11.1", "@tanstack/intent": "0.3.4", "@tanstack/store": "0.11.0", @@ -182,6 +184,7 @@ "react-dom": "19.2.7", "react-native-mmkv": "^4.3.2", "seroval": "1.5.4", + "size-limit": "12.1.0", "solid-js": "^1.9.14", "svelte": "^5.56.4", "tsdown": "0.22.3", @@ -250,5 +253,6 @@ "engines": { "bun": ">=1.0.0", "node": "^20.19.0 || >=22.12.0" - } + }, + "packageManager": "bun@1.3.14" } From bfea0a05e7a4a33dc349f9671c8f0080a640e07b Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 00:52:58 +0300 Subject: [PATCH 34/77] =?UTF-8?q?feat(ci):=20coverage=20gate=20(#22)=20?= =?UTF-8?q?=E2=80=94=2090%=20line=20threshold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `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 ✅. --- .github/workflows/ci.yml | 4 ++-- docs/audits/2026-07-04-docs-adapters-roi.md | 2 +- package.json | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6482a68..d6c16f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,8 +89,8 @@ jobs: - name: Setup uses: ./.github/actions/setup - - name: Run unit tests - run: bun run test + - name: Run unit tests with coverage gate + run: bun run test:coverage test-dom: name: 🌐 Test (DOM) diff --git a/docs/audits/2026-07-04-docs-adapters-roi.md b/docs/audits/2026-07-04-docs-adapters-roi.md index 4597b6a..a2333c6 100644 --- a/docs/audits/2026-07-04-docs-adapters-roi.md +++ b/docs/audits/2026-07-04-docs-adapters-roi.md @@ -142,7 +142,7 @@ Ordered by ROI = impact ÷ effort. **Effort:** S / M / L. **Status as of 2026-07 | ----- | ------------------------------------------------------------------------------------------------------------------------------- | ------ | | 20 | npm provenance + signing in `release.yml` (`id-token: write` + `--provenance`). | S | | 21 | Test matrix — real browser (Playwright) + Safari + SSR-framework (Next.js hydration). Today jsdom only. | M | -| 22 | Coverage gate. Suite is excellent but unmeasured. | S | +| ✅ 22 | Coverage gate. Suite is excellent but unmeasured. | S | | ✅ 23 | Bundle-size badge + `size-limit` gate; advertise zero-dep core. | S | | ✅ 24 | `packageManager` pin + TS consumer range + Node/React/TanStack compat table. | S | | 25 | `zustand`/`jotai`/`valtio`/`mobx`/signals source adapters — decide ship-as-subpath vs recipe first. | S each | diff --git a/package.json b/package.json index 9fee4de..8c38d0e 100644 --- a/package.json +++ b/package.json @@ -145,10 +145,11 @@ "lint:fix": "bun run lint --fix", "lint:fix:changes": "bun scripts/run-on-changed-files.ts lint:fix", "prepare": "husky || true", - "prepublishOnly": "bun run check && bun run intent:validate && bun run check:pack && bun run size", + "prepublishOnly": "bun run check && bun run intent:validate && bun run check:pack && bun run size && bun run test:coverage", "release": "changeset publish", "size": "size-limit", "test": "bun test ./src", + "test:coverage": "bun test ./src --coverage --coverage-threshold=0.90", "test:dom": "vitest run", "typecheck": "tsgo --noEmit", "version": "changeset version && bun scripts/sync-skill-versions.ts && bun run format CHANGELOG.md" From 4b67810bfad3b1943cc8e1d117bb141fb016ed60 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 00:55:58 +0300 Subject: [PATCH 35/77] =?UTF-8?q?docs:=20authoring-discipline=20audit=20(i?= =?UTF-8?q?ncremental)=20=E2=80=94=20compat=20+=20FAQ=20+=20audit=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 28 ++++++++++----------- docs/audits/2026-07-04-docs-adapters-roi.md | 18 ++++++------- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 43808b0..212b6fd 100644 --- a/README.md +++ b/README.md @@ -734,37 +734,35 @@ export function hydratedRune(signal: HydrationSignal | null) { ## Compatibility -| Runtime / dep | Supported range | -| ------------------------- | ----------------------- | -| `@stainless-code/persist` | 0.1.1 | -| Node | ^20.19.0 \|\| >=22.12.0 | -| Bun | >=1.0.0 | -| TypeScript | >=5.0 | -| React | ^18.0.0 \|\| ^19.0.0 | -| @tanstack/store | >=0.10.0 | +| Runtime / dep | Supported range | +| --------------- | ----------------------- | +| Node | ^20.19.0 \|\| >=22.12.0 | +| Bun | >=1.0.0 | +| React | ^18.0.0 \|\| ^19.0.0 | +| @tanstack/store | >=0.10.0 | ## FAQ **Why does my UI flash?** -You're on an async backend (IndexedDB, AsyncStorage, SecureStore, Node fs) without gating on hydration. The store holds its constructor default until the read settles — wrong theme, empty filters, then a snap to persisted state. Gate persisted-dependent UI with your framework adapter: [`useHydrated`](#what-does-hydration-aware-mean) (`@stainless-code/persist/frameworks/react`), `hydratedRune` (`@stainless-code/persist/frameworks/svelte`), or `hydratedStore` (`@stainless-code/persist/frameworks/svelte-store`). Sync backends (`localStorage`, MMKV) at module load need no gate. Don't manually defer writes before hydration — the middleware already gates writes until hydrated; double-gating drops legitimate updates. +Async backend (IndexedDB, AsyncStorage, SecureStore, Node fs) without a hydration gate. Gate with your framework adapter — [`useHydrated`](#what-does-hydration-aware-mean), `hydratedRune`, or `hydratedStore`. Don't manually defer writes — the middleware already gates them until hydrated; double-gating drops legitimate updates. **IDB cross-tab isn't syncing** -IndexedDB fires no `storage` events — `crossTab: true` alone does nothing on this backend. Import `@stainless-code/persist/transport/crosstab` (`createBroadcastCrossTab`), pass `bridge.crossTabEventTarget` as `crossTabEventTarget`, and wrap storage with `bridge.wrap(...)`. See [Cross-tab over IndexedDB](#cross-tab-over-indexeddb). +IndexedDB fires no `storage` events. Use `@stainless-code/persist/transport/crosstab` (`createBroadcastCrossTab`) as the `crossTabEventTarget` + `bridge.wrap(...)`. See [Cross-tab over IndexedDB](#cross-tab-over-indexeddb). **Quota exceeded / storage full** -Pass `retryWrite: ({ state, errorCount }) => ...` — return a smaller state to retry, or `undefined` to give up (last error routes to `onError`). Use `errorCount` as the aggressiveness dial. The write-generation guard ensures stale retries never clobber newer state. See [retryWrite — shrink-or-give-up on quota](#retrywrite--shrink-or-give-up-on-quota). +`retryWrite: ({ state, errorCount }) => ...` — shrink to retry, `undefined` to give up. The write-generation guard ensures stale retries never clobber newer state. See [retryWrite — shrink-or-give-up on quota](#retrywrite--shrink-or-give-up-on-quota). **Set / Map / Date don't round-trip** -Default `jsonCodec` is plain JSON — rich types silently degrade. String-wire backends (`localStorage`, AsyncStorage): `@stainless-code/persist/codecs/seroval` (`serovalCodec` / `createSerovalStorage`). Structured-clone: `@stainless-code/persist/backends/idb` (`createIdbStorage` — no codec needed). Never pair `identityCodec` with a string-only backend. See [Choosing a codec](#choosing-a-codec). +Use `@stainless-code/persist/codecs/seroval` for string-wire backends, or `@stainless-code/persist/backends/idb` (`createIdbStorage` — structured-clone, no codec). Never pair `identityCodec` with a string-only backend. See [Choosing a codec](#choosing-a-codec). **How do I clear all persisted keys on logout?** -Create one `createPersistRegistry()` from core; pass `registry` to every `persistStore` / `persistSource`. On logout: `await registry.clearAll()` — wipes every registered key in one shot. See [Clear-all on logout](#clear-all-on-logout). +`createPersistRegistry()` from core; pass `registry` to each store; `await registry.clearAll()`. See [Clear-all on logout](#clear-all-on-logout). **How do I encrypt at rest?** -Wrap any backend with `@stainless-code/persist/backends/encrypted` (`createEncryptedStorage`) — WebCrypto is async, so encryption lives at the storage seam, not in a sync codec: `createStorage(() => createEncryptedStorage(() => localStorage, { key }), serovalCodec())`. See [Recipes](#recipes) for compress-then-encrypt stacks. +`@stainless-code/persist/backends/encrypted` (`createEncryptedStorage`) — wraps any backend with AES-GCM. See [Recipes](#recipes) for compress-then-encrypt stacks. **The store is not a singleton — how do I clean up?** -Call `persist.destroy()` on unmount (e.g. `useEffect` cleanup). It detaches the source subscription, removes the cross-tab listener, unregisters from any `registry`, and flushes pending throttled writes. See [IndexedDB + React, end to end](#indexeddb--react-end-to-end) (non-singleton stores). +`persist.destroy()` on unmount (e.g. `useEffect` cleanup). See [IndexedDB + React, end to end](#indexeddb--react-end-to-end). ## API reference diff --git a/docs/audits/2026-07-04-docs-adapters-roi.md b/docs/audits/2026-07-04-docs-adapters-roi.md index a2333c6..b9853c4 100644 --- a/docs/audits/2026-07-04-docs-adapters-roi.md +++ b/docs/audits/2026-07-04-docs-adapters-roi.md @@ -138,15 +138,15 @@ Ordered by ROI = impact ÷ effort. **Effort:** S / M / L. **Status as of 2026-07 ### Tier 3 — Maturity & polish (medium impact, medium effort) -| # | Action | Effort | -| ----- | ------------------------------------------------------------------------------------------------------------------------------- | ------ | -| 20 | npm provenance + signing in `release.yml` (`id-token: write` + `--provenance`). | S | -| 21 | Test matrix — real browser (Playwright) + Safari + SSR-framework (Next.js hydration). Today jsdom only. | M | -| ✅ 22 | Coverage gate. Suite is excellent but unmeasured. | S | -| ✅ 23 | Bundle-size badge + `size-limit` gate; advertise zero-dep core. | S | -| ✅ 24 | `packageManager` pin + TS consumer range + Node/React/TanStack compat table. | S | -| 25 | `zustand`/`jotai`/`valtio`/`mobx`/signals source adapters — decide ship-as-subpath vs recipe first. | S each | -| ✅ 27 | FAQ / troubleshooting page — lift from `skill:141` + "why does my UI flash" + "IDB cross-tab isn't syncing" + "quota exceeded". | S | +| # | Action | Effort | +| ----- | ------------------------------------------------------------------------------------------------------- | ------ | +| 20 | npm provenance + signing in `release.yml` (`id-token: write` + `--provenance`). | S | +| 21 | Test matrix — real browser (Playwright) + Safari + SSR-framework (Next.js hydration). Today jsdom only. | M | +| ✅ 22 | Coverage gate. | S | +| ✅ 23 | Bundle-size badge + `size-limit` gate. | S | +| ✅ 24 | `packageManager` pin + TS consumer range + Node/React/TanStack compat table. | S | +| 25 | `zustand`/`jotai`/`valtio`/`mobx`/signals source adapters — decide ship-as-subpath vs recipe first. | S each | +| ✅ 27 | FAQ / troubleshooting page. | S | ### Tier 4 — Strategic bets (high impact, high effort) From d0ac058e684402a33b17b4571d8774a1ce9f996f Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 15:19:06 +0300 Subject: [PATCH 36/77] feat(sources): add zustand + jotai + valtio + mobx source adapters (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ✅. --- .changeset/store-adapters.md | 12 ++ README.md | 95 ++++++++++++ bun.lock | 88 +++++------ docs/architecture.md | 6 +- docs/audits/2026-07-04-docs-adapters-roi.md | 2 +- package.json | 40 ++++- src/adapters/sources/jotai.test.ts | 146 +++++++++++++++++++ src/adapters/sources/jotai.ts | 52 +++++++ src/adapters/sources/mobx.test.ts | 149 +++++++++++++++++++ src/adapters/sources/mobx.ts | 37 +++++ src/adapters/sources/valtio.test.ts | 154 ++++++++++++++++++++ src/adapters/sources/valtio.ts | 37 +++++ src/adapters/sources/zustand.test.ts | 141 ++++++++++++++++++ src/adapters/sources/zustand.ts | 34 +++++ tsdown.config.ts | 8 + typedoc.json | 4 + 16 files changed, 950 insertions(+), 55 deletions(-) create mode 100644 .changeset/store-adapters.md create mode 100644 src/adapters/sources/jotai.test.ts create mode 100644 src/adapters/sources/jotai.ts create mode 100644 src/adapters/sources/mobx.test.ts create mode 100644 src/adapters/sources/mobx.ts create mode 100644 src/adapters/sources/valtio.test.ts create mode 100644 src/adapters/sources/valtio.ts create mode 100644 src/adapters/sources/zustand.test.ts create mode 100644 src/adapters/sources/zustand.ts diff --git a/.changeset/store-adapters.md b/.changeset/store-adapters.md new file mode 100644 index 0000000..9395d5f --- /dev/null +++ b/.changeset/store-adapters.md @@ -0,0 +1,12 @@ +--- +"@stainless-code/persist": minor +--- + +Add four source adapters over the `PersistableSource` seam — each is a thin `persistSource` wrapper mapping the library's store API: + +- `./sources/zustand` (peer `zustand >=4.0.0`): `persistZustand(store, opts)` — zustand's `getState`/`setState`/`subscribe` map directly. +- `./sources/jotai` (peer `jotai >=2.0.0`): `persistJotai(store, atom, opts)` — wraps a writable atom + jotai `Store`; replace-merge default (like `persistAtom`) so primitive atoms don't hydrate to `{}`. +- `./sources/valtio` (peer `valtio >=1.0.0`): `persistValtio(proxyObject, opts)` — `snapshot` for reads, `Object.assign` for writes, `subscribe` for changes. +- `./sources/mobx` (peer `mobx >=6.0.0`): `persistMobx(observable, opts)` — `toJS` for reads, `Object.assign` for writes, `observe` for changes. + +Each is its own subpath with the peer optional, no cross-entry value imports. README "Wrapping your store" recipe section shows both the shipped adapter + the underlying `persistSource` mapping for customization. diff --git a/README.md b/README.md index 212b6fd..8f17f3a 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,10 @@ Each subpath owns its dependency as an **optional peer** — import only the ent | `@stainless-code/persist/backends/node-fs` | none (Node built-in) | | `@stainless-code/persist/transport/crosstab` | none (web global) | | `@stainless-code/persist/sources/tanstack-store` | `@tanstack/store` | +| `@stainless-code/persist/sources/zustand` | `zustand` | +| `@stainless-code/persist/sources/jotai` | `jotai` | +| `@stainless-code/persist/sources/valtio` | `valtio` | +| `@stainless-code/persist/sources/mobx` | `mobx` | | `@stainless-code/persist/frameworks/react` | `react` | | `@stainless-code/persist/frameworks/solid` | `solid-js` | | `@stainless-code/persist/frameworks/vue` | `vue` | @@ -408,6 +412,10 @@ Persistence middleware for any `getState`/`setState`/`subscribe` store (TanStack | `@stainless-code/persist/backends/node-fs` | `nodeFsStateStorage` (one file per key) | none (Node built-in) | | `@stainless-code/persist/transport/crosstab` | `createBroadcastCrossTab` | none (web global) | | `@stainless-code/persist/sources/tanstack-store` | `persistStore`, `persistAtom` | `@tanstack/store` (types only) | +| `@stainless-code/persist/sources/zustand` | `persistZustand` | `zustand` | +| `@stainless-code/persist/sources/jotai` | `persistJotai` | `jotai` | +| `@stainless-code/persist/sources/valtio` | `persistValtio` | `valtio` | +| `@stainless-code/persist/sources/mobx` | `persistMobx` | `mobx` | | `@stainless-code/persist/frameworks/react` | `useHydrated` React hook | `react` | | `@stainless-code/persist/frameworks/solid` | `useHydrated` (Solid `Accessor<boolean>`) | `solid-js` | | `@stainless-code/persist/frameworks/vue` | `useHydrated` (Vue `Ref<boolean>`) | `vue` | @@ -667,6 +675,93 @@ persistStore(store, { }); ``` +### Wrapping your store + +Every shipped source adapter is a thin `persistSource` wrapper — import the subpath, pass your store, wire storage. Redux, signals, hand-rolled atoms: same seam. + +**zustand** + +```ts +import { create } from "zustand"; +import { createJSONStorage } from "@stainless-code/persist"; +import { persistZustand } from "@stainless-code/persist/sources/zustand"; + +const usePrefs = create(() => ({ theme: "light" as const })); +const persist = persistZustand(usePrefs, { + name: "app:prefs:v1", + storage: createJSONStorage(() => localStorage), +}); +``` + +Or pass a custom `PersistableSource` to `persistSource` directly — the adapter is a thin wrapper over `getState`/`setState`/`subscribe`. + +**jotai** + +```ts +import { atom, createStore } from "jotai"; +import { createJSONStorage } from "@stainless-code/persist"; +import { persistJotai } from "@stainless-code/persist/sources/jotai"; + +const store = createStore(); +const themeAtom = atom<"light" | "dark">("light"); +const persist = persistJotai(store, themeAtom, { + name: "app:theme:v1", + storage: createJSONStorage(() => localStorage), +}); +``` + +Or pass a custom `PersistableSource` to `persistSource` directly — the adapter is a thin wrapper over `getState`/`setState`/`subscribe`. + +**valtio** + +```ts +import { proxy } from "valtio"; +import { createJSONStorage } from "@stainless-code/persist"; +import { persistValtio } from "@stainless-code/persist/sources/valtio"; + +const prefs = proxy({ theme: "light" as const }); +const persist = persistValtio(prefs, { + name: "app:prefs:v1", + storage: createJSONStorage(() => localStorage), +}); +``` + +Or pass a custom `PersistableSource` to `persistSource` directly — the adapter is a thin wrapper over `getState`/`setState`/`subscribe`. + +**mobx** + +```ts +import { observable } from "mobx"; +import { createJSONStorage } from "@stainless-code/persist"; +import { persistMobx } from "@stainless-code/persist/sources/mobx"; + +const prefs = observable.object({ theme: "light" as const }); +const persist = persistMobx(prefs, { + name: "app:prefs:v1", + storage: createJSONStorage(() => localStorage), +}); +``` + +Or pass a custom `PersistableSource` to `persistSource` directly — the adapter is a thin wrapper over `getState`/`setState`/`subscribe`. + +**Any other store** + +```ts +import { createJSONStorage, persistSource } from "@stainless-code/persist"; + +const persist = persistSource( + { + getState: () => myStore.getState(), + setState: (updater) => myStore.setState(updater), + subscribe: (listener) => myStore.subscribe(() => listener()), + }, + { + name: "app:custom:v1", + storage: createJSONStorage(() => localStorage), + }, +); +``` + ### Cross-tab over IndexedDB ```ts diff --git a/bun.lock b/bun.lock index c0dc152..5928d6e 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.31.0", "@react-native-async-storage/async-storage": "^3.1.1", - "@size-limit/preset-small-lib": "^12.1.0", + "@size-limit/preset-small-lib": "12.1.0", "@stainless-code/codemap": "0.11.1", "@tanstack/intent": "0.3.4", "@tanstack/store": "0.11.0", @@ -24,9 +24,11 @@ "expo-secure-store": "^57.0.0", "husky": "9.1.7", "idb-keyval": "6.2.6", + "jotai": "^2.20.1", "jsdom": "29.1.1", "knip": "6.24.0", "lint-staged": "17.0.8", + "mobx": "^6.16.1", "oxfmt": "0.56.0", "oxlint": "1.71.0", "preact": "10.29.4", @@ -35,15 +37,17 @@ "react-dom": "19.2.7", "react-native-mmkv": "^4.3.2", "seroval": "1.5.4", - "size-limit": "^12.1.0", + "size-limit": "12.1.0", "solid-js": "^1.9.14", "svelte": "^5.56.4", "tsdown": "0.22.3", "typedoc": "0.28.19", "typescript": "6.0.3", + "valtio": "^2.3.2", "vitest": "4.1.9", "vue": "^3.5.39", "zod": "^4.4.3", + "zustand": "^5.0.14", }, "peerDependencies": { "@angular/core": ">=17.0.0", @@ -100,7 +104,7 @@ "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], - "@babel/generator": ["@babel/generator@8.0.0", "", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g=="], + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], @@ -140,7 +144,7 @@ "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - "@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="], + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], "@babel/plugin-proposal-decorators": ["@babel/plugin-proposal-decorators@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-decorators": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg=="], @@ -1272,6 +1276,8 @@ "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "jotai": ["jotai@2.20.1", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-dnuKfU/GLi8B28RRMjQ3AfoN7kfzP8o41+AX2FmITZqEMY8PHnjABq+VkEooomLwYaGjda+pgy0yFSjaHX/ZPg=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], @@ -1436,6 +1442,8 @@ "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + "mobx": ["mobx@6.16.1", "", {}, "sha512-syNcDdX3KT+Jq3je6eGjBhuc24Z68td2VG0zNFqRswaE433D9SNH5VRy/xrGbJsUixfppLLccXhAW9JSf6n+SQ=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -1564,6 +1572,8 @@ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "proxy-compare": ["proxy-compare@3.0.1", "", {}, "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q=="], + "publint": ["publint@0.3.21", "", { "dependencies": { "@publint/pack": "^0.1.4", "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "sade": "^1.8.1" }, "bin": { "publint": "src/cli.js" } }, "sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ=="], "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], @@ -1852,6 +1862,8 @@ "validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="], + "valtio": ["valtio@2.3.2", "", { "dependencies": { "proxy-compare": "^3.0.1" }, "peerDependencies": { "@types/react": ">=18.0.0", "react": ">=18.0.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-YXhWQei9IN/ZDce9rhL3trCq9+vVq8M1gWmKVdP3YSZ2gxsmmNWVbxXwf9yG6ffu/dAvAD91nevg8xirGr4Dhg=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "vite": ["vite@8.1.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.16", "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA=="], @@ -1916,16 +1928,12 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "@arethetypeswrong/core/typescript": ["typescript@5.6.1-rc", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ=="], - - "@babel/core/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + "zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="], - "@babel/core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@arethetypeswrong/core/typescript": ["typescript@5.6.1-rc", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/generator/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], - "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1934,16 +1942,8 @@ "@babel/helper-create-regexp-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/parser/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], - "@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/template/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - - "@babel/traverse/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - - "@babel/traverse/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "@expo/cli/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], "@expo/cli/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], @@ -1958,8 +1958,6 @@ "@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], - "@expo/metro-config/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - "@expo/ws-tunnel/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], @@ -1976,25 +1974,19 @@ "@quansync/fs/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], - "@react-native/codegen/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "@react-native/dev-middleware/serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], "@stainless-code/codemap/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], - "@vue/compiler-core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "@vue/compiler-sfc/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "ast-kit/@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="], - "babel-preset-expo/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], @@ -2046,22 +2038,12 @@ "marked-terminal/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "metro/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - - "metro/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], "metro/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="], "metro-babel-transformer/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="], - "metro-transform-plugins/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - - "metro-transform-worker/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - - "metro-transform-worker/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -2096,8 +2078,12 @@ "rolldown/@oxc-project/types": ["@oxc-project/types@0.138.0", "", {}, "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA=="], + "rolldown-plugin-dts/@babel/generator": ["@babel/generator@8.0.0", "", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g=="], + "rolldown-plugin-dts/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], + "rolldown-plugin-dts/@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="], + "rolldown-plugin-dts/get-tsconfig": ["get-tsconfig@5.0.0-beta.5", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ=="], "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], @@ -2132,14 +2118,6 @@ "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], - "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - - "@babel/generator/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - - "@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - - "@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - "@expo/cli/accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "@expo/cli/accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], @@ -2152,13 +2130,11 @@ "@expo/cli/send/fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], - "@expo/metro-config/@babel/generator/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], "@react-native/dev-middleware/serve-static/send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], - "babel-preset-expo/@babel/generator/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "ast-kit/@babel/parser/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], "cli-highlight/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], @@ -2282,8 +2258,6 @@ "metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="], - "metro-transform-plugins/@babel/generator/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "metro/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="], "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], @@ -2306,6 +2280,10 @@ "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "rolldown-plugin-dts/@babel/generator/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], + + "rolldown-plugin-dts/@babel/parser/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], + "terminal-link/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "@expo/cli/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -2316,6 +2294,10 @@ "@react-native/dev-middleware/serve-static/send/fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + "ast-kit/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], + + "ast-kit/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], + "cli-highlight/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "knip/oxc-resolver/@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], @@ -2336,6 +2318,10 @@ "ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "rolldown-plugin-dts/@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], + + "rolldown-plugin-dts/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], + "@react-native/dev-middleware/serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "log-symbols/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], diff --git a/docs/architecture.md b/docs/architecture.md index 541c531..e544723 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,6 +30,10 @@ Persistence is bound to a structural `PersistableSource` (`getState` / `setState | `@stainless-code/persist/backends/node-fs` | `adapters/backends/node-fs` | none (Node built-in) | | `@stainless-code/persist/transport/crosstab` | `adapters/transport/crosstab` | none (web global) | | `@stainless-code/persist/sources/tanstack-store` | `adapters/sources/tanstack-store` | `@tanstack/store` (types only) | +| `@stainless-code/persist/sources/zustand` | `adapters/sources/zustand` | `zustand` | +| `@stainless-code/persist/sources/jotai` | `adapters/sources/jotai` | `jotai` | +| `@stainless-code/persist/sources/valtio` | `adapters/sources/valtio` | `valtio` | +| `@stainless-code/persist/sources/mobx` | `adapters/sources/mobx` | `mobx` | | `@stainless-code/persist/frameworks/react` | `adapters/frameworks/react` | `react` | | `@stainless-code/persist/frameworks/solid` | `adapters/frameworks/solid` | `solid-js` | | `@stainless-code/persist/frameworks/vue` | `adapters/frameworks/vue` | `vue` | @@ -49,7 +53,7 @@ No barrel — importing a subpath is the dependency opt-in. Each subpath entry o - `codecs/` — `StorageCodec` adapters (seroval, zod) - `backends/` — `StateStorage` adapters + wrappers (idb, async-storage, mmkv, secure-store, encrypted, compressed, node-fs) - `transport/` — `CrossTabEventTarget` adapters (crosstab — BroadcastChannel bridge) - - `sources/` — `PersistableSource` adapters (tanstack-store) + - `sources/` — `PersistableSource` adapters (tanstack-store, zustand, jotai, valtio, mobx) - `frameworks/` — `HydrationSignal` framework adapters (react, solid, vue, svelte, svelte-store, angular, preact) A per-entry self-check test pins the invariant: every adapter's relative imports resolve into `core/` (no cross-adapter coupling). `dist/` mirrors `src/` (`dist/<seam>/<name>.mjs` via tsdown's record-form `entry` keyed by `<seam>/<name>`) — src folder → tsdown key → dist path → subpath, all 1:1. diff --git a/docs/audits/2026-07-04-docs-adapters-roi.md b/docs/audits/2026-07-04-docs-adapters-roi.md index b9853c4..b2c4f8e 100644 --- a/docs/audits/2026-07-04-docs-adapters-roi.md +++ b/docs/audits/2026-07-04-docs-adapters-roi.md @@ -145,7 +145,7 @@ Ordered by ROI = impact ÷ effort. **Effort:** S / M / L. **Status as of 2026-07 | ✅ 22 | Coverage gate. | S | | ✅ 23 | Bundle-size badge + `size-limit` gate. | S | | ✅ 24 | `packageManager` pin + TS consumer range + Node/React/TanStack compat table. | S | -| 25 | `zustand`/`jotai`/`valtio`/`mobx`/signals source adapters — decide ship-as-subpath vs recipe first. | S each | +| ✅ 25 | `zustand`/`jotai`/`valtio`/`mobx` source adapters — shipped + recipes. | S each | | ✅ 27 | FAQ / troubleshooting page. | S | ### Tier 4 — Strategic bets (high impact, high effort) diff --git a/package.json b/package.json index 8c38d0e..097c528 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,22 @@ "types": "./dist/sources/tanstack-store.d.mts", "import": "./dist/sources/tanstack-store.mjs" }, + "./sources/zustand": { + "types": "./dist/sources/zustand.d.mts", + "import": "./dist/sources/zustand.mjs" + }, + "./sources/jotai": { + "types": "./dist/sources/jotai.d.mts", + "import": "./dist/sources/jotai.mjs" + }, + "./sources/valtio": { + "types": "./dist/sources/valtio.d.mts", + "import": "./dist/sources/valtio.mjs" + }, + "./sources/mobx": { + "types": "./dist/sources/mobx.d.mts", + "import": "./dist/sources/mobx.mjs" + }, "./frameworks/react": { "types": "./dist/frameworks/react.d.mts", "import": "./dist/frameworks/react.mjs" @@ -174,9 +190,11 @@ "expo-secure-store": "^57.0.0", "husky": "9.1.7", "idb-keyval": "6.2.6", + "jotai": "2.20.1", "jsdom": "29.1.1", "knip": "6.24.0", "lint-staged": "17.0.8", + "mobx": "6.16.1", "oxfmt": "0.56.0", "oxlint": "1.71.0", "preact": "10.29.4", @@ -191,9 +209,11 @@ "tsdown": "0.22.3", "typedoc": "0.28.19", "typescript": "6.0.3", + "valtio": "2.3.2", "vitest": "4.1.9", "vue": "^3.5.39", - "zod": "^4.4.3" + "zod": "^4.4.3", + "zustand": "5.0.14" }, "peerDependencies": { "@angular/core": ">=17.0.0", @@ -201,14 +221,18 @@ "@tanstack/store": ">=0.10.0", "expo-secure-store": ">=12.0.0", "idb-keyval": ">=4.0.0", + "jotai": ">=2.0.0", + "mobx": ">=6.0.0", "preact": ">=10.19.0", "react": "^18.0.0 || ^19.0.0", "react-native-mmkv": ">=4.0.0", "seroval": ">=1.0.0", "solid-js": ">=1.6.0", "svelte": ">=3.0.0", + "valtio": ">=1.0.0", "vue": ">=3.3.0", - "zod": ">=3.20.0" + "zod": ">=3.20.0", + "zustand": ">=4.0.0" }, "peerDependenciesMeta": { "seroval": { @@ -249,6 +273,18 @@ }, "expo-secure-store": { "optional": true + }, + "zustand": { + "optional": true + }, + "jotai": { + "optional": true + }, + "valtio": { + "optional": true + }, + "mobx": { + "optional": true } }, "engines": { diff --git a/src/adapters/sources/jotai.test.ts b/src/adapters/sources/jotai.test.ts new file mode 100644 index 0000000..21273b6 --- /dev/null +++ b/src/adapters/sources/jotai.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it } from "bun:test"; + +import type { WritableAtom } from "jotai"; + +import { createJSONStorage } from "../../core/persist-core"; +import type { StateStorage } from "../../core/persist-core"; +import { persistJotai } from "./jotai"; + +class MemoryStorage implements StateStorage { + private store = new Map<string, string>(); + + clear() { + this.store.clear(); + } + + getItem(key: string) { + return this.store.get(key) ?? null; + } + + removeItem(key: string) { + this.store.delete(key); + } + + setItem(key: string, value: string) { + this.store.set(key, value); + } +} + +function createMockJotaiStore<T>(initialValue: T) { + let value = initialValue; + const listeners = new Set<() => void>(); + const atom = { toString: () => "mock-atom" } as WritableAtom< + T, + [T | ((prev: T) => T)], + void + >; + const store = { + get: <V>(_atom: unknown) => value as unknown as V, + set: (_atom: unknown, newValue: unknown) => { + value = + typeof newValue === "function" + ? (newValue as (prev: T) => T)(value) + : (newValue as T); + listeners.forEach((l) => l()); + }, + sub: (_atom: unknown, listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; + return { + store: store as Parameters<typeof persistJotai>[0], + atom, + listenerCount: () => listeners.size, + getValue: () => value, + }; +} + +function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { + return new Promise<void>((resolve, reject) => { + let ticks = 0; + const tick = () => { + if (hasHydrated()) { + resolve(); + return; + } + if (++ticks > maxTicks) { + reject(new Error("waitForHydration: never hydrated")); + return; + } + queueMicrotask(tick); + }; + tick(); + }); +} + +describe("persistJotai", () => { + let memory: MemoryStorage; + + beforeEach(() => { + memory = new MemoryStorage(); + }); + + it("round-trips through persistSource", async () => { + const mock = createMockJotaiStore(0); + const jsonStorage = createJSONStorage<number>(() => memory)!; + await jsonStorage.setItem("count-atom", { state: 7, version: 0 }); + + const persist = persistJotai(mock.store, mock.atom, { + name: "count-atom", + storage: jsonStorage, + }); + + await waitForHydration(persist.hasHydrated); + expect(mock.getValue()).toBe(7); + + mock.store.set(mock.atom, 9); + await new Promise((resolve) => queueMicrotask(resolve)); + + const stored = await jsonStorage.getItem("count-atom"); + expect(stored?.state).toBe(9); + + const fresh = createMockJotaiStore(0); + const rehydrate = persistJotai(fresh.store, fresh.atom, { + name: "count-atom", + storage: jsonStorage, + }); + await waitForHydration(rehydrate.hasHydrated); + expect(fresh.getValue()).toBe(9); + }); + + it("subscribe fires on setState", async () => { + const mock = createMockJotaiStore(0); + const jsonStorage = createJSONStorage<number>(() => memory)!; + + const persist = persistJotai(mock.store, mock.atom, { + name: "subscribe-atom", + storage: jsonStorage, + }); + + await waitForHydration(persist.hasHydrated); + expect(mock.listenerCount()).toBe(1); + + mock.store.set(mock.atom, 42); + await new Promise((resolve) => queueMicrotask(resolve)); + + const stored = await jsonStorage.getItem("subscribe-atom"); + expect(stored?.state).toBe(42); + }); +}); + +describe("jotai dependency isolation", () => { + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file( + new URL("./jotai.ts", import.meta.url), + ).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); + } + }); +}); diff --git a/src/adapters/sources/jotai.ts b/src/adapters/sources/jotai.ts new file mode 100644 index 0000000..cd3b2b6 --- /dev/null +++ b/src/adapters/sources/jotai.ts @@ -0,0 +1,52 @@ +// jotai source adapter — peer `jotai` >=2.0.0. +import type { Atom, WritableAtom } from "jotai"; + +import type { PersistApi, PersistOptions } from "../../core/persist-core"; +import { persistSource } from "../../core/persist-core"; + +// jotai's Store type — structural to avoid importing internals. +interface JotaiStore { + get: <T>(atom: Atom<T>) => T; + set: <T>( + atom: WritableAtom<T, [T | ((prev: T) => T)], void>, + value: T | ((prev: T) => T), + ) => void; + sub: (atom: Atom<unknown>, listener: () => void) => () => void; +} + +/** + * Persist a writable jotai atom via a jotai `Store`. `get`/`set`/`sub` map + * to `PersistableSource` for the atom's value. + * + * @example + * ```ts + * import { atom, createStore } from "jotai"; + * import { persistJotai } from "@stainless-code/persist/sources/jotai"; + * const countAtom = atom(0); + * const store = createStore(); + * const persist = persistJotai(store, countAtom, { name: "count" }); + * ``` + */ +export function persistJotai<TState, TPersistedState = TState>( + store: JotaiStore, + atom: WritableAtom<TState, [TState | ((prev: TState) => TState)], void>, + options: PersistOptions<TState, TPersistedState>, +): PersistApi<TState, TPersistedState> { + return persistSource( + { + getState: () => store.get(atom), + setState: (updater) => store.set(atom, updater), + subscribe: (listener) => { + const unsub = store.sub(atom, () => listener()); + return { unsubscribe: unsub }; + }, + }, + { + ...options, + // `??` (not spread order) so an explicit `merge: undefined` still gets + // the replace-merge — a shallow-spread fallback would corrupt primitive + // atom states (spreading a number yields `{}`). + merge: options.merge ?? ((persisted) => persisted as TState), + }, + ); +} diff --git a/src/adapters/sources/mobx.test.ts b/src/adapters/sources/mobx.test.ts new file mode 100644 index 0000000..7de4e9d --- /dev/null +++ b/src/adapters/sources/mobx.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +import type { StateStorage } from "../../core/persist-core"; + +const observableMap = new WeakMap<object, Set<() => void>>(); + +mock.module("mobx", () => ({ + observe: (obj: object, callback: () => void) => { + const listeners = observableMap.get(obj); + if (listeners) listeners.add(callback); + return () => listeners?.delete(callback); + }, + toJS: <T>(obj: T): T => { + if (obj && typeof obj === "object") return { ...obj } as T; + return obj; + }, +})); + +const { createJSONStorage } = await import("../../core/persist-core"); +const { persistMobx } = await import("./mobx"); + +class MemoryStorage implements StateStorage { + private store = new Map<string, string>(); + + clear() { + this.store.clear(); + } + + getItem(key: string) { + return this.store.get(key) ?? null; + } + + removeItem(key: string) { + this.store.delete(key); + } + + setItem(key: string, value: string) { + this.store.set(key, value); + } +} + +function createMockObservable<T extends object>(initial: T): T { + const listeners = new Set<() => void>(); + const state = { ...initial }; + const proxy = new Proxy(state, { + get(target, prop) { + return target[prop as keyof T]; + }, + set(target, prop, value) { + (target as Record<string, unknown>)[prop as string] = value; + listeners.forEach((l) => l()); + return true; + }, + }); + observableMap.set(proxy, listeners); + return proxy as T; +} + +function listenerCount(observable: object) { + return observableMap.get(observable)?.size ?? 0; +} + +function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { + return new Promise<void>((resolve, reject) => { + let ticks = 0; + const tick = () => { + if (hasHydrated()) { + resolve(); + return; + } + if (++ticks > maxTicks) { + reject(new Error("waitForHydration: never hydrated")); + return; + } + queueMicrotask(tick); + }; + tick(); + }); +} + +describe("persistMobx", () => { + let memory: MemoryStorage; + + beforeEach(() => { + memory = new MemoryStorage(); + }); + + it("round-trips through persistSource", async () => { + const observable = createMockObservable({ count: 0 }); + const jsonStorage = createJSONStorage<{ count: number }>(() => memory)!; + await jsonStorage.setItem("count-observable", { + state: { count: 7 }, + version: 0, + }); + + const persist = persistMobx(observable, { + name: "count-observable", + storage: jsonStorage, + }); + + await waitForHydration(persist.hasHydrated); + expect(observable.count).toBe(7); + + Object.assign(observable, { count: observable.count + 1 }); + await new Promise((resolve) => queueMicrotask(resolve)); + + const stored = await jsonStorage.getItem("count-observable"); + expect(stored?.state.count).toBe(8); + + const freshObservable = createMockObservable({ count: 0 }); + const rehydrate = persistMobx(freshObservable, { + name: "count-observable", + storage: jsonStorage, + }); + await waitForHydration(rehydrate.hasHydrated); + expect(freshObservable.count).toBe(8); + }); + + it("subscribe fires on setState", async () => { + const observable = createMockObservable({ count: 0 }); + const jsonStorage = createJSONStorage<{ count: number }>(() => memory)!; + + const persist = persistMobx(observable, { + name: "subscribe-observable", + storage: jsonStorage, + }); + + await waitForHydration(persist.hasHydrated); + expect(listenerCount(observable)).toBe(1); + + Object.assign(observable, { count: 42 }); + await new Promise((resolve) => queueMicrotask(resolve)); + + const stored = await jsonStorage.getItem("subscribe-observable"); + expect(stored?.state.count).toBe(42); + }); +}); + +describe("mobx dependency isolation", () => { + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file(new URL("./mobx.ts", import.meta.url)).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); + } + }); +}); diff --git a/src/adapters/sources/mobx.ts b/src/adapters/sources/mobx.ts new file mode 100644 index 0000000..e29e00d --- /dev/null +++ b/src/adapters/sources/mobx.ts @@ -0,0 +1,37 @@ +// mobx source adapter — peer `mobx` >=6.0.0. +import { observe, toJS } from "mobx"; + +import type { PersistApi, PersistOptions } from "../../core/persist-core"; +import { persistSource } from "../../core/persist-core"; + +/** + * Persist a mobx observable object. `toJS` for reads, `Object.assign` for + * writes, `observe` for change tracking. + * + * @example + * ```ts + * import { observable } from "mobx"; + * import { persistMobx } from "@stainless-code/persist/sources/mobx"; + * const state = observable.object({ count: 0 }); + * const persist = persistMobx(state, { name: "count" }); + * ``` + */ +export function persistMobx<TState extends object, TPersistedState = TState>( + observable: TState, + options: PersistOptions<TState, TPersistedState>, +): PersistApi<TState, TPersistedState> { + return persistSource( + { + getState: () => toJS(observable) as TState, + setState: (updater) => { + const next = updater(toJS(observable) as TState); + Object.assign(observable, next); + }, + subscribe: (listener) => { + const unsub = observe(observable, () => listener()); + return { unsubscribe: unsub }; + }, + }, + options, + ); +} diff --git a/src/adapters/sources/valtio.test.ts b/src/adapters/sources/valtio.test.ts new file mode 100644 index 0000000..88ce202 --- /dev/null +++ b/src/adapters/sources/valtio.test.ts @@ -0,0 +1,154 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +import type { StateStorage } from "../../core/persist-core"; + +type MockProxy<T extends object> = T & { + __listeners: Set<() => void>; + __state: T; +}; + +mock.module("valtio", () => ({ + snapshot: <T extends object>(proxyObj: T): T => { + const state = (proxyObj as MockProxy<T>).__state ?? proxyObj; + return { ...state }; + }, + subscribe: (proxyObj: object, callback: () => void) => { + const listeners = (proxyObj as MockProxy<object>).__listeners; + listeners.add(callback); + return () => listeners.delete(callback); + }, +})); + +const { createJSONStorage } = await import("../../core/persist-core"); +const { persistValtio } = await import("./valtio"); + +class MemoryStorage implements StateStorage { + private store = new Map<string, string>(); + + clear() { + this.store.clear(); + } + + getItem(key: string) { + return this.store.get(key) ?? null; + } + + removeItem(key: string) { + this.store.delete(key); + } + + setItem(key: string, value: string) { + this.store.set(key, value); + } +} + +function createMockProxy<T extends object>(initial: T): MockProxy<T> { + const listeners = new Set<() => void>(); + const state = { ...initial }; + const proxy = new Proxy(state, { + get(target, prop) { + if (prop === "__listeners") return listeners; + if (prop === "__state") return state; + return target[prop as keyof T]; + }, + set(target, prop, value) { + if (prop === "__listeners" || prop === "__state") return true; + (target as Record<string, unknown>)[prop as string] = value; + listeners.forEach((l) => l()); + return true; + }, + }) as MockProxy<T>; + proxy.__listeners = listeners; + proxy.__state = state; + return proxy; +} + +function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { + return new Promise<void>((resolve, reject) => { + let ticks = 0; + const tick = () => { + if (hasHydrated()) { + resolve(); + return; + } + if (++ticks > maxTicks) { + reject(new Error("waitForHydration: never hydrated")); + return; + } + queueMicrotask(tick); + }; + tick(); + }); +} + +describe("persistValtio", () => { + let memory: MemoryStorage; + + beforeEach(() => { + memory = new MemoryStorage(); + }); + + it("round-trips through persistSource", async () => { + const proxy = createMockProxy({ count: 0 }); + const jsonStorage = createJSONStorage<{ count: number }>(() => memory)!; + await jsonStorage.setItem("count-proxy", { + state: { count: 7 }, + version: 0, + }); + + const persist = persistValtio(proxy, { + name: "count-proxy", + storage: jsonStorage, + }); + + await waitForHydration(persist.hasHydrated); + expect(proxy.count).toBe(7); + + Object.assign(proxy, { count: proxy.count + 1 }); + await new Promise((resolve) => queueMicrotask(resolve)); + + const stored = await jsonStorage.getItem("count-proxy"); + expect(stored?.state.count).toBe(8); + + const freshProxy = createMockProxy({ count: 0 }); + const rehydrate = persistValtio(freshProxy, { + name: "count-proxy", + storage: jsonStorage, + }); + await waitForHydration(rehydrate.hasHydrated); + expect(freshProxy.count).toBe(8); + }); + + it("subscribe fires on setState", async () => { + const proxy = createMockProxy({ count: 0 }); + const jsonStorage = createJSONStorage<{ count: number }>(() => memory)!; + + const persist = persistValtio(proxy, { + name: "subscribe-proxy", + storage: jsonStorage, + }); + + await waitForHydration(persist.hasHydrated); + expect(proxy.__listeners.size).toBe(1); + + Object.assign(proxy, { count: 42 }); + await new Promise((resolve) => queueMicrotask(resolve)); + + const stored = await jsonStorage.getItem("subscribe-proxy"); + expect(stored?.state.count).toBe(42); + }); +}); + +describe("valtio dependency isolation", () => { + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file( + new URL("./valtio.ts", import.meta.url), + ).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); + } + }); +}); diff --git a/src/adapters/sources/valtio.ts b/src/adapters/sources/valtio.ts new file mode 100644 index 0000000..e17ec4f --- /dev/null +++ b/src/adapters/sources/valtio.ts @@ -0,0 +1,37 @@ +// valtio source adapter — peer `valtio` >=1.0.0. +import { snapshot, subscribe } from "valtio"; + +import type { PersistApi, PersistOptions } from "../../core/persist-core"; +import { persistSource } from "../../core/persist-core"; + +/** + * Persist a valtio proxy. `snapshot` for reads, `Object.assign` for writes, + * `subscribe` for change tracking. + * + * @example + * ```ts + * import { proxy } from "valtio"; + * import { persistValtio } from "@stainless-code/persist/sources/valtio"; + * const state = proxy({ count: 0 }); + * const persist = persistValtio(state, { name: "count" }); + * ``` + */ +export function persistValtio<TState extends object, TPersistedState = TState>( + proxyObject: TState, + options: PersistOptions<TState, TPersistedState>, +): PersistApi<TState, TPersistedState> { + return persistSource( + { + getState: () => snapshot(proxyObject) as TState, + setState: (updater) => { + const next = updater(snapshot(proxyObject) as TState); + Object.assign(proxyObject, next); + }, + subscribe: (listener) => { + const unsub = subscribe(proxyObject, () => listener()); + return { unsubscribe: unsub }; + }, + }, + options, + ); +} diff --git a/src/adapters/sources/zustand.test.ts b/src/adapters/sources/zustand.test.ts new file mode 100644 index 0000000..78665e5 --- /dev/null +++ b/src/adapters/sources/zustand.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it } from "bun:test"; + +import type { StoreApi } from "zustand"; + +import { createJSONStorage } from "../../core/persist-core"; +import type { StateStorage } from "../../core/persist-core"; +import { persistZustand } from "./zustand"; + +class MemoryStorage implements StateStorage { + private store = new Map<string, string>(); + + clear() { + this.store.clear(); + } + + getItem(key: string) { + return this.store.get(key) ?? null; + } + + removeItem(key: string) { + this.store.delete(key); + } + + setItem(key: string, value: string) { + this.store.set(key, value); + } +} + +function createMockStore<T>(initial: T) { + let state = initial; + const listeners = new Set<() => void>(); + return { + getState: () => state, + getInitialState: () => initial, + setState: (updater: (prev: T) => T) => { + state = updater(state); + listeners.forEach((l) => l()); + }, + subscribe: (listener: (state: T, prevState: T) => void) => { + const wrapped = () => listener(state, state); + listeners.add(wrapped); + return () => { + listeners.delete(wrapped); + }; + }, + listenerCount: () => listeners.size, + }; +} + +function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { + return new Promise<void>((resolve, reject) => { + let ticks = 0; + const tick = () => { + if (hasHydrated()) { + resolve(); + return; + } + if (++ticks > maxTicks) { + reject(new Error("waitForHydration: never hydrated")); + return; + } + queueMicrotask(tick); + }; + tick(); + }); +} + +describe("persistZustand", () => { + let memory: MemoryStorage; + + beforeEach(() => { + memory = new MemoryStorage(); + }); + + it("round-trips through persistSource", async () => { + const store = createMockStore({ count: 0 }); + const jsonStorage = createJSONStorage<{ count: number }>(() => memory)!; + await jsonStorage.setItem("count-store", { + state: { count: 7 }, + version: 0, + }); + + const persist = persistZustand(store as StoreApi<{ count: number }>, { + name: "count-store", + storage: jsonStorage, + }); + + await waitForHydration(persist.hasHydrated); + expect(store.getState().count).toBe(7); + + store.setState((prev) => ({ ...prev, count: prev.count + 1 })); + await new Promise((resolve) => queueMicrotask(resolve)); + + const stored = await jsonStorage.getItem("count-store"); + expect(stored?.state.count).toBe(8); + + const freshStore = createMockStore({ count: 0 }); + const rehydrate = persistZustand( + freshStore as StoreApi<{ count: number }>, + { + name: "count-store", + storage: jsonStorage, + }, + ); + await waitForHydration(rehydrate.hasHydrated); + expect(freshStore.getState().count).toBe(8); + }); + + it("subscribe fires on setState", async () => { + const store = createMockStore({ count: 0 }); + const jsonStorage = createJSONStorage<{ count: number }>(() => memory)!; + + const persist = persistZustand(store as StoreApi<{ count: number }>, { + name: "subscribe-store", + storage: jsonStorage, + }); + + await waitForHydration(persist.hasHydrated); + expect(store.listenerCount()).toBe(1); + + store.setState((prev) => ({ ...prev, count: 42 })); + await new Promise((resolve) => queueMicrotask(resolve)); + + const stored = await jsonStorage.getItem("subscribe-store"); + expect(stored?.state.count).toBe(42); + }); +}); + +describe("zustand dependency isolation", () => { + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file( + new URL("./zustand.ts", import.meta.url), + ).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); + } + }); +}); diff --git a/src/adapters/sources/zustand.ts b/src/adapters/sources/zustand.ts new file mode 100644 index 0000000..ffe297c --- /dev/null +++ b/src/adapters/sources/zustand.ts @@ -0,0 +1,34 @@ +// zustand source adapter — peer `zustand` >=4.0.0. +import type { StoreApi } from "zustand"; + +import type { PersistApi, PersistOptions } from "../../core/persist-core"; +import { persistSource } from "../../core/persist-core"; + +/** + * Persist a zustand store. zustand's `getState`/`setState`/`subscribe` map + * directly to `PersistableSource`. + * + * @example + * ```ts + * import { create } from "zustand"; + * import { persistZustand } from "@stainless-code/persist/sources/zustand"; + * const store = create(() => ({ count: 0 })); + * const persist = persistZustand(store, { name: "count", storage: createJSONStorage(() => localStorage) }); + * ``` + */ +export function persistZustand<TState, TPersistedState = TState>( + store: StoreApi<TState>, + options: PersistOptions<TState, TPersistedState>, +): PersistApi<TState, TPersistedState> { + return persistSource( + { + getState: () => store.getState(), + setState: (updater) => store.setState(updater), + subscribe: (listener) => { + const unsub = store.subscribe(() => listener()); + return { unsubscribe: unsub }; + }, + }, + options, + ); +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 462b270..99a19b2 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -21,6 +21,10 @@ export default defineConfig({ "backends/node-fs": "src/adapters/backends/node-fs.ts", "transport/crosstab": "src/adapters/transport/crosstab.ts", "sources/tanstack-store": "src/adapters/sources/tanstack-store.ts", + "sources/zustand": "src/adapters/sources/zustand.ts", + "sources/jotai": "src/adapters/sources/jotai.ts", + "sources/valtio": "src/adapters/sources/valtio.ts", + "sources/mobx": "src/adapters/sources/mobx.ts", "frameworks/react": "src/adapters/frameworks/react.ts", "frameworks/solid": "src/adapters/frameworks/solid.ts", "frameworks/vue": "src/adapters/frameworks/vue.ts", @@ -48,6 +52,10 @@ export default defineConfig({ "@react-native-async-storage/async-storage", "react-native-mmkv", "expo-secure-store", + "zustand", + "jotai", + "valtio", + "mobx", ], }, clean: true, diff --git a/typedoc.json b/typedoc.json index feb7c4a..7837db5 100644 --- a/typedoc.json +++ b/typedoc.json @@ -13,6 +13,10 @@ "src/adapters/backends/node-fs.ts", "src/adapters/transport/crosstab.ts", "src/adapters/sources/tanstack-store.ts", + "src/adapters/sources/zustand.ts", + "src/adapters/sources/jotai.ts", + "src/adapters/sources/valtio.ts", + "src/adapters/sources/mobx.ts", "src/adapters/frameworks/react.ts", "src/adapters/frameworks/solid.ts", "src/adapters/frameworks/vue.ts", From ab9da05128e702ca4002229b7f8b442d90c7f926 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 15:19:59 +0300 Subject: [PATCH 37/77] update lock file --- bun.lock | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index 5928d6e..baca3d8 100644 --- a/bun.lock +++ b/bun.lock @@ -24,11 +24,11 @@ "expo-secure-store": "^57.0.0", "husky": "9.1.7", "idb-keyval": "6.2.6", - "jotai": "^2.20.1", + "jotai": "2.20.1", "jsdom": "29.1.1", "knip": "6.24.0", "lint-staged": "17.0.8", - "mobx": "^6.16.1", + "mobx": "6.16.1", "oxfmt": "0.56.0", "oxlint": "1.71.0", "preact": "10.29.4", @@ -43,11 +43,11 @@ "tsdown": "0.22.3", "typedoc": "0.28.19", "typescript": "6.0.3", - "valtio": "^2.3.2", + "valtio": "2.3.2", "vitest": "4.1.9", "vue": "^3.5.39", "zod": "^4.4.3", - "zustand": "^5.0.14", + "zustand": "5.0.14", }, "peerDependencies": { "@angular/core": ">=17.0.0", @@ -55,14 +55,18 @@ "@tanstack/store": ">=0.10.0", "expo-secure-store": ">=12.0.0", "idb-keyval": ">=4.0.0", + "jotai": ">=2.0.0", + "mobx": ">=6.0.0", "preact": ">=10.19.0", "react": "^18.0.0 || ^19.0.0", "react-native-mmkv": ">=4.0.0", "seroval": ">=1.0.0", "solid-js": ">=1.6.0", "svelte": ">=3.0.0", + "valtio": ">=1.0.0", "vue": ">=3.3.0", "zod": ">=3.20.0", + "zustand": ">=4.0.0", }, "optionalPeers": [ "@angular/core", @@ -70,14 +74,18 @@ "@tanstack/store", "expo-secure-store", "idb-keyval", + "jotai", + "mobx", "preact", "react", "react-native-mmkv", "seroval", "solid-js", "svelte", + "valtio", "vue", "zod", + "zustand", ], }, }, From 1e1d7b739f0f598f564e1ec2fca018b2d7c03d94 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 15:20:17 +0300 Subject: [PATCH 38/77] fix(jotai): export JotaiStore type so typedoc includes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/adapters/sources/jotai.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/sources/jotai.ts b/src/adapters/sources/jotai.ts index cd3b2b6..146a786 100644 --- a/src/adapters/sources/jotai.ts +++ b/src/adapters/sources/jotai.ts @@ -4,7 +4,7 @@ import type { Atom, WritableAtom } from "jotai"; import type { PersistApi, PersistOptions } from "../../core/persist-core"; import { persistSource } from "../../core/persist-core"; -// jotai's Store type — structural to avoid importing internals. +/** jotai's Store type — structural to avoid importing internals. */ interface JotaiStore { get: <T>(atom: Atom<T>) => T; set: <T>( From 1f1e95773ef9981dbc9c2d3c0d1c973e918b9fa2 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 15:21:37 +0300 Subject: [PATCH 39/77] fix(jotai): export JotaiStore type for typedoc inclusion --- src/adapters/sources/jotai.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/sources/jotai.ts b/src/adapters/sources/jotai.ts index 146a786..6c15b28 100644 --- a/src/adapters/sources/jotai.ts +++ b/src/adapters/sources/jotai.ts @@ -5,7 +5,7 @@ import type { PersistApi, PersistOptions } from "../../core/persist-core"; import { persistSource } from "../../core/persist-core"; /** jotai's Store type — structural to avoid importing internals. */ -interface JotaiStore { +export interface JotaiStore { get: <T>(atom: Atom<T>) => T; set: <T>( atom: WritableAtom<T, [T | ((prev: T) => T)], void>, From 27d015e1d52330f2d8e5494328cd259e9f1ed81d Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 17:23:48 +0300 Subject: [PATCH 40/77] refactor(sources): shape-name adapters (persistStore/Atom/Proxy/Observable) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/store-adapters.md | 10 +++++----- README.md | 26 +++++++++++++------------- docs/architecture.md | 2 +- package.json | 2 +- src/adapters/sources/jotai.test.ts | 12 ++++++------ src/adapters/sources/jotai.ts | 6 +++--- src/adapters/sources/mobx.test.ts | 10 +++++----- src/adapters/sources/mobx.ts | 9 ++++++--- src/adapters/sources/valtio.test.ts | 10 +++++----- src/adapters/sources/valtio.ts | 6 +++--- src/adapters/sources/zustand.test.ts | 19 ++++++++----------- src/adapters/sources/zustand.ts | 6 +++--- 12 files changed, 59 insertions(+), 59 deletions(-) diff --git a/.changeset/store-adapters.md b/.changeset/store-adapters.md index 9395d5f..d4a2760 100644 --- a/.changeset/store-adapters.md +++ b/.changeset/store-adapters.md @@ -2,11 +2,11 @@ "@stainless-code/persist": minor --- -Add four source adapters over the `PersistableSource` seam — each is a thin `persistSource` wrapper mapping the library's store API: +Add four source adapters over the `PersistableSource` seam — shape-named (not library-named): same persistable shape → same name → same merge semantics, regardless of library; the subpath carries the library. Each is a thin `persistSource` wrapper mapping the library's store API: -- `./sources/zustand` (peer `zustand >=4.0.0`): `persistZustand(store, opts)` — zustand's `getState`/`setState`/`subscribe` map directly. -- `./sources/jotai` (peer `jotai >=2.0.0`): `persistJotai(store, atom, opts)` — wraps a writable atom + jotai `Store`; replace-merge default (like `persistAtom`) so primitive atoms don't hydrate to `{}`. -- `./sources/valtio` (peer `valtio >=1.0.0`): `persistValtio(proxyObject, opts)` — `snapshot` for reads, `Object.assign` for writes, `subscribe` for changes. -- `./sources/mobx` (peer `mobx >=6.0.0`): `persistMobx(observable, opts)` — `toJS` for reads, `Object.assign` for writes, `observe` for changes. +- `./sources/zustand` (peer `zustand >=4.0.0`): `persistStore(store, opts)` — zustand's `getState`/`setState`/`subscribe` map directly. Same name + shallow-spread merge as `./sources/tanstack-store`'s `persistStore` (same shape); alias one if importing both. +- `./sources/jotai` (peer `jotai >=2.0.0`): `persistAtom(store, atom, opts)` — wraps a writable atom + jotai `Store`; replace-merge default (like `persistAtom` from `./sources/tanstack-store`) so primitive atoms don't hydrate to `{}`. +- `./sources/valtio` (peer `valtio >=1.0.0`): `persistProxy(proxyObject, opts)` — `snapshot` for reads, `Object.assign` for writes, `subscribe` for changes. +- `./sources/mobx` (peer `mobx >=6.0.0`): `persistObservable(observable, opts)` — `toJS` for reads, `Object.assign` for writes, `observe` for changes. Each is its own subpath with the peer optional, no cross-entry value imports. README "Wrapping your store" recipe section shows both the shipped adapter + the underlying `persistSource` mapping for customization. diff --git a/README.md b/README.md index 8f17f3a..d93d15a 100644 --- a/README.md +++ b/README.md @@ -412,10 +412,10 @@ Persistence middleware for any `getState`/`setState`/`subscribe` store (TanStack | `@stainless-code/persist/backends/node-fs` | `nodeFsStateStorage` (one file per key) | none (Node built-in) | | `@stainless-code/persist/transport/crosstab` | `createBroadcastCrossTab` | none (web global) | | `@stainless-code/persist/sources/tanstack-store` | `persistStore`, `persistAtom` | `@tanstack/store` (types only) | -| `@stainless-code/persist/sources/zustand` | `persistZustand` | `zustand` | -| `@stainless-code/persist/sources/jotai` | `persistJotai` | `jotai` | -| `@stainless-code/persist/sources/valtio` | `persistValtio` | `valtio` | -| `@stainless-code/persist/sources/mobx` | `persistMobx` | `mobx` | +| `@stainless-code/persist/sources/zustand` | `persistStore` | `zustand` | +| `@stainless-code/persist/sources/jotai` | `persistAtom` | `jotai` | +| `@stainless-code/persist/sources/valtio` | `persistProxy` | `valtio` | +| `@stainless-code/persist/sources/mobx` | `persistObservable` | `mobx` | | `@stainless-code/persist/frameworks/react` | `useHydrated` React hook | `react` | | `@stainless-code/persist/frameworks/solid` | `useHydrated` (Solid `Accessor<boolean>`) | `solid-js` | | `@stainless-code/persist/frameworks/vue` | `useHydrated` (Vue `Ref<boolean>`) | `vue` | @@ -677,17 +677,17 @@ persistStore(store, { ### Wrapping your store -Every shipped source adapter is a thin `persistSource` wrapper — import the subpath, pass your store, wire storage. Redux, signals, hand-rolled atoms: same seam. +Every shipped source adapter is a thin `persistSource` wrapper — import the subpath, pass your store, wire storage. **Naming is shape-based, not library-based** (`persistStore` / `persistAtom` / `persistProxy` / `persistObservable`): same persistable shape → same name → same merge semantics, regardless of library; the subpath carries the library. Importing two same-shape adapters into one module? Alias one: `import { persistStore as persistZustand } from "@stainless-code/persist/sources/zustand"`. Redux, signals, hand-rolled atoms: same seam — pass a custom `PersistableSource` to `persistSource` directly. **zustand** ```ts import { create } from "zustand"; import { createJSONStorage } from "@stainless-code/persist"; -import { persistZustand } from "@stainless-code/persist/sources/zustand"; +import { persistStore } from "@stainless-code/persist/sources/zustand"; const usePrefs = create(() => ({ theme: "light" as const })); -const persist = persistZustand(usePrefs, { +const persist = persistStore(usePrefs, { name: "app:prefs:v1", storage: createJSONStorage(() => localStorage), }); @@ -700,11 +700,11 @@ Or pass a custom `PersistableSource` to `persistSource` directly — the adapter ```ts import { atom, createStore } from "jotai"; import { createJSONStorage } from "@stainless-code/persist"; -import { persistJotai } from "@stainless-code/persist/sources/jotai"; +import { persistAtom } from "@stainless-code/persist/sources/jotai"; const store = createStore(); const themeAtom = atom<"light" | "dark">("light"); -const persist = persistJotai(store, themeAtom, { +const persist = persistAtom(store, themeAtom, { name: "app:theme:v1", storage: createJSONStorage(() => localStorage), }); @@ -717,10 +717,10 @@ Or pass a custom `PersistableSource` to `persistSource` directly — the adapter ```ts import { proxy } from "valtio"; import { createJSONStorage } from "@stainless-code/persist"; -import { persistValtio } from "@stainless-code/persist/sources/valtio"; +import { persistProxy } from "@stainless-code/persist/sources/valtio"; const prefs = proxy({ theme: "light" as const }); -const persist = persistValtio(prefs, { +const persist = persistProxy(prefs, { name: "app:prefs:v1", storage: createJSONStorage(() => localStorage), }); @@ -733,10 +733,10 @@ Or pass a custom `PersistableSource` to `persistSource` directly — the adapter ```ts import { observable } from "mobx"; import { createJSONStorage } from "@stainless-code/persist"; -import { persistMobx } from "@stainless-code/persist/sources/mobx"; +import { persistObservable } from "@stainless-code/persist/sources/mobx"; const prefs = observable.object({ theme: "light" as const }); -const persist = persistMobx(prefs, { +const persist = persistObservable(prefs, { name: "app:prefs:v1", storage: createJSONStorage(() => localStorage), }); diff --git a/docs/architecture.md b/docs/architecture.md index e544723..d248220 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -53,7 +53,7 @@ No barrel — importing a subpath is the dependency opt-in. Each subpath entry o - `codecs/` — `StorageCodec` adapters (seroval, zod) - `backends/` — `StateStorage` adapters + wrappers (idb, async-storage, mmkv, secure-store, encrypted, compressed, node-fs) - `transport/` — `CrossTabEventTarget` adapters (crosstab — BroadcastChannel bridge) - - `sources/` — `PersistableSource` adapters (tanstack-store, zustand, jotai, valtio, mobx) + - `sources/` — `PersistableSource` adapters (tanstack-store, zustand, jotai, valtio, mobx). Shape-named, not library-named — same persistable shape → same name → same merge semantics; the subpath carries the library. Alias when importing two same-shape adapters into one module. - `frameworks/` — `HydrationSignal` framework adapters (react, solid, vue, svelte, svelte-store, angular, preact) A per-entry self-check test pins the invariant: every adapter's relative imports resolve into `core/` (no cross-adapter coupling). `dist/` mirrors `src/` (`dist/<seam>/<name>.mjs` via tsdown's record-form `entry` keyed by `<seam>/<name>`) — src folder → tsdown key → dist path → subpath, all 1:1. diff --git a/package.json b/package.json index 097c528..047145d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@stainless-code/persist", "version": "0.1.1", - "description": "Hydration-aware persistence middleware for any reactive store — pluggable storage × codec seams, cross-tab sync, and React/Solid/Vue/Svelte hydration adapters", + "description": "Hydration-aware persistence for any reactive store — zero-dep persistSource core; codecs, backends, cross-tab transport, source + framework hydration adapters ship as opt-in recipes", "keywords": [ "broadcastchannel", "codec", diff --git a/src/adapters/sources/jotai.test.ts b/src/adapters/sources/jotai.test.ts index 21273b6..0328944 100644 --- a/src/adapters/sources/jotai.test.ts +++ b/src/adapters/sources/jotai.test.ts @@ -4,7 +4,7 @@ import type { WritableAtom } from "jotai"; import { createJSONStorage } from "../../core/persist-core"; import type { StateStorage } from "../../core/persist-core"; -import { persistJotai } from "./jotai"; +import { persistAtom } from "./jotai"; class MemoryStorage implements StateStorage { private store = new Map<string, string>(); @@ -51,7 +51,7 @@ function createMockJotaiStore<T>(initialValue: T) { }, }; return { - store: store as Parameters<typeof persistJotai>[0], + store: store as Parameters<typeof persistAtom>[0], atom, listenerCount: () => listeners.size, getValue: () => value, @@ -76,7 +76,7 @@ function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { }); } -describe("persistJotai", () => { +describe("persistAtom", () => { let memory: MemoryStorage; beforeEach(() => { @@ -88,7 +88,7 @@ describe("persistJotai", () => { const jsonStorage = createJSONStorage<number>(() => memory)!; await jsonStorage.setItem("count-atom", { state: 7, version: 0 }); - const persist = persistJotai(mock.store, mock.atom, { + const persist = persistAtom(mock.store, mock.atom, { name: "count-atom", storage: jsonStorage, }); @@ -103,7 +103,7 @@ describe("persistJotai", () => { expect(stored?.state).toBe(9); const fresh = createMockJotaiStore(0); - const rehydrate = persistJotai(fresh.store, fresh.atom, { + const rehydrate = persistAtom(fresh.store, fresh.atom, { name: "count-atom", storage: jsonStorage, }); @@ -115,7 +115,7 @@ describe("persistJotai", () => { const mock = createMockJotaiStore(0); const jsonStorage = createJSONStorage<number>(() => memory)!; - const persist = persistJotai(mock.store, mock.atom, { + const persist = persistAtom(mock.store, mock.atom, { name: "subscribe-atom", storage: jsonStorage, }); diff --git a/src/adapters/sources/jotai.ts b/src/adapters/sources/jotai.ts index 6c15b28..415fc0b 100644 --- a/src/adapters/sources/jotai.ts +++ b/src/adapters/sources/jotai.ts @@ -21,13 +21,13 @@ export interface JotaiStore { * @example * ```ts * import { atom, createStore } from "jotai"; - * import { persistJotai } from "@stainless-code/persist/sources/jotai"; + * import { persistAtom } from "@stainless-code/persist/sources/jotai"; * const countAtom = atom(0); * const store = createStore(); - * const persist = persistJotai(store, countAtom, { name: "count" }); + * const persist = persistAtom(store, countAtom, { name: "count" }); * ``` */ -export function persistJotai<TState, TPersistedState = TState>( +export function persistAtom<TState, TPersistedState = TState>( store: JotaiStore, atom: WritableAtom<TState, [TState | ((prev: TState) => TState)], void>, options: PersistOptions<TState, TPersistedState>, diff --git a/src/adapters/sources/mobx.test.ts b/src/adapters/sources/mobx.test.ts index 7de4e9d..a8e302a 100644 --- a/src/adapters/sources/mobx.test.ts +++ b/src/adapters/sources/mobx.test.ts @@ -17,7 +17,7 @@ mock.module("mobx", () => ({ })); const { createJSONStorage } = await import("../../core/persist-core"); -const { persistMobx } = await import("./mobx"); +const { persistObservable } = await import("./mobx"); class MemoryStorage implements StateStorage { private store = new Map<string, string>(); @@ -78,7 +78,7 @@ function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { }); } -describe("persistMobx", () => { +describe("persistObservable", () => { let memory: MemoryStorage; beforeEach(() => { @@ -93,7 +93,7 @@ describe("persistMobx", () => { version: 0, }); - const persist = persistMobx(observable, { + const persist = persistObservable(observable, { name: "count-observable", storage: jsonStorage, }); @@ -108,7 +108,7 @@ describe("persistMobx", () => { expect(stored?.state.count).toBe(8); const freshObservable = createMockObservable({ count: 0 }); - const rehydrate = persistMobx(freshObservable, { + const rehydrate = persistObservable(freshObservable, { name: "count-observable", storage: jsonStorage, }); @@ -120,7 +120,7 @@ describe("persistMobx", () => { const observable = createMockObservable({ count: 0 }); const jsonStorage = createJSONStorage<{ count: number }>(() => memory)!; - const persist = persistMobx(observable, { + const persist = persistObservable(observable, { name: "subscribe-observable", storage: jsonStorage, }); diff --git a/src/adapters/sources/mobx.ts b/src/adapters/sources/mobx.ts index e29e00d..fe87198 100644 --- a/src/adapters/sources/mobx.ts +++ b/src/adapters/sources/mobx.ts @@ -11,12 +11,15 @@ import { persistSource } from "../../core/persist-core"; * @example * ```ts * import { observable } from "mobx"; - * import { persistMobx } from "@stainless-code/persist/sources/mobx"; + * import { persistObservable } from "@stainless-code/persist/sources/mobx"; * const state = observable.object({ count: 0 }); - * const persist = persistMobx(state, { name: "count" }); + * const persist = persistObservable(state, { name: "count" }); * ``` */ -export function persistMobx<TState extends object, TPersistedState = TState>( +export function persistObservable< + TState extends object, + TPersistedState = TState, +>( observable: TState, options: PersistOptions<TState, TPersistedState>, ): PersistApi<TState, TPersistedState> { diff --git a/src/adapters/sources/valtio.test.ts b/src/adapters/sources/valtio.test.ts index 88ce202..919a49d 100644 --- a/src/adapters/sources/valtio.test.ts +++ b/src/adapters/sources/valtio.test.ts @@ -20,7 +20,7 @@ mock.module("valtio", () => ({ })); const { createJSONStorage } = await import("../../core/persist-core"); -const { persistValtio } = await import("./valtio"); +const { persistProxy } = await import("./valtio"); class MemoryStorage implements StateStorage { private store = new Map<string, string>(); @@ -81,7 +81,7 @@ function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { }); } -describe("persistValtio", () => { +describe("persistProxy", () => { let memory: MemoryStorage; beforeEach(() => { @@ -96,7 +96,7 @@ describe("persistValtio", () => { version: 0, }); - const persist = persistValtio(proxy, { + const persist = persistProxy(proxy, { name: "count-proxy", storage: jsonStorage, }); @@ -111,7 +111,7 @@ describe("persistValtio", () => { expect(stored?.state.count).toBe(8); const freshProxy = createMockProxy({ count: 0 }); - const rehydrate = persistValtio(freshProxy, { + const rehydrate = persistProxy(freshProxy, { name: "count-proxy", storage: jsonStorage, }); @@ -123,7 +123,7 @@ describe("persistValtio", () => { const proxy = createMockProxy({ count: 0 }); const jsonStorage = createJSONStorage<{ count: number }>(() => memory)!; - const persist = persistValtio(proxy, { + const persist = persistProxy(proxy, { name: "subscribe-proxy", storage: jsonStorage, }); diff --git a/src/adapters/sources/valtio.ts b/src/adapters/sources/valtio.ts index e17ec4f..d8c6b01 100644 --- a/src/adapters/sources/valtio.ts +++ b/src/adapters/sources/valtio.ts @@ -11,12 +11,12 @@ import { persistSource } from "../../core/persist-core"; * @example * ```ts * import { proxy } from "valtio"; - * import { persistValtio } from "@stainless-code/persist/sources/valtio"; + * import { persistProxy } from "@stainless-code/persist/sources/valtio"; * const state = proxy({ count: 0 }); - * const persist = persistValtio(state, { name: "count" }); + * const persist = persistProxy(state, { name: "count" }); * ``` */ -export function persistValtio<TState extends object, TPersistedState = TState>( +export function persistProxy<TState extends object, TPersistedState = TState>( proxyObject: TState, options: PersistOptions<TState, TPersistedState>, ): PersistApi<TState, TPersistedState> { diff --git a/src/adapters/sources/zustand.test.ts b/src/adapters/sources/zustand.test.ts index 78665e5..d584640 100644 --- a/src/adapters/sources/zustand.test.ts +++ b/src/adapters/sources/zustand.test.ts @@ -4,7 +4,7 @@ import type { StoreApi } from "zustand"; import { createJSONStorage } from "../../core/persist-core"; import type { StateStorage } from "../../core/persist-core"; -import { persistZustand } from "./zustand"; +import { persistStore } from "./zustand"; class MemoryStorage implements StateStorage { private store = new Map<string, string>(); @@ -65,7 +65,7 @@ function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { }); } -describe("persistZustand", () => { +describe("persistStore", () => { let memory: MemoryStorage; beforeEach(() => { @@ -80,7 +80,7 @@ describe("persistZustand", () => { version: 0, }); - const persist = persistZustand(store as StoreApi<{ count: number }>, { + const persist = persistStore(store as StoreApi<{ count: number }>, { name: "count-store", storage: jsonStorage, }); @@ -95,13 +95,10 @@ describe("persistZustand", () => { expect(stored?.state.count).toBe(8); const freshStore = createMockStore({ count: 0 }); - const rehydrate = persistZustand( - freshStore as StoreApi<{ count: number }>, - { - name: "count-store", - storage: jsonStorage, - }, - ); + const rehydrate = persistStore(freshStore as StoreApi<{ count: number }>, { + name: "count-store", + storage: jsonStorage, + }); await waitForHydration(rehydrate.hasHydrated); expect(freshStore.getState().count).toBe(8); }); @@ -110,7 +107,7 @@ describe("persistZustand", () => { const store = createMockStore({ count: 0 }); const jsonStorage = createJSONStorage<{ count: number }>(() => memory)!; - const persist = persistZustand(store as StoreApi<{ count: number }>, { + const persist = persistStore(store as StoreApi<{ count: number }>, { name: "subscribe-store", storage: jsonStorage, }); diff --git a/src/adapters/sources/zustand.ts b/src/adapters/sources/zustand.ts index ffe297c..cf598f8 100644 --- a/src/adapters/sources/zustand.ts +++ b/src/adapters/sources/zustand.ts @@ -11,12 +11,12 @@ import { persistSource } from "../../core/persist-core"; * @example * ```ts * import { create } from "zustand"; - * import { persistZustand } from "@stainless-code/persist/sources/zustand"; + * import { persistStore } from "@stainless-code/persist/sources/zustand"; * const store = create(() => ({ count: 0 })); - * const persist = persistZustand(store, { name: "count", storage: createJSONStorage(() => localStorage) }); + * const persist = persistStore(store, { name: "count", storage: createJSONStorage(() => localStorage) }); * ``` */ -export function persistZustand<TState, TPersistedState = TState>( +export function persistStore<TState, TPersistedState = TState>( store: StoreApi<TState>, options: PersistOptions<TState, TPersistedState>, ): PersistApi<TState, TPersistedState> { From a6e7ae2e2ae7f4bad7b58bb511811b6fefcdf2c5 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 17:26:17 +0300 Subject: [PATCH 41/77] =?UTF-8?q?docs(governance):=20sweep=20README=20+=20?= =?UTF-8?q?architecture=20=E2=80=94=20stale=20refs,=20ethos-aligned=20comp?= =?UTF-8?q?at?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- README.md | 12 ++++++------ docs/architecture.md | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d93d15a..f82a59a 100644 --- a/README.md +++ b/README.md @@ -829,12 +829,12 @@ export function hydratedRune(signal: HydrationSignal | null) { ## Compatibility -| Runtime / dep | Supported range | -| --------------- | ----------------------- | -| Node | ^20.19.0 \|\| >=22.12.0 | -| Bun | >=1.0.0 | -| React | ^18.0.0 \|\| ^19.0.0 | -| @tanstack/store | >=0.10.0 | +| Runtime | Supported range | +| ------- | ----------------------- | +| Node | ^20.19.0 \|\| >=22.12.0 | +| Bun | >=1.0.0 | + +Optional peer ranges (frameworks, stores, codecs, backends) live in `package.json` `peerDependencies` — import only the subpaths you use. ## FAQ diff --git a/docs/architecture.md b/docs/architecture.md index d248220..488ba2f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -81,12 +81,12 @@ One API. Sync backends (localStorage) settle hydration before first paint; async Two runners, split by what they need: -| Runner | Scope | Pattern | Why | -| ----------------------------------------- | ------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------- | -| `bun:test` | `src/**/*.test.ts` | `bun test ./src` | No DOM — fast unit tests for the core, codecs, backends, TanStack adapters, and `useHydrated` SSR/snapshot contracts. | -| `vitest` + jsdom + @testing-library/react | `tests-dom/**/*.test.tsx` | `bun run test:dom` | The React `useHydrated` rerender + unmount-detach path needs a DOM + a client renderer (`useSyncExternalStore` reactivity). | +| Runner | Scope | Pattern | Why | +| ----------------------------------------- | ------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | +| `bun:test` | `src/**/*.test.ts` | `bun test ./src` | No DOM — fast unit tests for the core, codecs, backends, transport, source + framework adapters, and hydration SSR/snapshot contracts. | +| `vitest` + jsdom + @testing-library/react | `tests-dom/**/*.test.tsx` | `bun run test:dom` | The React `useHydrated` rerender + unmount-detach path needs a DOM + a client renderer (`useSyncExternalStore` reactivity). | -The split is structural — `tests-dom/` is a top-level directory outside `bun test ./src`'s scan, so the two runners never pick up the same file. `check` runs both in parallel; CI runs them as separate jobs (`Test`, `Test (DOM)`) gated by the single `CI complete` job. The bun suite's `use-hydrated.test.ts` header documents which contracts it pins and which it deliberately leaves to the vitest suite. +The split is structural — `tests-dom/` is a top-level directory outside `bun test ./src`'s scan, so the two runners never pick up the same file. `check` runs both in parallel; CI runs them as separate jobs (`Test`, `Test (DOM)`) gated by the single `CI complete` job. The bun suite's `src/adapters/frameworks/react.test.ts` header documents which contracts it pins and which it deliberately leaves to the vitest suite. ## Reference From 8f1d24c96d2a11d640ac5e48872317649d73158f Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 17:33:56 +0300 Subject: [PATCH 42/77] docs(audit): fix stale paths + subpaths in 2026-07-04 docs-adapters-roi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/audits/2026-07-04-docs-adapters-roi.md | 280 ++++++++++---------- 1 file changed, 140 insertions(+), 140 deletions(-) diff --git a/docs/audits/2026-07-04-docs-adapters-roi.md b/docs/audits/2026-07-04-docs-adapters-roi.md index b2c4f8e..cf0746f 100644 --- a/docs/audits/2026-07-04-docs-adapters-roi.md +++ b/docs/audits/2026-07-04-docs-adapters-roi.md @@ -18,7 +18,7 @@ ## Core capability inventory -52 distinct capabilities confirmed by the core audit (read `src/persist-core.ts` + `src/persist-core.test.ts` in full). Highlights: +52 distinct capabilities confirmed by the core audit (read `src/core/persist-core.ts` + `src/core/persist-core.test.ts` in full). Highlights: Two-axis `storage × codec` seam · structured-clone mode via `identityCodec` (Set/Map/Date survive without a codec) · trailing throttle with bypass for `skipPersist` removals · `destroy()` flushes pending throttled writes in `noRetry` mode · uncapped `retryWrite` that owns termination and covers post-migrate write-back · write-generation guard spanning throttle+retry+destroy+rehydrate · cross-tab `storageArea` identity guard with key-only fallback · `onCrossTabRemove` ownership primitive · `PersistRegistry` clear-all with `allSettled` + rethrow-first · `HydrationSignal` pull-model adapter contract (no initial notify, no payload, SSR renders hydrated) · `alwaysHydratedSignal()` collapses the no-persist ternary · `instanceof Promise` (same-realm) over thenable duck-typing · Node 22+ broken-`localStorage` shape guard · expiry-before-migrate ordering. @@ -26,12 +26,12 @@ Most of these are **only documented in JSDoc + tests**, not consumer prose. ### Extension seams (interface shapes) -- **Storage backend** — `StateStorage<TRaw>` (`src/persist-core.ts:20`): `getItem`/`setItem`/`removeItem`, sync or async (detected via `instanceof Promise`). -- **Codec** — `StorageCodec<S, TRaw>` (`src/persist-core.ts:74`): `encode`/`decode` of `StorageValue<S>` ↔ wire type. -- **Reactive source** — `PersistableSource<TState>` (`src/persist-core.ts:357`): `getState`/`setState`/`subscribe`. -- **Framework hydration** — `HydrationSignal` (`src/hydration.ts:28`): `subscribeHydrated`/`isHydrated`, pull model. -- **Cross-tab event target** — `CrossTabEventTarget` (`src/persist-core.ts:113`). -- **Registry** — `PersistRegistry` (`src/persist-core.ts:369`). +- **Storage backend** — `StateStorage<TRaw>` (`src/core/persist-core.ts:20`): `getItem`/`setItem`/`removeItem`, sync or async (detected via `instanceof Promise`). +- **Codec** — `StorageCodec<S, TRaw>` (`src/core/persist-core.ts:74`): `encode`/`decode` of `StorageValue<S>` ↔ wire type. +- **Reactive source** — `PersistableSource<TState>` (`src/core/persist-core.ts:357`): `getState`/`setState`/`subscribe`. +- **Framework hydration** — `HydrationSignal` (`src/core/hydration.ts:28`): `subscribeHydrated`/`isHydrated`, pull model. +- **Cross-tab event target** — `CrossTabEventTarget` (`src/core/persist-core.ts:113`). +- **Registry** — `PersistRegistry` (`src/core/persist-core.ts:369`). ### Core gaps (no shipped impl, seam only) @@ -41,11 +41,11 @@ No IDB transactions · no `BroadcastChannel` transport shipped · no migration-c ## Adapter landscape -5 subpath entries today (`package.json:32-53`): `.` (zero-dep core) · `./seroval` (seroval codec) · `./idb` (idb-keyval, structured-clone mode) · `./tanstack-store` (`persistStore`/`persistAtom`) · `./react` (`useHydrated` only). +5 subpath entries today: `.` (zero-dep core) · `./codecs/seroval` (seroval codec) · `./backends/idb` (idb-keyval, structured-clone mode) · `./sources/tanstack-store` (`persistStore`/`persistAtom`) · `./frameworks/react` (`useHydrated` only). -Each optional peer is isolated behind its own subpath entry — importing the subpath IS the dep opt-in (enforced by `persist-idb.test.ts:175`). +Each optional peer is isolated behind its own subpath entry — importing the subpath IS the dep opt-in (enforced by `src/adapters/backends/idb.test.ts:175`). -**React surface is just `useHydrated`.** No provider/context, no auto store binding, no auto-`destroy()` on unmount, no RN-specific entry. `use-hydrated.ts:22` JSDoc signals richer ergonomics are intentionally deferred to a higher-layer package. +**React surface is just `useHydrated`.** No provider/context, no auto store binding, no auto-`destroy()` on unmount, no RN-specific entry. `src/adapters/frameworks/react.ts:22` JSDoc signals richer ergonomics are intentionally deferred to a higher-layer package. ### Missing adapters — brainstormed @@ -107,34 +107,34 @@ Ordered by ROI = impact ÷ effort. **Effort:** S / M / L. **Status as of 2026-07 ### Tier 1 — Ship first (high impact, low effort) -| # | Action | Effort | -| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | -| ✅ 1 | Define "hydration-aware" in the README — one paragraph + a "what flashes without it" diagram. Fixes the single biggest tone gap. | S | -| ✅ 2 | Add a complete IDB + React + `useHydrated` + `destroy()` walkthrough to the README. Closes the gap that justifies the library's existence. | S | -| ✅ 3 | Link the generated `docs/api/` site from the README + publish to GitHub Pages (`.nojekyll` already present). | S | -| ✅ 4 | Document `createPersistRegistry` + clear-all-on-logout with a recipe. | S | -| ✅ 5 | Add recipes for `partialize`, `merge`, `retryWrite`, `throttleMs`, `maxAge`, `buster` — six hidden powers, one short block each. | S | -| ✅ 6 | BroadcastChannel → `CrossTabEventTarget` bridge adapter for IDB cross-tab. Seam exists (`persist-core.ts:113`); IDB fires no `storage` events so `crossTab` is silently broken on IDB without it (`persist-idb.ts:62`, `skill:116`). | S | -| ✅ 7 | `expo-secure-store` / `react-native-mmkv` / `AsyncStorage` storage adapters (one subpath each). Unlocks an entire platform. | S each | -| ✅ 8 | `zod`-validated codec adapter — decode runs in existing corrupt-payload try/catch (`persist-core.ts:473`); validation errors map cleanly to `clearCorruptOnFailure`. | S | -| ✅ 9 | Solid + Vue framework hydration adapters — `HydrationSignal` JSDoc names both as targets (`hydration.ts:9-10`); each is a one-liner. | S each | +| # | Action | Effort | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| ✅ 1 | Define "hydration-aware" in the README — one paragraph + a "what flashes without it" diagram. Fixes the single biggest tone gap. | S | +| ✅ 2 | Add a complete IDB + React + `useHydrated` + `destroy()` walkthrough to the README. Closes the gap that justifies the library's existence. | S | +| ✅ 3 | Link the generated `docs/api/` site from the README + publish to GitHub Pages (`.nojekyll` already present). | S | +| ✅ 4 | Document `createPersistRegistry` + clear-all-on-logout with a recipe. | S | +| ✅ 5 | Add recipes for `partialize`, `merge`, `retryWrite`, `throttleMs`, `maxAge`, `buster` — six hidden powers, one short block each. | S | +| ✅ 6 | BroadcastChannel → `CrossTabEventTarget` bridge adapter for IDB cross-tab. Seam exists (`src/core/persist-core.ts:113`); IDB fires no `storage` events so `crossTab` is silently broken on IDB without it (`src/adapters/backends/idb.ts:62`, `skill:116`). | S | +| ✅ 7 | `expo-secure-store` / `react-native-mmkv` / `AsyncStorage` storage adapters (one subpath each). Unlocks an entire platform. | S each | +| ✅ 8 | `zod`-validated codec adapter — decode runs in existing corrupt-payload try/catch (`src/core/persist-core.ts:473`); validation errors map cleanly to `clearCorruptOnFailure`. | S | +| ✅ 9 | Solid + Vue framework hydration adapters — `HydrationSignal` JSDoc names both as targets (`src/core/hydration.ts:9-10`); each is a one-liner. | S each | ### Tier 2 — Build out the surface (high impact, medium effort) -| # | Action | Effort | -| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -| ✅ 10 | Encryption-at-rest codec (WebCrypto + codec). Headline "custom codec" example in JSDoc (`persist-core.ts:69`, `persist-idb.ts:52`, `skill:155`) with no shipped impl. | M | -| 11 | `examples/` monorepo workspace — runnable `tanstack-idb-react`, `tanstack-localstorage-react`, `nextjs-ssr`, `react-native-mmkv`. No runnable demo today. | M | -| 12 | Docs site (VitePress / Astro Starlight) — split wall-of-text README into Getting Started → Adapters → Recipes → Adapter authoring → Reference; host `docs/api/` under it. | M | -| 13 | TanStack Query persister bridge (`persistQueryClient`-shaped). JSDoc cites Query as reference design (`persist-core.ts:12,263`). Flagship integration. | M | -| ✅ 14 | Migration/porting guide — option mapping + conceptual diff vs zustand-persist / redux-persist / query-persist-client / pinia-persist. | S | -| ✅ 15 | Comparison table across the 4 incumbents. README paragraph → table. | S | -| ✅ 16 | Storage & codec decision matrices — lift + expand the skill's 4-row version to consumer docs. | S | -| ✅ 17 | `CompressionStream` codec — native API, ~S now; pairs with binary `TRaw`. | M | -| ✅ 18 | Node `fs` storage adapter — trivial `StateStorage`; unblocks server/SSR/CLI. | S | -| ✅ 19 | Pack-validation + semver gate in CI — `attw --pack` + `knip` + `publint`. Prevents shipping a broken `exports` map. | S | -| ✅ 33 | Angular-signals hydration adapter — `signal` + `effect`-based gate over `HydrationSignal`; peer `@angular/core`. | S | -| ✅ 34 | Preact hydration adapter — near-clone of `./frameworks/react` (`useSyncExternalStore`); peer `preact`. | S | +| # | Action | Effort | +| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| ✅ 10 | Encryption-at-rest codec (WebCrypto + codec). Headline "custom codec" example in JSDoc (`src/core/persist-core.ts:69`, `src/adapters/backends/idb.ts:52`, `skill:155`) with no shipped impl. | M | +| 11 | `examples/` monorepo workspace — runnable `tanstack-idb-react`, `tanstack-localstorage-react`, `nextjs-ssr`, `react-native-mmkv`. No runnable demo today. | M | +| 12 | Docs site (VitePress / Astro Starlight) — split wall-of-text README into Getting Started → Adapters → Recipes → Adapter authoring → Reference; host `docs/api/` under it. | M | +| 13 | TanStack Query persister bridge (`persistQueryClient`-shaped). JSDoc cites Query as reference design (`src/core/persist-core.ts:12,263`). Flagship integration. | M | +| ✅ 14 | Migration/porting guide — option mapping + conceptual diff vs zustand-persist / redux-persist / query-persist-client / pinia-persist. | S | +| ✅ 15 | Comparison table across the 4 incumbents. README paragraph → table. | S | +| ✅ 16 | Storage & codec decision matrices — lift + expand the skill's 4-row version to consumer docs. | S | +| ✅ 17 | `CompressionStream` codec — native API, ~S now; pairs with binary `TRaw`. | M | +| ✅ 18 | Node `fs` storage adapter — trivial `StateStorage`; unblocks server/SSR/CLI. | S | +| ✅ 19 | Pack-validation + semver gate in CI — `attw --pack` + `knip` + `publint`. Prevents shipping a broken `exports` map. | S | +| ✅ 33 | Angular-signals hydration adapter — `signal` + `effect`-based gate over `HydrationSignal`; peer `@angular/core`. | S | +| ✅ 34 | Preact hydration adapter — near-clone of `./frameworks/react` (`useSyncExternalStore`); peer `preact`. | S | ### Tier 3 — Maturity & polish (medium impact, medium effort) @@ -150,13 +150,13 @@ Ordered by ROI = impact ÷ effort. **Effort:** S / M / L. **Status as of 2026-07 ### Tier 4 — Strategic bets (high impact, high effort) -| # | Action | Effort | -| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| 28 | React ergonomics layer — `<PersistProvider>` + context + `usePersisted(store)` selector binding + auto-`destroy()`. `use-hydrated.ts:22` signals this is intentionally deferred. | M-L | -| 29 | StackBlitz / CodeSandbox playground embedded in docs site. | M | -| 30 | OPFS + SQLite-WASM + Cloudflare KV/Durable Objects storage adapters. | M-L | -| 31 | Migration-chain helper — `createMigrationChain({...})`; today `migrate` is a single callback, v0→v1→v2 chaining is user-written. | M | -| ✅ 32 | Continue the TanStack upstream pitch (`docs/plans/upstream-tanstack-pitch.md`) — adapter breadth (#13, #9) + docs polish (#1, #2, #12) are the leverage. Ongoing. | ongoing | +| # | Action | Effort | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| 28 | React ergonomics layer — `<PersistProvider>` + context + `usePersisted(store)` selector binding + auto-`destroy()`. `src/adapters/frameworks/react.ts:22` signals this is intentionally deferred. | M-L | +| 29 | StackBlitz / CodeSandbox playground embedded in docs site. | M | +| 30 | OPFS + SQLite-WASM + Cloudflare KV/Durable Objects storage adapters. | M-L | +| 31 | Migration-chain helper — `createMigrationChain({...})`; today `migrate` is a single callback, v0→v1→v2 chaining is user-written. | M | +| ✅ 32 | Continue the TanStack upstream pitch (`docs/plans/upstream-tanstack-pitch.md`) — adapter breadth (#13, #9) + docs polish (#1, #2, #12) are the leverage. Ongoing. | ongoing | --- @@ -183,11 +183,11 @@ Four GLM 5.2 subagents ran in parallel, one per lane. Resume any for a deeper dr # Appendix A — Core capability inventory (full subagent report) -Read-only audit of `src/persist-core.ts`, `src/persist-core.test.ts`, `src/hydration.ts`, `src/hydration.test.ts`, `src/index.ts`. All file:line refs point to `src/`. +Read-only audit of `src/core/persist-core.ts`, `src/core/persist-core.test.ts`, `src/core/hydration.ts`, `src/core/hydration.test.ts`, `src/core/index.ts`. All file:line refs point to `src/core/` (core) or `src/adapters/<seam>/` (adapters). ## A.1 Public API surface -### `persist-core.ts` +### `src/core/persist-core.ts` **Types / interfaces** @@ -217,7 +217,7 @@ Read-only audit of `src/persist-core.ts`, `src/persist-core.test.ts`, `src/hydra | `createJSONStorage<S>(getStorage, options?)` | `521:526` | `createStorage(getStorage, jsonCodec(options))` convenience. | | `persistSource(source, options)` | `586:1124` | Attach persist to any `PersistableSource`; returns `PersistApi` (or no-op API when storage unavailable). | -### `hydration.ts` +### `src/core/hydration.ts` | Export | Line | One-line | | --------------------------- | --------- | ------------------------------------------------------------------------------------------------ | @@ -284,7 +284,7 @@ Re-exports `./persist-core` + `./hydration` only. No barrel into optional peers. | 48 | **Idempotent unsubscribe** | `84:90`, test `236:255` | Second call to a stale unsub doesn't re-trigger teardown. | | 49 | **Pull-model signal (no initial notification, no payload)** | JSDoc `18:21`, tests `198:279` | Listeners never invoked on subscribe; (re)read `isHydrated()` after attaching; missed transitions recovered by snapshot re-read. | | 50 | **`alwaysHydratedSignal()` (no-persist uniform handle)** | `103:108` | Drops the `persist ? toHydrationSignal(persist) : null` ternary. | -| 51 | **Zero-dep core (enforced by test)** | test `19:30` | `persist-core.ts` has no value imports; type-only imports only. | +| 51 | **Zero-dep core (enforced by test)** | test `19:30` | `src/core/persist-core.ts` has no value imports; type-only imports only. | | 52 | **Async-vs-sync detection via `instanceof Promise` (not thenable)** | JSDoc `13:18`, `createStorage` `494` | A stored value with a `.then` property is never mistaken for a pending read; same-realm native promises only. | ## A.3 Extension points / seams (interface shapes) @@ -394,7 +394,7 @@ export interface HydrationSource { ## A.5 Gaps / limitations -- **No IndexedDB integration in core** — only the seam (`StateStorage<TRaw>` + `identityCodec`). Actual IDB lives in the `./persist-idb` subpath (idb-keyval peer). No transaction batching, no key-range cursors, no IDB-specific error mapping in core. +- **No IndexedDB integration in core** — only the seam (`StateStorage<TRaw>` + `identityCodec`). Actual IDB lives in the `./backends/idb` subpath (idb-keyval peer). No transaction batching, no key-range cursors, no IDB-specific error mapping in core. - **No `BroadcastChannel` transport shipped** — only the `CrossTabEventTarget` seam. Default is `window` `storage` events (same-origin only, no large-payload guarantee). A `BroadcastChannel` bridge is user-implemented. - **No schema-migration helper / migration chain** — `migrate` is a single callback receiving the stored version; multi-step v0→v1→v2 chaining is user-written. - **No compression** — pluggable via `StorageCodec` (encode/decode), but nothing shipped. @@ -417,7 +417,7 @@ export interface HydrationSource { ### Notes for docs-adequacy / ROI analysis - The JSDoc on `PersistOptions` is exceptionally thorough (every invariant, every trade-off) — but several runtime invariants (write-generation guard, re-schedule-after-rehydrate, `noRetry` teardown flush, `instanceof Promise` choice, Node-22 shape check) live only in code comments + tests, not in any consumer-facing README. -- The `HydrationSignal` adapter contract is fully specified in `hydration.ts` JSDoc but is exactly the kind of thing adapter authors need promoted to top-level docs. +- The `HydrationSignal` adapter contract is fully specified in `src/core/hydration.ts` JSDoc but is exactly the kind of thing adapter authors need promoted to top-level docs. - Gaps are mostly "ship more codecs/transports" (compression, encryption, `BroadcastChannel`, IDB transactions) — all have clean seams already in place, so ROI on adding them is high (no core rework needed). --- @@ -426,50 +426,50 @@ export interface HydrationSource { ## B.1 Current adapters / entry points -The `exports` map (`package.json:32-53`) ships 5 subpath entries. Each optional peer is isolated behind its own subpath entry — importing the subpath IS the dep opt-in (enforced by a test at `persist-idb.test.ts:175-199`). +The `exports` map in `package.json` ships 5 subpath entries. Each optional peer is isolated behind its own subpath entry — importing the subpath IS the dep opt-in (enforced by a test at `src/adapters/backends/idb.test.ts:175-199`). ### `.` (the core entry) -- **Provides:** re-exports `persist-core` + `hydration` (`src/index.ts:5-6`). Zero-dep. +- **Provides:** re-exports `persist-core` + `hydration` (`src/core/index.ts:5-6`). Zero-dep. - **Implements:** the canonical adapter contracts + `persistSource` engine + `createJSONStorage` / `createStorage` / `jsonCodec` / `identityCodec` / `createPersistRegistry` + `toHydrationSignal` / `alwaysHydratedSignal`. -- **Deps:** none. `peerDependenciesMeta` marks everything optional (`package.json:116-129`). +- **Deps:** none. `peerDependenciesMeta` in `package.json` marks everything optional. - **Consumer meaning:** the framework-agnostic persistence middleware. Bring your own reactive source via `persistSource`, or wire a framework adapter on top. This is also the entry non-TanStack users (zustand/Redux/hand-rolled atom) use — `persistSource` is the universal seam. -### `./seroval` +### `./codecs/seroval` -- **Provides:** `serovalCodec` + `createSerovalStorage` (`persist-seroval.ts:21,37`). -- **Implements:** `StorageCodec<S>` from persist-core (`persist-seroval.ts:9`). +- **Provides:** `serovalCodec` + `createSerovalStorage` (`src/adapters/codecs/seroval.ts:21,37`). +- **Implements:** `StorageCodec<S>` from persist-core (`src/adapters/codecs/seroval.ts:9`). - **Deps:** `seroval` (optional peer, `>=1.0.0`). -- **Consumer meaning:** a drop-in codec so `Set` / `Map` / `Date` round-trip through any _string-keyed_ backend (`localStorage`, `sessionStorage`, custom). One factory: `createSerovalStorage(() => localStorage)` (`persist-seroval.ts:31-35`). The codec is also exposed standalone so it composes over `createStorage` for non-default backends (`persist-seroval.test.ts:159-180`). +- **Consumer meaning:** a drop-in codec so `Set` / `Map` / `Date` round-trip through any _string-keyed_ backend (`localStorage`, `sessionStorage`, custom). One factory: `createSerovalStorage(() => localStorage)` (`src/adapters/codecs/seroval.ts:31-35`). The codec is also exposed standalone so it composes over `createStorage` for non-default backends (`src/adapters/codecs/seroval.test.ts:159-180`). -### `./idb` +### `./backends/idb` -- **Provides:** `idbStateStorage` + `createIdbStorage` (`persist-idb.ts:34,74`). -- **Implements:** `StateStorage<TRaw>` (`persist-idb.ts:11`) with `TRaw = StorageValue<S>` — the **structured-clone mode** that the generic wire-type seam enables. +- **Provides:** `idbStateStorage` + `createIdbStorage` (`src/adapters/backends/idb.ts:34,74`). +- **Implements:** `StateStorage<TRaw>` (`src/adapters/backends/idb.ts:11`) with `TRaw = StorageValue<S>` — the **structured-clone mode** that the generic wire-type seam enables. - **Deps:** `idb-keyval` (optional peer, `>=4.0.0`). -- **Consumer meaning:** IndexedDB-backed persistence that stores the `StorageValue` envelope _natively_ — `Set`/`Map`/`Date` round-trip via structured clone with **no codec at all** (`identityCodec`), better DevTools inspection (objects, not encoded strings). Codec use cases (encryption/compression) compose as a one-liner over `idbStateStorage` + `createStorage` (`persist-idb.ts:52-58`). Custom idb-keyval `store` for namespacing (`persist-idb.ts:16-23`). +- **Consumer meaning:** IndexedDB-backed persistence that stores the `StorageValue` envelope _natively_ — `Set`/`Map`/`Date` round-trip via structured clone with **no codec at all** (`identityCodec`), better DevTools inspection (objects, not encoded strings). Codec use cases (encryption/compression) compose as a one-liner over `idbStateStorage` + `createStorage` (`src/adapters/backends/idb.ts:52-58`). Custom idb-keyval `store` for namespacing (`src/adapters/backends/idb.ts:16-23`). -### `./tanstack-store` +### `./sources/tanstack-store` -- **Provides:** `persistStore` + `persistAtom` (`persist-tanstack.ts:24,56`). -- **Implements:** thin wrappers that supply the `PersistableSource` shape (`persist-core.ts:357-361`) to `persistSource`. -- **Deps:** `@tanstack/store` (optional peer, `>=0.10.0`); types only (`persist-tanstack.ts:4`). -- **Consumer meaning:** the only shipped reactive-source adapters. `persistStore` wraps a `Store` (action-bearing via `StoreActionMap`); `persistAtom` wraps a writable `Atom` and overrides default `merge` to **replace** (not shallow-spread) so primitive atom values aren't corrupted (`persist-tanstack.ts:74-80`), and throws on readonly atoms (`persist-tanstack.ts:60-62`). +- **Provides:** `persistStore` + `persistAtom` (`src/adapters/sources/tanstack-store.ts:24,56`). +- **Implements:** thin wrappers that supply the `PersistableSource` shape (`src/core/persist-core.ts:357-361`) to `persistSource`. +- **Deps:** `@tanstack/store` (optional peer, `>=0.10.0`); types only (`src/adapters/sources/tanstack-store.ts:4`). +- **Consumer meaning:** the only shipped reactive-source adapters. `persistStore` wraps a `Store` (action-bearing via `StoreActionMap`); `persistAtom` wraps a writable `Atom` and overrides default `merge` to **replace** (not shallow-spread) so primitive atom values aren't corrupted (`src/adapters/sources/tanstack-store.ts:74-80`), and throws on readonly atoms (`src/adapters/sources/tanstack-store.ts:60-62`). -### `./react` +### `./frameworks/react` -- **Provides:** `useHydrated` (`use-hydrated.ts:37`). -- **Implements:** mounts a `HydrationSignal` (`hydration.ts:28-31`) into React via `useSyncExternalStore`. +- **Provides:** `useHydrated` (`src/adapters/frameworks/react.ts:37`). +- **Implements:** mounts a `HydrationSignal` (`src/core/hydration.ts:28-31`) into React via `useSyncExternalStore`. - **Deps:** `react` (optional peer, `^18.0.0 || ^19.0.0`). -- **Consumer meaning:** the _only_ React surface. Returns `{ hydrated }` to gate the hydrate flash on async backends (IndexedDB). State reads stay on the store (`useSelector`), not this hook (`use-hydrated.ts:7-10`). Null signal → `hydrated: true`. Server snapshot is always `true` (`use-hydrated.ts:42`). +- **Consumer meaning:** the _only_ React surface. Returns `{ hydrated }` to gate the hydrate flash on async backends (IndexedDB). State reads stay on the store (`useSelector`), not this hook (`src/adapters/frameworks/react.ts:7-10`). Null signal → `hydrated: true`. Server snapshot is always `true` (`src/adapters/frameworks/react.ts:42`). ## B.2 Adapter pattern -Three orthogonal seams, all in `persist-core.ts` / `hydration.ts`. An "adapter" picks exactly one seam to extend; it never reimplements the `persistSource` plumbing. +Three orthogonal seams, all in `src/core/persist-core.ts` / `src/core/hydration.ts`. An "adapter" picks exactly one seam to extend; it never reimplements the `persistSource` plumbing. ### Seam A — storage backend: `StateStorage<TRaw>` -```src/persist-core.ts:20:24 +```src/core/persist-core.ts:20:24 export interface StateStorage<TRaw = string> { getItem: (name: string) => TRaw | null | Promise<TRaw | null>; setItem: (name: string, value: TRaw) => void | Promise<void>; @@ -477,22 +477,22 @@ export interface StateStorage<TRaw = string> { } ``` -Sync or async (detected via `instanceof Promise`, _not_ thenable duck-typing — `persist-core.ts:14-19`). A new backend adapter either hand-rolls these three methods, or wraps an existing backend (like `idbStateStorage` wraps idb-keyval, `persist-idb.ts:37-42`) and passes it to `createStorage`. +Sync or async (detected via `instanceof Promise`, _not_ thenable duck-typing — `src/core/persist-core.ts:14-19`). A new backend adapter either hand-rolls these three methods, or wraps an existing backend (like `idbStateStorage` wraps idb-keyval, `src/adapters/backends/idb.ts:37-42`) and passes it to `createStorage`. ### Seam B — codec: `StorageCodec<S, TRaw>` -```src/persist-core.ts:74:77 +```src/core/persist-core.ts:74:77 export interface StorageCodec<S, TRaw = string> { encode: (value: StorageValue<S>) => TRaw; decode: (raw: TRaw) => StorageValue<S>; } ``` -A new codec plugs into `createStorage(getStorage, codec, options)` (`persist-core.ts:444-448`) — that's the entire integration surface. `serovalCodec` (`persist-seroval.ts:21-24`) is the reference. +A new codec plugs into `createStorage(getStorage, codec, options)` (`src/core/persist-core.ts:444-448`) — that's the entire integration surface. `serovalCodec` (`src/adapters/codecs/seroval.ts:21-24`) is the reference. ### Seam C — reactive source: `PersistableSource<TState>` -```src/persist-core.ts:357:361 +```src/core/persist-core.ts:357:361 export interface PersistableSource<TState> { getState: () => TState; setState: (updater: (prev: TState) => TState) => void; @@ -500,73 +500,73 @@ export interface PersistableSource<TState> { } ``` -A new framework/store adapter constructs this shape and calls `persistSource(source, opts)`. `persistStore`/`persistAtom` (`persist-tanstack.ts:28-38`, `64-72`) are the reference. +A new framework/store adapter constructs this shape and calls `persistSource(source, opts)`. `persistStore`/`persistAtom` (`src/adapters/sources/tanstack-store.ts:28-38`, `64-72`) are the reference. ### Seam D — framework hydration: `HydrationSignal` -```src/hydration.ts:28:31 +```src/core/hydration.ts:28:31 export interface HydrationSignal { subscribeHydrated: (listener: () => void) => () => void; isHydrated: () => boolean; } ``` -Adapter contract (`hydration.ts:14-27`): multiple concurrent subscribers, idempotent unsubscribe, **no** initial notification, **no** payload (pull model), SSR renders `hydrated: true`, `null` signal → hydrated. `useHydrated` is the reference implementation (`use-hydrated.ts:37-44`). +Adapter contract (`src/core/hydration.ts:14-27`): multiple concurrent subscribers, idempotent unsubscribe, **no** initial notification, **no** payload (pull model), SSR renders `hydrated: true`, `null` signal → hydrated. `useHydrated` is the reference implementation (`src/adapters/frameworks/react.ts:37-44`). **Adapter contract checklist for a new adapter:** -1. Own your dep — ship a new subpath entry, mark it optional in `peerDependenciesMeta`, never import it from core/other entries (the isolation test pattern at `persist-idb.test.ts:175-199`). +1. Own your dep — ship a new subpath entry, mark it optional in `peerDependenciesMeta`, never import it from core/other entries (the isolation test pattern at `src/adapters/backends/idb.test.ts:175-199`). 2. Map onto exactly one seam (A/B/C/D); compose via the exposed factory (`createStorage` / `persistSource` / `toHydrationSignal`), never reimplement the engine. -3. For framework adapters, implement the SSR policy (`getServerSnapshot` returns `true`) in the adapter, not the signal (`hydration.ts:22-25`). +3. For framework adapters, implement the SSR policy (`getServerSnapshot` returns `true`) in the adapter, not the signal (`src/core/hydration.ts:22-25`). ## B.3 Missing adapters ### Storage backends -| Adapter | Effort | Demand | Justification | -| ---------------------------------------------------------------- | ------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **sessionStorage** wrapper (`createSessionStorage` / doc recipe) | S | med | Already works today via `createJSONStorage(() => sessionStorage)` — but no named factory or SKILL entry. Cross-tab caveat is documented (`skills/tanstack-store/SKILL.md:116`). Low payoff; mostly a DX/ discoverability gap. | -| **OPFS** (Origin Private File System) | M | med | High-volume structured state in browsers; async, file-backed. Fits `StateStorage<TRaw>` naturally. Growing demand as localStorage hits quota. | -| **Node fs / file** | S | med | Server/SSR/CLI persistence. Trivial `StateStorage` over `fs.readFileSync`/`writeFileSync` (or async). Unblocks Node usage entirely — currently the lib is browser-flavored. | -| **memory** (test/fixture storage) | S | low | Already hand-rolled in every test file (`persist-seroval.test.ts:7-25`, `persist-tanstack.test.ts:10-28`, `use-hydrated.test.ts:25-39`). Shipping one would dedupe ~3 copies. | -| **Redis** | M | med | Server-side persistent state; async. Pairs with Node fs entry for a real backend story. | -| **SQLite WASM** (wa-sqlite / sqlite-wasm) | L | med | Structured-clone mode like IDB; powerful but heavy. Better as a community recipe than a shipped peer. | -| **expo-secure-store** | S | high | React Native secure persistence is a top requested feature for any persist lib; small surface. | -| **MMKV** (react-native-mmkv) | S | high | RN default fast KV; synchronous, drops straight into `StateStorage`. Very high RN demand signal. | -| **AsyncStorage** (RN) | S | high | Legacy RN fallback; still widely used. | -| **Chrome `storage.area`** (`local`/`sync`/`session`) | S | med | Extension developers — `localStorage` is forbidden in MV3 service workers. Strong niche demand. | -| **cookies** | M | low | Server-rendered hydration story; awkward (size limits, HTTP coupling). Better as recipe. | -| **Cloudflare KV / Durable Objects** | M | med | Edge runtime persistence; async `StateStorage`. Growing with Workers adoption. | -| **BroadcastChannel bridge** for IDB cross-tab | S | high | Explicitly called out as missing (`persist-idb.ts:62-64`, `skills/tanstack-store/SKILL.md:116`) — IDB fires no `storage` events, so `crossTab` is broken on IDB without a `crossTabEventTarget` bridge. The seam exists (`CrossTabEventTarget`, `persist-core.ts:113-122`); only the adapter doesn't. | +| Adapter | Effort | Demand | Justification | +| ---------------------------------------------------------------- | ------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **sessionStorage** wrapper (`createSessionStorage` / doc recipe) | S | med | Already works today via `createJSONStorage(() => sessionStorage)` — but no named factory or SKILL entry. Cross-tab caveat is documented (`skills/tanstack-store/SKILL.md:116`). Low payoff; mostly a DX/ discoverability gap. | +| **OPFS** (Origin Private File System) | M | med | High-volume structured state in browsers; async, file-backed. Fits `StateStorage<TRaw>` naturally. Growing demand as localStorage hits quota. | +| **Node fs / file** | S | med | Server/SSR/CLI persistence. Trivial `StateStorage` over `fs.readFileSync`/`writeFileSync` (or async). Unblocks Node usage entirely — currently the lib is browser-flavored. | +| **memory** (test/fixture storage) | S | low | Already hand-rolled in every test file (`src/adapters/codecs/seroval.test.ts:7-25`, `src/adapters/sources/tanstack-store.test.ts:10-28`, `src/adapters/frameworks/react.test.ts:25-39`). Shipping one would dedupe ~3 copies. | +| **Redis** | M | med | Server-side persistent state; async. Pairs with Node fs entry for a real backend story. | +| **SQLite WASM** (wa-sqlite / sqlite-wasm) | L | med | Structured-clone mode like IDB; powerful but heavy. Better as a community recipe than a shipped peer. | +| **expo-secure-store** | S | high | React Native secure persistence is a top requested feature for any persist lib; small surface. | +| **MMKV** (react-native-mmkv) | S | high | RN default fast KV; synchronous, drops straight into `StateStorage`. Very high RN demand signal. | +| **AsyncStorage** (RN) | S | high | Legacy RN fallback; still widely used. | +| **Chrome `storage.area`** (`local`/`sync`/`session`) | S | med | Extension developers — `localStorage` is forbidden in MV3 service workers. Strong niche demand. | +| **cookies** | M | low | Server-rendered hydration story; awkward (size limits, HTTP coupling). Better as recipe. | +| **Cloudflare KV / Durable Objects** | M | med | Edge runtime persistence; async `StateStorage`. Growing with Workers adoption. | +| **BroadcastChannel bridge** for IDB cross-tab | S | high | Explicitly called out as missing (`src/adapters/backends/idb.ts:62-64`, `skills/tanstack-store/SKILL.md:116`) — IDB fires no `storage` events, so `crossTab` is broken on IDB without a `crossTabEventTarget` bridge. The seam exists (`CrossTabEventTarget`, `src/core/persist-core.ts:113-122`); only the adapter doesn't. | ### Codecs -| Codec | Effort | Demand | Justification | -| ------------------------------------------------------------ | ------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **JSON** | — | — | Already default (`jsonCodec`, `persist-core.ts:407-412`). | -| **seroval** | — | — | Shipped. | -| **structuredClone** codec | S | low | Largely subsumed by IDB identity mode; marginal value as a codec. | -| **MessagePack / cbor-x / CBOR** | S | med | Compact binary wire format; `cbor-x` is very fast. Drops into `StorageCodec<S, TRaw=Uint8Array>` — needs `TRaw` plumbing already in the seam. | -| **zod-validated encode/decode** | S | high | Schema-gated persistence is a frequently-requested feature; `decode` runs in the existing try/catch corrupt-payload path (`persist-core.ts:473-488`), so validation errors map cleanly to `clearCorruptOnFailure`. | -| **protobuf** | L | low | Strongly-typed but heavy toolchain; better as recipe. | -| **encryption-at-rest** (WebCrypto + codec) | M | high | Explicitly framed as the canonical "custom codec" use case (`persist-core.ts:69-70`, `persist-idb.ts:52-58`, `skills/tanstack-store/SKILL.md:155`). No shipped adapter despite being the headline composition example. | -| **compression** (gzip/brotli via WASM / `CompressionStream`) | M | med | Native `CompressionStream` makes this a ~S now; pairs with binary `TRaw`. | -| **superjson / devalue** | S | med | Already name-dropped as drop-ins (`persist-core.ts:69`, `persist-core.ts:437-440`); seroval covers the same niche so demand is partial. | -| **immutable-hamt** | L | low | Niche; structural sharing for huge state. Better as external lib. | +| Codec | Effort | Demand | Justification | +| ------------------------------------------------------------ | ------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **JSON** | — | — | Already default (`jsonCodec`, `src/core/persist-core.ts:407-412`). | +| **seroval** | — | — | Shipped. | +| **structuredClone** codec | S | low | Largely subsumed by IDB identity mode; marginal value as a codec. | +| **MessagePack / cbor-x / CBOR** | S | med | Compact binary wire format; `cbor-x` is very fast. Drops into `StorageCodec<S, TRaw=Uint8Array>` — needs `TRaw` plumbing already in the seam. | +| **zod-validated encode/decode** | S | high | Schema-gated persistence is a frequently-requested feature; `decode` runs in the existing try/catch corrupt-payload path (`src/core/persist-core.ts:473-488`), so validation errors map cleanly to `clearCorruptOnFailure`. | +| **protobuf** | L | low | Strongly-typed but heavy toolchain; better as recipe. | +| **encryption-at-rest** (WebCrypto + codec) | M | high | Explicitly framed as the canonical "custom codec" use case (`src/core/persist-core.ts:69-70`, `src/adapters/backends/idb.ts:52-58`, `skills/tanstack-store/SKILL.md:155`). No shipped adapter despite being the headline composition example. | +| **compression** (gzip/brotli via WASM / `CompressionStream`) | M | med | Native `CompressionStream` makes this a ~S now; pairs with binary `TRaw`. | +| **superjson / devalue** | S | med | Already name-dropped as drop-ins (`src/core/persist-core.ts:69`, `src/core/persist-core.ts:437-440`); seroval covers the same niche so demand is partial. | +| **immutable-hamt** | L | low | Niche; structural sharing for huge state. Better as external lib. | ### Framework integrations -| Adapter | Effort | Demand | Justification | -| ------------------------------------------------------------- | ------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **React `useHydrated`** | — | — | Shipped. | -| **Solid** `from(signal)` adapter | S | high | The `HydrationSignal` contract (`hydration.ts:8-10`) explicitly names Solid `from` as a target. Trivial one-liner; high Solid demand. | -| **Vue** (`shallowRef` + watch) | S | high | Also explicitly named in the signal JSDoc (`hydration.ts:10`). | -| **Svelte** (`createSubscriber` / readable store) | S | med | Also named (`hydration.ts:9`). | -| **Preact** | S | med | `useSyncExternalStore` compatible — near-clone of React adapter. | -| **Angular signals** | S | med | `signal` + `effect`-based hydration gate; growing signals userbase. | -| **TanStack Query** persister bridge | M | high | Natural cross-sell (the JSDoc repeatedly cites TanStack Query as the reference design, e.g. `persist-core.ts:12`, `persist-core.ts:263-264`, `persist-core.ts:88`). A `persistQueryClient`-shaped adapter would be a flagship integration. | -| **React provider/context/auto-binding** | M | high | See §B.5 — React users get only a hook, no ergonomics layer. | -| **Zustand / Jotai / Valtio / MobX / signals** source adapters | S each | med | All reduce to `PersistableSource` (the skill explicitly says "pass a custom implementation to persist anything else", `skills/tanstack-store/SKILL.md:135-139`). Each is ~10 lines; the question is whether to ship them or keep them as recipes. | +| Adapter | Effort | Demand | Justification | +| ------------------------------------------------------------- | ------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **React `useHydrated`** | — | — | Shipped. | +| **Solid** `from(signal)` adapter | S | high | The `HydrationSignal` contract (`src/core/hydration.ts:8-10`) explicitly names Solid `from` as a target. Trivial one-liner; high Solid demand. | +| **Vue** (`shallowRef` + watch) | S | high | Also explicitly named in the signal JSDoc (`src/core/hydration.ts:10`). | +| **Svelte** (`createSubscriber` / readable store) | S | med | Also named (`src/core/hydration.ts:9`). | +| **Preact** | S | med | `useSyncExternalStore` compatible — near-clone of React adapter. | +| **Angular signals** | S | med | `signal` + `effect`-based hydration gate; growing signals userbase. | +| **TanStack Query** persister bridge | M | high | Natural cross-sell (the JSDoc repeatedly cites TanStack Query as the reference design, e.g. `src/core/persist-core.ts:12`, `src/core/persist-core.ts:263-264`, `src/core/persist-core.ts:88`). A `persistQueryClient`-shaped adapter would be a flagship integration. | +| **React provider/context/auto-binding** | M | high | See §B.5 — React users get only a hook, no ergonomics layer. | +| **Zustand / Jotai / Valtio / MobX / signals** source adapters | S each | med | All reduce to `PersistableSource` (the skill explicitly says "pass a custom implementation to persist anything else", `skills/tanstack-store/SKILL.md:135-139`). Each is ~10 lines; the question is whether to ship them or keep them as recipes. | **Highest-leverage gaps (rough ranking):** MMKV/AsyncStorage/expo-secure-store (RN block), BroadcastChannel IDB cross-tab bridge (completes a documented-but-missing feature), encryption-at-rest codec (the headline example with no implementation), zod-validated codec, Solid/Vue framework adapters, TanStack Query bridge. @@ -575,25 +575,25 @@ Adapter contract (`hydration.ts:14-27`): multiple concurrent subscribers, idempo **None.** No `examples/`, `example/`, `demo/`, `playground/`, `snippets/`, or `sandboxes/` directory exists (Glob returned 0). The only runnable artefacts beyond `src/*.test.ts` and `tests-dom/*.test.tsx` are: - `skills/tanstack-store/SKILL.md` — the single shipped skill (the `skills` dir contains only `tanstack-store/`, confirmed by `ls`). -- Inline `@example` JSDoc blocks in each adapter module (`persist-idb.ts:67-72`, `persist-seroval.ts:29-35`, `persist-tanstack.ts:13-22`, `use-hydrated.ts:25-35`). +- Inline `@example` JSDoc blocks in each adapter module (`src/adapters/backends/idb.ts:67-72`, `src/adapters/codecs/seroval.ts:29-35`, `src/adapters/sources/tanstack-store.ts:13-22`, `src/adapters/frameworks/react.ts:25-35`). - `README.md` and `docs/architecture.md` prose snippets. The `package.json:25-29` `files` array ships `dist` + `skills` only — no examples are published either. There is no end-to-end runnable app demonstrating wiring (store + storage + codec + hydration gate) outside the test suite. -## B.5 `./react` entry nuance +## B.5 `./frameworks/react` entry nuance -`useHydrated` is the **entire** React surface. Confirmed: `exports` maps `./react` → only `./dist/use-hydrated.{d.,}mts` (`package.json:49-52`), and `use-hydrated.ts` exports one function (`use-hydrated.ts:37`). +`useHydrated` is the **entire** React surface. Confirmed: `exports` maps `./frameworks/react` → only `./dist/frameworks/react.{d.,}mts` in `package.json`, and `src/adapters/frameworks/react.ts` exports one function (`src/adapters/frameworks/react.ts:37`). **What React users do NOT get:** -- **No provider / context.** No `<PersistProvider>`, no React context, no Devtools. Each component manually threads a `HydrationSignal` to `useHydrated` (`use-hydrated.ts:32-34`). -- **No automatic store binding.** The hook returns _only_ `hydrated` (`use-hydrated.ts:5-11`); state reads are explicitly the caller's job via `useSelector` from `@tanstack/store` (the JSDoc is emphatic: "Returns ONLY `hydrated` — state reads go through `useSelector`", `use-hydrated.ts:18-19`). There is no `usePersisted(store)` or selector-binding helper. -- **No `persistStore`-aware React hook.** The TanStack adapter (`./tanstack-store`) and the React adapter (`./react`) are decoupled — a React user imports `persistStore` from one subpath, `toHydrationSignal` from `.` core, and `useHydrated` from another, wiring them by hand (the canonical 3-line recipe at `skills/tanstack-store/SKILL.md:75-82`). +- **No provider / context.** No `<PersistProvider>`, no React context, no Devtools. Each component manually threads a `HydrationSignal` to `useHydrated` (`src/adapters/frameworks/react.ts:32-34`). +- **No automatic store binding.** The hook returns _only_ `hydrated` (`src/adapters/frameworks/react.ts:5-11`); state reads are explicitly the caller's job via `useSelector` from `@tanstack/store` (the JSDoc is emphatic: "Returns ONLY `hydrated` — state reads go through `useSelector`", `src/adapters/frameworks/react.ts:18-19`). There is no `usePersisted(store)` or selector-binding helper. +- **No `persistStore`-aware React hook.** The TanStack adapter (`./sources/tanstack-store`) and the React adapter (`./frameworks/react`) are decoupled — a React user imports `persistStore` from one subpath, `toHydrationSignal` from `.` core, and `useHydrated` from another, wiring them by hand (the canonical 3-line recipe at `skills/tanstack-store/SKILL.md:75-82`). - **No automatic `destroy()` on unmount.** Teardown is manual — the skill documents the `useEffect` cleanup pattern as user responsibility (`skills/tanstack-store/SKILL.md:96-101`). - **No hydration-aware `<Suspense>`/`use()` integration, no `useSyncExternalStore` selector helper, no SSR helper beyond the implicit server-snapshot policy.** - **No React Native-specific entry** (no MMKV/AsyncStorage/expo-secure-store wiring — see §B.3). -The design is deliberately minimal — `use-hydrated.ts:22-24` states the hook is "the reference" implementation of the framework-agnostic `HydrationSignal` adapter contract, signaling that richer React ergonomics (provider, auto-binding, Devtools) are intentionally left to consumers or a future higher-layer package. +The design is deliberately minimal — `src/adapters/frameworks/react.ts:22-24` states the hook is "the reference" implementation of the framework-agnostic `HydrationSignal` adapter contract, signaling that richer React ergonomics (provider, auto-binding, Devtools) are intentionally left to consumers or a future higher-layer package. --- @@ -620,17 +620,17 @@ The design is deliberately minimal — `use-hydrated.ts:22-24` states the hook i ### `@stainless-code/persist` (core) -| Export | Status | Where | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `persistSource` | ✅ | README:117, skill:131–139 | -| `PersistApi` (interface: `rehydrate`, `hasHydrated`, `onHydrate`, `onFinishHydration`, `setOptions`, `clearStorage`, `getOptions`, `destroy`) | 🟡 | Lifecycle paragraph README:183 mentions `rehydrate`/`destroy`/`onError`; full surface only enumerated in skill:164. README never lists `setOptions`/`getOptions`/`clearStorage`/`onHydrate`/`onFinishHydration`. | -| `createStorage` | ✅ | README:120, 125, 131 | -| `createJSONStorage` | 🟡 | Only appears in README:75 inside a backend example; never explained as a public factory. | -| `jsonCodec` | ✅ | README:95 | -| `identityCodec` | ✅ | README:97, skill:144 | -| `registry` / `createPersistRegistry` / `PersistRegistry` | ❌ | `createPersistRegistry` is exported in `persist-core.ts:384` but **not mentioned anywhere in consumer docs**. The skill:163 lists `registry` as an option and skill:166 mentions `registry.clearAll()` once, but there is no example, no "clear-all-on-logout" recipe, no link to `createPersistRegistry`. | -| `HydrationSignal` / `toHydrationSignal` / `alwaysHydratedSignal` / `HydrationSource` | 🟡 | `toHydrationSignal` in quick-start README:40 and skill:79. `HydrationSignal` named in adapter section README:156. **`alwaysHydratedSignal` is undocumented** in any consumer doc — only in JSDoc. `HydrationSource` not mentioned. | -| Types: `StateStorage`, `StorageValue`, `PersistStorage`, `StorageCodec`, `JsonStorageOptions`, `CreateStorageOptions`, `CrossTabStorageEvent`, `CrossTabEventTarget`, `PersistOptions`, `PersistableSource` | 🟡 | `StateStorage`/`StorageCodec`/`PersistableSource` named in README:70–106. `CrossTabEventTarget` mentioned README:149. `StorageValue`, `PersistStorage`, `CreateStorageOptions`, `JsonStorageOptions`, `CrossTabStorageEvent` not surfaced in prose (only JSDoc + typedoc). | +| Export | Status | Where | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `persistSource` | ✅ | README:117, skill:131–139 | +| `PersistApi` (interface: `rehydrate`, `hasHydrated`, `onHydrate`, `onFinishHydration`, `setOptions`, `clearStorage`, `getOptions`, `destroy`) | 🟡 | Lifecycle paragraph README:183 mentions `rehydrate`/`destroy`/`onError`; full surface only enumerated in skill:164. README never lists `setOptions`/`getOptions`/`clearStorage`/`onHydrate`/`onFinishHydration`. | +| `createStorage` | ✅ | README:120, 125, 131 | +| `createJSONStorage` | 🟡 | Only appears in README:75 inside a backend example; never explained as a public factory. | +| `jsonCodec` | ✅ | README:95 | +| `identityCodec` | ✅ | README:97, skill:144 | +| `registry` / `createPersistRegistry` / `PersistRegistry` | ❌ | `createPersistRegistry` is exported in `src/core/persist-core.ts:384` but **not mentioned anywhere in consumer docs**. The skill:163 lists `registry` as an option and skill:166 mentions `registry.clearAll()` once, but there is no example, no "clear-all-on-logout" recipe, no link to `createPersistRegistry`. | +| `HydrationSignal` / `toHydrationSignal` / `alwaysHydratedSignal` / `HydrationSource` | 🟡 | `toHydrationSignal` in quick-start README:40 and skill:79. `HydrationSignal` named in adapter section README:156. **`alwaysHydratedSignal` is undocumented** in any consumer doc — only in JSDoc. `HydrationSource` not mentioned. | +| Types: `StateStorage`, `StorageValue`, `PersistStorage`, `StorageCodec`, `JsonStorageOptions`, `CreateStorageOptions`, `CrossTabStorageEvent`, `CrossTabEventTarget`, `PersistOptions`, `PersistableSource` | 🟡 | `StateStorage`/`StorageCodec`/`PersistableSource` named in README:70–106. `CrossTabEventTarget` mentioned README:149. `StorageValue`, `PersistStorage`, `CreateStorageOptions`, `JsonStorageOptions`, `CrossTabStorageEvent` not surfaced in prose (only JSDoc + typedoc). | ### `@stainless-code/persist/seroval` @@ -681,7 +681,7 @@ Documented in skill:163 list, but in the **README** only a subset is shown in pr - **`createPersistRegistry` + `registry` clear-all-on-logout** — fully undocumented in README; the only "logout wipes everything" path is invisible to a new reader. - **`alwaysHydratedSignal`** — exported, undocumented in prose. - **`partialize` / `merge` / `onRehydrateStorage`** — core projection/merge hooks, absent from the README entirely (only in skill). -- **`retryWrite`** — has a great JSDoc example (`persist-core.ts:290–299`) but no README recipe; the quota-shrink story is a selling point and is buried. +- **`retryWrite`** — has a great JSDoc example (`src/core/persist-core.ts:290–299`) but no README recipe; the quota-shrink story is a selling point and is buried. - **`setOptions` / `getOptions` / `clearStorage`** on `PersistApi` — never enumerated in README. - **The generated API site** (`docs/api/`) — built, validated, but **never linked from the README** and is git-ignored so consumers can't browse it in-repo; they must run `bun run docs:api` or read hovers. @@ -732,9 +732,9 @@ Counting copy-pasteable code blocks across README + skill: | `partialize` | ❌ | no example anywhere | | `merge` custom | ❌ | no example | | `migrate` | ✅ | skill:122–129 | -| `retryWrite` | ✅ JSDoc only | `persist-core.ts:290–299` — not in README/skill | +| `retryWrite` | ✅ JSDoc only | `src/core/persist-core.ts:290–299` — not in README/skill | | `registry` / `clearAll` | ❌ | no example | -| `skipPersist` | ✅ | skill (via persistStore JSDoc `persist-tanstack.ts:18`), README mentions | +| `skipPersist` | ✅ | skill (via persistStore JSDoc `src/adapters/sources/tanstack-store.ts:18`), README mentions | | `throttleMs` | ❌ | no example, only prose | | `maxAge` / `buster` | ❌ | no example, only prose | | Svelte / Solid / Vue adapter | 🟡 | README:161–178 Svelte sketch only | @@ -748,7 +748,7 @@ Counting copy-pasteable code blocks across README + skill: - **Docs site (VitePress / MkDocs / Astro Starlight).** Currently the consumer surface is one giant README. A multi-page site with sidebar nav would fix the "wall of text" problem and let the generated `docs/api/` be hosted and linked. - **"Getting Started" guide** — progressive (install → 30-sec localStorage → IDB + hydration gate → SSR), distinct from the reference-dense README. -- **"Adapters" catalog page** — one page per entry (`./seroval`, `./idb`, `./tanstack-store`, `./react`) with install, API, and a complete example each. +- **"Adapters" catalog page** — one page per entry (`./codecs/seroval`, `./backends/idb`, `./sources/tanstack-store`, `./frameworks/react`) with install, API, and a complete example each. - **"Choose your storage" decision matrix** — localStorage vs sessionStorage vs IndexedDB vs AsyncStorage vs custom: sync/async, cross-tab support, structured-clone, size limits, when to gate UI. The skill has a 4-row version (skill:150–155); a fuller one belongs in consumer docs. - **"Choose your codec" matrix** — jsonCodec vs serovalCodec vs identityCodec vs custom: Set/Map/Date support, wire type, backend compatibility, perf notes. Currently scattered across README:95–103. - **Recipes section** (separate page) — encryption-at-rest, cross-tab IDB via BroadcastChannel, partialize+merge, retryWrite quota-shrink, registry clear-all-on-logout, SSR/Next.js, React Native AsyncStorage, throttled high-frequency writes, schema migration chains. Most of these have no example today. @@ -764,7 +764,7 @@ Counting copy-pasteable code blocks across README + skill: - **Value proposition:** Crisp at the seam/structure level ("every 'can it do X?' is a one-line composition instead of a feature request", README:3). But it leads with mechanism (seams, `PersistableSource`) before outcome (your prefs survive reload, no UI flash, works with any store). A new user learns _how it's built_ before _what it does for them_. The headline should answer "what do I get?" first. - **"Hydration-aware":** **Not explained.** The word appears in the title (`README.md:1`), in `package.json` description, and ~20 times in the README, but is never defined. A reader who knows "hydration" only from React/Next.js SSR will be confused — here it means _"has the persisted state finished loading from storage yet,"_ a completely different concept. README:152 says "gate UI on `useHydrated`" but never shows _what_ flashes (the default state rendering briefly before stored state lands). **This is the single biggest tone gap.** -- **TanStack intent:** Clear in `skills/tanstack-store/SKILL.md` and `docs/plans/upstream-tanstack-pitch.md`, but **opaque in the README**. The README never says "this is meant to become TanStack Persist" or that `./tanstack-store` is the primary adapter. A reader sees five equal-weight subpaths and doesn't know `@tanstack/store` is the blessed path. The "Relationship to TanStack Persist / zustand persist" section (README:46) frames it as a _competitor/alternative_ rather than a _collaboration target_, which undersells the TanStack intent. +- **TanStack intent:** Clear in `skills/tanstack-store/SKILL.md` and `docs/plans/upstream-tanstack-pitch.md`, but **opaque in the README**. The README never says "this is meant to become TanStack Persist" or that `./sources/tanstack-store` is the primary adapter. A reader sees five equal-weight subpaths and doesn't know `@tanstack/store` is the blessed path. The "Relationship to TanStack Persist / zustand persist" section (README:46) frames it as a _competitor/alternative_ rather than a _collaboration target_, which undersells the TanStack intent. - **Density vs audience mismatch:** The README is one document trying to serve "give me 5 minutes" (quick start) and "I want the full seam theory" (Extensibility guide) and "I'm writing a Svelte adapter" (adapter section). All three audiences get one scroll. Progressive disclosure (a Getting Started page → Extensibility guide → Adapter authoring) would serve each without overwhelming the others. - **Voice:** Confident and opinionated (good — "deliberate divergence", "no barrel", "prefs shouldn't silently expire"). This is a strength; preserve it in any restructure. - **`bun`-centrism:** README assumes Bun (`bun add`). Engines field supports Node ≥20.19. The install command should show npm/pnpm/yarn too, or a note that any package manager works. From 0a939870985ee65679b5ec4b77721301de351178 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 17:37:06 +0300 Subject: [PATCH 43/77] =?UTF-8?q?docs(plans):=20add=20remaining-roi=20?= =?UTF-8?q?=E2=80=94=20actionable=20post-audit=20follow-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/plans/remaining-roi.md | 120 ++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 3 +- 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 docs/plans/remaining-roi.md diff --git a/docs/plans/remaining-roi.md b/docs/plans/remaining-roi.md new file mode 100644 index 0000000..466e44b --- /dev/null +++ b/docs/plans/remaining-roi.md @@ -0,0 +1,120 @@ +# Remaining ROI work + +Actionable items not yet shipped from the [2026-07-04 ROI audit](../audits/2026-07-04-docs-adapters-roi.md). Shipped items are ✅-marked in the audit; this plan tracks the rest. When an item ships, lift its durable bits to `docs/architecture.md` / `docs/roadmap.md` / a rule and strike it here; when the list is empty, delete this file (per [docs-governance § Closing a plan](../../.agents/skills/docs-governance/LIFECYCLE.md)). + +## Context (read this first) + +`@stainless-code/persist` is a hydration-aware persistence middleware for any reactive store. The agnostic core is `persistSource(source, options)` (`src/core/persist-core.ts`) plus `HydrationSignal` (`src/core/hydration.ts`). Three seams compose every backend × codec cell: + +- **Backend** — `StateStorage<TRaw>`: `getItem` / `setItem` / `removeItem`, sync or Promise (async detected via `instanceof Promise`, not thenable duck-typing). +- **Codec** — `StorageCodec<S, TRaw>`: pure `encode` / `decode` between the `StorageValue<S>` envelope and the backend's wire type. Sync by design — async transforms (encryption, compression) are backend **wrappers**, not codecs. +- **Source** — `PersistableSource<TState>`: `getState` / `setState` / `subscribe`. Structural, store-agnostic. + +Framework adapters mount `HydrationSignal` into each framework's external-store mechanism (React `useSyncExternalStore`, Solid `from`, Vue `shallowRef` + `onScopeDispose`, Svelte runes `createSubscriber` / stores `readable`, Angular `signal` + `effect`, Preact `useSyncExternalStore` via `preact/compat`). + +**Layout:** `src/core/` (zero-dep engine) + `src/adapters/<seam>/` (`codecs/`, `backends/`, `transport/`, `sources/`, `frameworks/`). One subpath per optional peer, mirroring `src/` → `dist/` → `./<seam>/<name>` 1:1. No barrel — importing a subpath is the dependency opt-in. Each adapter imports only from `core/` (enforced by a per-entry self-check test). Full seam model + entry-point table + test matrix: [`docs/architecture.md`](../architecture.md). Consumer guide + recipes: root [`README.md`](../../README.md). + +**Source-adapter naming:** shape-based, not library-based — `persistStore` / `persistAtom` / `persistProxy` / `persistObservable`. Same persistable shape → same name → same merge semantics (Store/Proxy/Observable shallow-spread; Atom replace); the subpath carries the library. Alias when importing two same-shape adapters into one module. + +## Conventions (apply to every item) + +- **New subpath** = add to `package.json` `exports` + `peerDependencies` + `peerDependenciesMeta` (optional) + `tsdown.config.ts` `entry` + `deps.neverBundle` + `typedoc.json` `entryPoints`. Mirror `src/` → `dist/` → subpath 1:1. +- **Adapter isolation** — imports only from `core/`; co-locate a `*.test.ts` "dependency isolation" block asserting every relative import resolves into `../../core/`. +- **Zero-dep core gate** — `src/core/persist-core.ts` has no value imports (enforced by `src/core/persist-core.test.ts`). +- **Changeset** — `.changeset/<slug>.md` with `@stainless-code/persist: minor` (or `major` for breaking) for any public-surface change. +- **Verify after each step** — `bun run lint:changes`, `bun run format:changes`, `bun test <co-located pair>`, `bun run typecheck`; use `bun run format` / `lint:fix` (pinned `oxfmt` / `oxlint`), not `bunx`. +- **Pre-commit** runs format/lint/typecheck/tests on staged files — never `--no-verify`. Run the per-file checks first so the hook passes first try (stash/restore can eat untracked files). + +## Remaining items (ROI-ordered) + +### 1. TanStack Query persister bridge — Tier 2, M + +- **What:** a `./sources/tanstack-query` (or `./integrations/tanstack-query`) subpath exposing a `persistQueryClient`-shaped adapter over `persistSource`. Supply a cache-shaped `PersistableSource` (`getState` → `queryClient.getQueryCache().getAll()`; `setState` → `setQueryData` per entry; `subscribe` → `getQueryCache().subscribe`). +- **Why:** the JSDoc repeatedly cites `@tanstack/query-persist-client` as the reference design (`src/core/persist-core.ts:12,263`); the README migration guide already maps the option names (`maxAge`, `buster`, `retryWrite` ↔ `retry`, `throttleMs` ↔ `throttleTime`). Flagship integration — converts the cache-bound incumbent's users. +- **Acceptance:** subpath ships + co-located test + README recipe; `persistQueryClient`-shaped call works against a mock `QueryClient`. Verify the cache-shaped source round-trips through `persistSource` with `createJSONStorage` + `maxAge`/`buster`. +- **Lands:** README "Wrapping your store" / a new "Integrations" section; changeset. + +### 2. `examples/` monorepo workspace — Tier 2, M + +- **What:** a top-level `examples/` workspace with runnable apps: `tanstack-idb-react`, `tanstack-localstorage-react`, `nextjs-ssr`, `react-native-mmkv`. Each wires store + storage + codec + hydration gate end-to-end. +- **Why:** zero runnable demos today — the only executable artefacts are `src/*.test.ts` and `tests-dom/*.test.tsx`. The headline use case (IDB + React + `useHydrated` + `destroy()`) exists only as README prose. +- **Acceptance:** each example `bun install && bun dev` runs; the IDB example demonstrates the hydrate flash + `useHydrated` gate; the Next.js example demonstrates the SSR `true` snapshot; the RN example uses `./backends/mmkv` (sync, no gate). Exclude from the published package (`package.json` `files` keeps `dist` + `skills`). +- **Lands:** stays in-repo as `examples/` (not a doc); README links to it. Changeset: `minor` (dev-only, no public-surface change → possibly no changeset needed). + +### 3. Docs site — Tier 2, M + +- **What:** a VitePress or Astro Starlight site splitting the wall-of-text README into Getting Started → Adapters → Recipes → Adapter authoring → Reference; host the generated `docs/api/` under it. The README stays as the landing-page digest. +- **Why:** the README is one document serving "give me 5 minutes", "I want the seam theory", and "I'm writing a Svelte adapter" — all three audiences get one scroll. The generated `docs/api/` site is built but git-ignored and unlinked. +- **Acceptance:** site builds (`bun run docs:site` or equivalent), deployed to GitHub Pages (`.nojekyll` already present); README links to it; `docs/api/` reachable from the site nav. No content duplication — the site pulls from the same source prose where possible. +- **Lands:** `docs/site/` (or a `docs/` restructure); README trimmed to landing digest. Changeset: `minor`. + +### 4. npm provenance + signing — Tier 3, S + +- **What:** add `id-token: write` permission + `--provenance` to the changeset publish step in `.github/workflows/release.yml`. +- **Why:** the release flow lacks npm provenance/signing — supply-chain integrity gap. Low effort, high integrity payoff. +- **Acceptance:** a release publishes with provenance (verifiable on npmjs.com); the workflow run shows `id-token: write` in effect. +- **Lands:** `.github/workflows/release.yml`. No changeset (infra-only). + +### 5. Real-browser + SSR test matrix — Tier 3, M + +- **What:** add a Playwright job covering the React `useHydrated` rerender/detach path in a real browser (Chromium + WebKit/Safari); add a Next.js SSR smoke that asserts the server renders `hydrated: true` and the client hydrates without a flash. The `tests-dom` vitest/jsdom suite stays (fast); Playwright is the slow, real-environment gate. +- **Why:** today the matrix is jsdom only — no real browser, no Safari, no SSR-framework. `useSyncExternalStore` reactivity and SSR snapshot policy are the constraint-critical paths; jsdom can diverge from real browsers. +- **Acceptance:** CI `Test (Browser)` job runs Playwright green; `Test (SSR)` job runs a Next.js app green; both gated by `CI complete`. Co-locate fixtures under `tests-browser/` and `tests-ssr/` (outside `bun test ./src`'s scan, like `tests-dom/`). +- **Lands:** `.github/workflows/ci.yml` + new test dirs; `docs/architecture.md` § Test matrix updated. No changeset (test-only). + +### 6. Migration-chain helper — Tier 4, M + +- **What:** a `createMigrationChain({ 0: fn, 1: fn, 2: fn })` helper (in `core/` or a `./codecs/`-style utility subpath) that walks v0→v1→…→current, feeding each step's output to the next, and produces a single `migrate` callback for `persistSource`. +- **Why:** today `migrate` is a single callback receiving the stored version; multi-step v0→v1→v2 chaining is user-written and error-prone (off-by-one on the stored version, skipped steps). +- **Acceptance:** helper ships with tests covering forward chaining + skip-from-older-than-chain + a throwing step; README recipe under "Recipes". Decide core vs subpath: if it stays zero-dep it can live in `core/`; if it needs no peer, a `core/` export is fine (no new subpath). +- **Lands:** `src/core/persist-core.ts` (or a new utility) + JSDoc + README recipe. Changeset: `minor` (new public export). + +### 7. React ergonomics layer — Tier 4, M-L + +- **What:** a `./frameworks/react` ergonomics companion (or a new `./frameworks/react-context` subpath) — `<PersistProvider>` + React context + `usePersisted(store, selector)` selector binding + auto-`destroy()` on unmount. The existing `useHydrated` stays the reference primitive. +- **Why:** `useHydrated` is the entire React surface today — no provider, no auto store binding, no auto-teardown. Each consumer manually threads a `HydrationSignal` + `useEffect` cleanup (`src/adapters/frameworks/react.ts:22` signals this is intentionally deferred). +- **Acceptance:** subpath ships + `tests-dom` coverage for mount/unmount teardown + selector rerender + provider scoping; README "React ergonomics" section. Keep it optional — the bare `useHydrated` path must remain valid. +- **Lands:** `src/adapters/frameworks/react-context.ts` (new subpath) + README section. Changeset: `minor`. **Decision needed:** ship in-repo or as a separate package (the JSDoc deferral hints at a higher-layer package). + +### 8. OPFS + SQLite-WASM + Cloudflare KV/Durable Objects adapters — Tier 4, M-L + +- **What:** three new `./backends/` subpaths: `opfs` (Origin Private File System, async, file-backed, high-volume structured state), `sqlite-wasm` (wa-sqlite / sqlite-wasm, structured-clone mode like IDB), `cloudflare-kv` + `cloudflare-do` (edge runtime, async `StateStorage`). +- **Why:** extends the backend surface to high-volume browser state, structured-query WASM storage, and edge runtimes. All fit `StateStorage<TRaw>` cleanly; no core rework. +- **Acceptance:** each ships as its own subpath with optional peer + co-located test (mock the runtime, like the MMKV/AsyncStorage tests) + README backend decision-matrix row. `sqlite-wasm` may be better as a community recipe than a shipped peer (heavy) — decide per-adapter. +- **Lands:** `src/adapters/backends/<name>.ts` + README "Choosing a storage" row + changeset (one per adapter). + +### 9. StackBlitz / CodeSandbox playground — Tier 4, M + +- **What:** an embedded live-editable example (StackBlitz or CodeSandbox) linked from the docs site (item 3) and README — the fastest on-ramp for a new user. +- **Why:** no playground today; a new user can't try a wiring without cloning. Pairs with the docs site (item 3) and `examples/` (item 2). +- **Acceptance:** a one-click playground loads with a working TanStack + IDB + React wiring; the README + docs site link to it. +- **Deps:** item 2 (`examples/`) or item 3 (docs site) should land first so the playground has a source app. +- **Lands:** README + docs site link. Changeset: `minor` (dev/docs-only). + +## Backlog (lower-priority, brainstormed — not ROI-tiered) + +From audit Appendix B.3. Each is a one-line composition over an existing seam; ship only if demand surfaces. + +- **sessionStorage named factory** (S) — already works via `createJSONStorage(() => sessionStorage)`; a named factory is pure DX/discoverability. Cross-tab is meaningless (per-tab). +- **memory test-fixture adapter** (S) — dedupes the `MemoryStorage` class copy-pasted across test files. Test-only; not published. +- **Redis backend** (M) — server-side persistent state; async `StateStorage`. Pairs with `./backends/node-fs` for a real server story. +- **Chrome `storage.area`** (S) — `local` / `sync` / `session` for MV3 extensions (`localStorage` is forbidden in MV3 service workers). +- **cookies backend** (M) — server-rendered hydration; size limits + HTTP coupling make it awkward — likely a recipe, not a shipped peer. +- **Codecs** — MessagePack / cbor-x / CBOR (S, compact binary wire — needs `TRaw = Uint8Array`), superjson / devalue (S, class-instance round-trip), structuredClone (S, largely subsumed by IDB identity mode), protobuf (L, heavy toolchain — recipe). + +## Sequencing + +1. **#4 (provenance)** — S, unblocks supply-chain integrity; do first. +2. **#1 (Query bridge) + #6 (migration-chain)** — M each, pure code, high adoption payoff, no deps. +3. **#5 (real-browser + SSR matrix)** — M, de-risks the hydration-critical paths before more surface lands. +4. **#2 (examples/) → #3 (docs site) → #9 (playground)** — the docs/demo arc; sequence so each builds on the prior. +5. **#7 (React ergonomics) + #8 (OPFS/SQLite/Cloudflare)** — strategic; decide ship-vs-recipe per item. + +## Reference + +- [Audit (full findings + shipped ✅ marks)](../audits/2026-07-04-docs-adapters-roi.md) +- [Architecture — seams, entry points, test matrix](../architecture.md) +- [Roadmap](../roadmap.md) +- [Upstream TanStack pitch](./upstream-tanstack-pitch.md) +- Root [README](../../README.md) — install, quick start, recipes, decision matrices +- [.agents/skills/docs-governance](../../.agents/skills/docs-governance/SKILL.md) — plan lifecycle (close = lift + delete) diff --git a/docs/roadmap.md b/docs/roadmap.md index d49ef49..1cf674f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,11 +1,12 @@ # Roadmap -Forward-looking plans only — **not** a mirror of `src/`. **Doc index:** [README.md](./README.md). **Design / seams:** [architecture.md](./architecture.md). Shipped features (core, codecs, backends, TanStack adapters, React/Solid/Vue/Svelte hydration adapters) live in `src/` and the root [README.md](../README.md) — not enumerated here. +Forward-looking plans only — **not** a mirror of `src/`. **Doc index:** [README.md](./README.md). **Design / seams:** [architecture.md](./architecture.md). Shipped features live in `src/` and the root [README.md](../README.md) — not enumerated here. --- ## Next +- **Remaining ROI work** — actionable items not yet shipped from the [2026-07-04 audit](./audits/2026-07-04-docs-adapters-roi.md): TanStack Query bridge, `examples/` workspace, docs site, npm provenance, real-browser + SSR test matrix, migration-chain helper, React ergonomics layer, OPFS/SQLite/Cloudflare adapters, playground. Plan: [`plans/remaining-roi.md`](./plans/remaining-roi.md). - **Upstream TanStack Persist collaboration** — pitch the `persistSource` middleware model (structural `PersistableSource` + first-class hydration lifecycle) to the TanStack Persist maintainers as a merge target, after the stainless-code publish stabilises. Draft: [`plans/upstream-tanstack-pitch.md`](./plans/upstream-tanstack-pitch.md). --- From 31f9636856ae2ec23ef01c16c848268bbb6f6e77 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 17:40:28 +0300 Subject: [PATCH 44/77] chore(package): refresh npm keywords to the shipped surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- package.json | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 047145d..86ef480 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.1.1", "description": "Hydration-aware persistence for any reactive store — zero-dep persistSource core; codecs, backends, cross-tab transport, source + framework hydration adapters ship as opt-in recipes", "keywords": [ + "angular", "broadcastchannel", "codec", "compression", @@ -10,9 +11,13 @@ "encryption", "hydration", "indexeddb", + "jotai", "localstorage", "middleware", + "mmkv", + "mobx", "persistence", + "preact", "react", "react-native", "reactive", @@ -22,10 +27,12 @@ "store", "svelte", "tanstack", - "tanstack-intent", "tanstack-store", + "typescript", + "valtio", "vue", - "zod" + "zod", + "zustand" ], "homepage": "https://github.com/stainless-code/persist#readme", "bugs": { From 4352e8f867c6fc6a1299f22a5f17613f95ef9a29 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 17:41:54 +0300 Subject: [PATCH 45/77] docs(plans): refresh upstream-tanstack-pitch to current surface + accuracy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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. --- docs/plans/upstream-tanstack-pitch.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/upstream-tanstack-pitch.md b/docs/plans/upstream-tanstack-pitch.md index f311e39..104c2bf 100644 --- a/docs/plans/upstream-tanstack-pitch.md +++ b/docs/plans/upstream-tanstack-pitch.md @@ -6,11 +6,11 @@ Hi — sharing [`@stainless-code/persist`](https://github.com/stainless-code/persist), a hydration-aware persistence middleware for any reactive store. Published under stainless-code, extracted, test-covered. It could serve as a reference or a contribution toward TanStack Persist; happy to fold any of it upstream. Quick tour. -**1. What it is.** A middleware over a _structural_ `PersistableSource` (`getState`/`setState`/`subscribe`) — not a store-specific wrapper. `persistSource(source, options)` persists anything reactive; `persistStore`/`persistAtom` are thin adapters for `@tanstack/store`. Unlike a passive `StoragePersister` (dump/load a blob), the middleware owns the lifecycle — hydrate-on-create, subscribe-writes gated until hydrated, trailing throttle, teardown — so the store stays a plain store and persistence is a sidekick. Three seams (`StateStorage` × `StorageCodec` × `PersistableSource`) make every backend × codec cell a one-line composition; the surface is exercised across codecs (seroval, zod), backends (IndexedDB, AsyncStorage, MMKV, SecureStore, encrypted, compressed), a BroadcastChannel cross-tab transport, TanStack store sources, and framework hydration adapters (React/Solid/Vue/Svelte/Angular/Preact) — so the shape is proven to scale, not sketched. ([README § Extensibility guide](https://github.com/stainless-code/persist#extensibility-guide)) +**1. What it is.** A middleware over a _structural_ `PersistableSource` (`getState`/`setState`/`subscribe`) — not a store-specific wrapper. `persistSource(source, options)` persists anything reactive; `persistStore`/`persistAtom` are thin adapters for `@tanstack/store`. Unlike a passive `StoragePersister` (dump/load a blob), the middleware owns the lifecycle — hydrate-on-create, subscribe-writes gated until hydrated, trailing throttle, teardown — so the store stays a plain store and persistence is a sidekick. Three seams (`StateStorage` × `StorageCodec` × `PersistableSource`) make every backend × codec cell a one-line composition; the surface is exercised across codecs (seroval, zod), backends (IndexedDB, AsyncStorage, MMKV, SecureStore, encrypted, compressed, node-fs), a BroadcastChannel cross-tab transport, store sources (TanStack Store, zustand, jotai, valtio, mobx), and framework hydration adapters (React/Solid/Vue/Svelte/Angular/Preact) — so the shape is proven to scale, not sketched. ([README § Extensibility guide](https://github.com/stainless-code/persist#extensibility-guide)) **2. Sync vs async — one API.** No split into two APIs. `getItem` returning a native `Promise` switches the read path to async; sync backends stay sync and hydration settles before first paint (no flash, no `Suspense`). Async backends ride the same `getItem` Promise branch; UI gates on a hydration signal. `instanceof Promise` (not thenable duck-typing) so a stored value with a `then` field is never mistaken for a pending read. ([architecture § Sync vs async](https://github.com/stainless-code/persist/blob/main/docs/architecture.md#sync-vs-async)) -**3. Where it lands vs `@tanstack/query-persist-client`.** Fact-checked against query's source. **Parity** — query has these too: `buster`, `maxAge`, `throttleTime` (`throttleMs`), `retry` (`retryWrite` shrink-or-give-up), a hydration gate (`useIsRestoring` / `PersistQueryClientProvider`), and framework hydration adapters (it ships React/Solid/Svelte/Angular/Preact). **Beyond** — a store-agnostic source (query is cache-bound), a codec seam independent of the backend (query couples `serialize`/`deserialize` to the persister factory), versioned `migrate` (query has only `buster`, no `version`), cross-tab sync, and schema validation (a `zod` codec). One deliberate divergence: `maxAge` is opt-in, not default-on — prefs shouldn't silently expire. ([README § Comparison](https://github.com/stainless-code/persist#comparison-with-other-persist-libraries)) +**3. Where it lands vs `@tanstack/query-persist-client`.** Fact-checked against query's source. **Parity** — query has these too: `buster`, `maxAge`, `throttleTime` (`throttleMs`), `retry` (`retryWrite` shrink-or-give-up), and a hydration gate (`useIsRestoring` / `PersistQueryClientProvider`, React in-core). **Beyond** — a store-agnostic source (query is cache-bound), a codec seam independent of the backend (query couples `serialize`/`deserialize` to the persister factory), versioned `migrate` (query has only `buster`, no `version`), cross-tab sync, schema validation (a `zod` codec), and framework hydration adapters beyond React (Solid/Vue/Svelte/Angular/Preact — query's non-React adapters live in sidecar `@tanstack/query-*-<fw>` packages, not query-persist-client core). One deliberate divergence: `maxAge` is opt-in, not default-on — prefs shouldn't silently expire. ([README § Comparison](https://github.com/stainless-code/persist#comparison-with-other-persist-libraries)) **4. How it could merge.** `StateStorage` + `StorageCodec` drop in; `persistSource` is the store-agnostic core a `persistStore`/`persistQuery`/`persistForm` family can share; `HydrationSignal` is the framework-agnostic subscribe target — React (`useSyncExternalStore`), Solid (`from`), Vue (`shallowRef` + `onScopeDispose`), Svelte (runes `createSubscriber`, plus a `readable` store variant for Svelte 4), Angular (`signal` + `effect`), and Preact (`useSyncExternalStore` via preact/compat) mount it. Contributable: the core + the TanStack adapter + the framework hydration adapters. No barrel — one subpath per optional peer, categorized to mirror the source (`./codecs/…`, `./backends/…`, `./transport/…`, `./sources/…`, `./frameworks/…`) — so the dep opt-in is the import. From 0c4ea170b401da8d3f60e7092aaef57d5d1a699e Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 18:09:22 +0300 Subject: [PATCH 46/77] ci(release): switch to npm trusted publishing (OIDC) + provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/release.yml | 11 +++++++---- package.json | 3 ++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e0034e..fe6127a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,6 @@ # [Changesets](https://github.com/changesets/changesets): open a "Version packages" PR when -# `.changeset/*.md` files land on `main`, then publish to npm when that PR merges (requires `NPM_TOKEN`). +# `.changeset/*.md` files land on `main`, then publish to npm when that PR merges via npm +# trusted publishing (GitHub OIDC — no NPM_TOKEN; provenance auto-generated). name: Release on: @@ -12,11 +13,13 @@ concurrency: ${{ github.workflow }}-${{ github.ref }} permissions: contents: write pull-requests: write + id-token: write jobs: release: name: Release runs-on: ubuntu-latest + environment: release steps: - name: Checkout repository uses: actions/checkout@v7 @@ -29,8 +32,9 @@ jobs: # Opens/updates the Version PR when `.changeset/*.md` exist. When there are none, the action # can still run `publish` (see changesets/action) so a just-merged Version PR can ship. # `changeset publish` should exit 0 when there is nothing new to publish. If this job fails, - # read the step log: common causes are missing/invalid NPM_TOKEN, registry/network errors, or - # a real publish failure — not assumed from the runner alone. + # read the step log: common causes are OIDC/trusted-publishing misconfig (`id-token: write`, + # the `release` environment, or the npm trusted-publisher binding), registry/network errors, + # or a real publish failure — not assumed from the runner alone. - name: Create Release Pull Request or Publish to npm id: changesets uses: changesets/action@v1 @@ -43,4 +47,3 @@ jobs: createGithubReleases: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/package.json b/package.json index 86ef480..d11ae43 100644 --- a/package.json +++ b/package.json @@ -145,7 +145,8 @@ } }, "publishConfig": { - "access": "public" + "access": "public", + "provenance": true }, "scripts": { "build": "tsdown", From 8ee66f1aeca0046f0483d3301a4eb06b872d92ac Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 18:10:27 +0300 Subject: [PATCH 47/77] 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. --- docs/plans/remaining-roi.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/plans/remaining-roi.md b/docs/plans/remaining-roi.md index 466e44b..c778643 100644 --- a/docs/plans/remaining-roi.md +++ b/docs/plans/remaining-roi.md @@ -48,12 +48,13 @@ Framework adapters mount `HydrationSignal` into each framework's external-store - **Acceptance:** site builds (`bun run docs:site` or equivalent), deployed to GitHub Pages (`.nojekyll` already present); README links to it; `docs/api/` reachable from the site nav. No content duplication — the site pulls from the same source prose where possible. - **Lands:** `docs/site/` (or a `docs/` restructure); README trimmed to landing digest. Changeset: `minor`. -### 4. npm provenance + signing — Tier 3, S +### 4. npm provenance + signing — Tier 3, S ✅ implemented (verify on next release) -- **What:** add `id-token: write` permission + `--provenance` to the changeset publish step in `.github/workflows/release.yml`. -- **Why:** the release flow lacks npm provenance/signing — supply-chain integrity gap. Low effort, high integrity payoff. -- **Acceptance:** a release publishes with provenance (verifiable on npmjs.com); the workflow run shows `id-token: write` in effect. -- **Lands:** `.github/workflows/release.yml`. No changeset (infra-only). +- **What:** npm **trusted publishing** (GitHub OIDC) — no long-lived `NPM_TOKEN`. `.github/workflows/release.yml`: add `id-token: write` + `environment: release` (matches the npm trusted-publisher binding's environment claim); remove `NPM_TOKEN` from the changesets step env. `package.json`: `publishConfig.provenance: true`. Provenance is auto-generated under trusted publishing (no `--provenance` flag); `changesets/action@v1` detects OIDC and skips the `.npmrc` token write, and `changeset publish` routes through `npm publish` (non-pnpm → npm) which does the OIDC exchange. +- **Why:** eliminates the long-lived token secret (the biggest supply-chain risk) + ships Sigstore provenance. Low effort, high integrity payoff. +- **Implemented:** `ci(release): switch to npm trusted publishing` — workflow + `package.json` committed. npm side configured: trusted publisher = `stainless-code/persist` + `release.yml` + environment `release` + `Allow npm publish`; publishing access = "require 2FA and disallow tokens". +- **Acceptance (remaining):** the next changeset merge to `main` publishes with provenance — verify the npm version page shows the Provenance badge, or `npm view @stainless-code/persist@<ver> --json` includes `dist.attestations`. Then revoke + delete the old `NPM_TOKEN` repo secret. Strike this item once verified. +- **Lands:** `.github/workflows/release.yml` + `package.json`. No changeset (infra-only). ### 5. Real-browser + SSR test matrix — Tier 3, M From 2fbf6ad0c7301dbfc65f7325d322be8d1d107ac9 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 18:20:33 +0300 Subject: [PATCH 48/77] refactor(test): dedupe MemoryStorage into src/testing/memory-storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/adapters/backends/compressed.test.ts | 23 ++----------------- src/adapters/backends/encrypted.test.ts | 23 ++----------------- src/adapters/codecs/seroval.test.ts | 21 +---------------- src/adapters/codecs/zod.test.ts | 23 ++----------------- src/adapters/frameworks/react.test.ts | 19 ++-------------- src/adapters/sources/jotai.test.ts | 22 +----------------- src/adapters/sources/mobx.test.ts | 22 +----------------- src/adapters/sources/tanstack-store.test.ts | 22 +----------------- src/adapters/sources/valtio.test.ts | 22 +----------------- src/adapters/sources/zustand.test.ts | 22 +----------------- src/core/hydration.test.ts | 19 ++-------------- src/core/persist-core.test.ts | 21 +---------------- src/testing/memory-storage.ts | 25 +++++++++++++++++++++ 13 files changed, 42 insertions(+), 242 deletions(-) create mode 100644 src/testing/memory-storage.ts diff --git a/src/adapters/backends/compressed.test.ts b/src/adapters/backends/compressed.test.ts index 732137f..e663dee 100644 --- a/src/adapters/backends/compressed.test.ts +++ b/src/adapters/backends/compressed.test.ts @@ -1,30 +1,11 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { createStorage, persistSource } from "../../core/persist-core"; -import type { PersistableSource, StateStorage } from "../../core/persist-core"; +import type { PersistableSource } from "../../core/persist-core"; +import { MemoryStorage } from "../../testing/memory-storage"; import { serovalCodec } from "../codecs/seroval"; import { createCompressedStorage } from "./compressed"; -class MemoryStorage implements StateStorage { - private store = new Map<string, string>(); - - clear() { - this.store.clear(); - } - - getItem(key: string) { - return this.store.get(key) ?? null; - } - - removeItem(key: string) { - this.store.delete(key); - } - - setItem(key: string, value: string) { - this.store.set(key, value); - } -} - function createMockSource<T>(initial: T): PersistableSource<T> & { state: T } { let state = initial; const listeners = new Set<() => void>(); diff --git a/src/adapters/backends/encrypted.test.ts b/src/adapters/backends/encrypted.test.ts index b8c8c55..663ec61 100644 --- a/src/adapters/backends/encrypted.test.ts +++ b/src/adapters/backends/encrypted.test.ts @@ -1,30 +1,11 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { createStorage, persistSource } from "../../core/persist-core"; -import type { PersistableSource, StateStorage } from "../../core/persist-core"; +import type { PersistableSource } from "../../core/persist-core"; +import { MemoryStorage } from "../../testing/memory-storage"; import { serovalCodec } from "../codecs/seroval"; import { createEncryptedStorage } from "./encrypted"; -class MemoryStorage implements StateStorage { - private store = new Map<string, string>(); - - clear() { - this.store.clear(); - } - - getItem(key: string) { - return this.store.get(key) ?? null; - } - - removeItem(key: string) { - this.store.delete(key); - } - - setItem(key: string, value: string) { - this.store.set(key, value); - } -} - function createMockSource<T>(initial: T): PersistableSource<T> & { state: T } { let state = initial; const listeners = new Set<() => void>(); diff --git a/src/adapters/codecs/seroval.test.ts b/src/adapters/codecs/seroval.test.ts index 6baa9a7..11b94da 100644 --- a/src/adapters/codecs/seroval.test.ts +++ b/src/adapters/codecs/seroval.test.ts @@ -2,28 +2,9 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { createStorage, persistSource } from "../../core/persist-core"; import type { PersistableSource, StateStorage } from "../../core/persist-core"; +import { MemoryStorage } from "../../testing/memory-storage"; import { createSerovalStorage, serovalCodec } from "./seroval"; -class MemoryStorage implements StateStorage { - private store = new Map<string, string>(); - - clear() { - this.store.clear(); - } - - getItem(key: string) { - return this.store.get(key) ?? null; - } - - removeItem(key: string) { - this.store.delete(key); - } - - setItem(key: string, value: string) { - this.store.set(key, value); - } -} - function createMockSource<T>(initial: T): PersistableSource<T> & { state: T } { let state = initial; const listeners = new Set<() => void>(); diff --git a/src/adapters/codecs/zod.test.ts b/src/adapters/codecs/zod.test.ts index a962c8d..3b42fd2 100644 --- a/src/adapters/codecs/zod.test.ts +++ b/src/adapters/codecs/zod.test.ts @@ -3,29 +3,10 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { z } from "zod"; import { createStorage, persistSource } from "../../core/persist-core"; -import type { PersistableSource, StateStorage } from "../../core/persist-core"; +import type { PersistableSource } from "../../core/persist-core"; +import { MemoryStorage } from "../../testing/memory-storage"; import { createZodStorage, zodCodec } from "./zod"; -class MemoryStorage implements StateStorage { - private store = new Map<string, string>(); - - clear() { - this.store.clear(); - } - - getItem(key: string) { - return this.store.get(key) ?? null; - } - - removeItem(key: string) { - this.store.delete(key); - } - - setItem(key: string, value: string) { - this.store.set(key, value); - } -} - function createMockSource<T>(initial: T): PersistableSource<T> & { state: T } { let state = initial; const listeners = new Set<() => void>(); diff --git a/src/adapters/frameworks/react.test.ts b/src/adapters/frameworks/react.test.ts index 94d8621..b9456fb 100644 --- a/src/adapters/frameworks/react.test.ts +++ b/src/adapters/frameworks/react.test.ts @@ -5,7 +5,8 @@ import { renderToString } from "react-dom/server"; import { alwaysHydratedSignal, toHydrationSignal } from "../../core/hydration"; import { createJSONStorage, persistSource } from "../../core/persist-core"; -import type { PersistableSource, StateStorage } from "../../core/persist-core"; +import type { PersistableSource } from "../../core/persist-core"; +import { MemoryStorage } from "../../testing/memory-storage"; import { useHydrated } from "./react"; /** @@ -22,22 +23,6 @@ import { useHydrated } from "./react"; * subscribe/notify contract that wiring rides on. */ -class MemoryStorage implements StateStorage { - private store = new Map<string, string>(); - clear() { - this.store.clear(); - } - getItem(key: string) { - return this.store.get(key) ?? null; - } - removeItem(key: string) { - this.store.delete(key); - } - setItem(key: string, value: string) { - this.store.set(key, value); - } -} - function createMockSource<T>(initial: T): PersistableSource<T> & { state: T } { let state = initial; const listeners = new Set<() => void>(); diff --git a/src/adapters/sources/jotai.test.ts b/src/adapters/sources/jotai.test.ts index 0328944..7bce931 100644 --- a/src/adapters/sources/jotai.test.ts +++ b/src/adapters/sources/jotai.test.ts @@ -3,29 +3,9 @@ import { beforeEach, describe, expect, it } from "bun:test"; import type { WritableAtom } from "jotai"; import { createJSONStorage } from "../../core/persist-core"; -import type { StateStorage } from "../../core/persist-core"; +import { MemoryStorage } from "../../testing/memory-storage"; import { persistAtom } from "./jotai"; -class MemoryStorage implements StateStorage { - private store = new Map<string, string>(); - - clear() { - this.store.clear(); - } - - getItem(key: string) { - return this.store.get(key) ?? null; - } - - removeItem(key: string) { - this.store.delete(key); - } - - setItem(key: string, value: string) { - this.store.set(key, value); - } -} - function createMockJotaiStore<T>(initialValue: T) { let value = initialValue; const listeners = new Set<() => void>(); diff --git a/src/adapters/sources/mobx.test.ts b/src/adapters/sources/mobx.test.ts index a8e302a..30dfed6 100644 --- a/src/adapters/sources/mobx.test.ts +++ b/src/adapters/sources/mobx.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; -import type { StateStorage } from "../../core/persist-core"; +import { MemoryStorage } from "../../testing/memory-storage"; const observableMap = new WeakMap<object, Set<() => void>>(); @@ -19,26 +19,6 @@ mock.module("mobx", () => ({ const { createJSONStorage } = await import("../../core/persist-core"); const { persistObservable } = await import("./mobx"); -class MemoryStorage implements StateStorage { - private store = new Map<string, string>(); - - clear() { - this.store.clear(); - } - - getItem(key: string) { - return this.store.get(key) ?? null; - } - - removeItem(key: string) { - this.store.delete(key); - } - - setItem(key: string, value: string) { - this.store.set(key, value); - } -} - function createMockObservable<T extends object>(initial: T): T { const listeners = new Set<() => void>(); const state = { ...initial }; diff --git a/src/adapters/sources/tanstack-store.test.ts b/src/adapters/sources/tanstack-store.test.ts index 7316efc..1bbe737 100644 --- a/src/adapters/sources/tanstack-store.test.ts +++ b/src/adapters/sources/tanstack-store.test.ts @@ -4,29 +4,9 @@ import { createAtom, Store } from "@tanstack/store"; import type { Atom } from "@tanstack/store"; import { createJSONStorage } from "../../core/persist-core"; -import type { StateStorage } from "../../core/persist-core"; +import { MemoryStorage } from "../../testing/memory-storage"; import { persistAtom, persistStore } from "./tanstack-store"; -class MemoryStorage implements StateStorage { - private store = new Map<string, string>(); - - clear() { - this.store.clear(); - } - - getItem(key: string) { - return this.store.get(key) ?? null; - } - - removeItem(key: string) { - this.store.delete(key); - } - - setItem(key: string, value: string) { - this.store.set(key, value); - } -} - function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { return new Promise<void>((resolve, reject) => { let ticks = 0; diff --git a/src/adapters/sources/valtio.test.ts b/src/adapters/sources/valtio.test.ts index 919a49d..6a89e8f 100644 --- a/src/adapters/sources/valtio.test.ts +++ b/src/adapters/sources/valtio.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; -import type { StateStorage } from "../../core/persist-core"; +import { MemoryStorage } from "../../testing/memory-storage"; type MockProxy<T extends object> = T & { __listeners: Set<() => void>; @@ -22,26 +22,6 @@ mock.module("valtio", () => ({ const { createJSONStorage } = await import("../../core/persist-core"); const { persistProxy } = await import("./valtio"); -class MemoryStorage implements StateStorage { - private store = new Map<string, string>(); - - clear() { - this.store.clear(); - } - - getItem(key: string) { - return this.store.get(key) ?? null; - } - - removeItem(key: string) { - this.store.delete(key); - } - - setItem(key: string, value: string) { - this.store.set(key, value); - } -} - function createMockProxy<T extends object>(initial: T): MockProxy<T> { const listeners = new Set<() => void>(); const state = { ...initial }; diff --git a/src/adapters/sources/zustand.test.ts b/src/adapters/sources/zustand.test.ts index d584640..29c4943 100644 --- a/src/adapters/sources/zustand.test.ts +++ b/src/adapters/sources/zustand.test.ts @@ -3,29 +3,9 @@ import { beforeEach, describe, expect, it } from "bun:test"; import type { StoreApi } from "zustand"; import { createJSONStorage } from "../../core/persist-core"; -import type { StateStorage } from "../../core/persist-core"; +import { MemoryStorage } from "../../testing/memory-storage"; import { persistStore } from "./zustand"; -class MemoryStorage implements StateStorage { - private store = new Map<string, string>(); - - clear() { - this.store.clear(); - } - - getItem(key: string) { - return this.store.get(key) ?? null; - } - - removeItem(key: string) { - this.store.delete(key); - } - - setItem(key: string, value: string) { - this.store.set(key, value); - } -} - function createMockStore<T>(initial: T) { let state = initial; const listeners = new Set<() => void>(); diff --git a/src/core/hydration.test.ts b/src/core/hydration.test.ts index d086357..481f755 100644 --- a/src/core/hydration.test.ts +++ b/src/core/hydration.test.ts @@ -1,25 +1,10 @@ import { describe, expect, it } from "bun:test"; +import { MemoryStorage } from "../testing/memory-storage"; import { alwaysHydratedSignal, toHydrationSignal } from "./hydration"; import type { HydrationSource } from "./hydration"; import { createJSONStorage, persistSource } from "./persist-core"; -import type { PersistableSource, StateStorage } from "./persist-core"; - -class MemoryStorage implements StateStorage { - private store = new Map<string, string>(); - clear() { - this.store.clear(); - } - getItem(key: string) { - return this.store.get(key) ?? null; - } - removeItem(key: string) { - this.store.delete(key); - } - setItem(key: string, value: string) { - this.store.set(key, value); - } -} +import type { PersistableSource } from "./persist-core"; function createMockSource<T>(initial: T): PersistableSource<T> & { state: T } { let state = initial; diff --git a/src/core/persist-core.test.ts b/src/core/persist-core.test.ts index f678faf..452bb98 100644 --- a/src/core/persist-core.test.ts +++ b/src/core/persist-core.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { MemoryStorage } from "../testing/memory-storage"; import { createJSONStorage, createPersistRegistry, @@ -44,26 +45,6 @@ describe("persist-core zero-dep gate", () => { }); }); -class MemoryStorage implements StateStorage { - private store = new Map<string, string>(); - - clear() { - this.store.clear(); - } - - getItem(key: string) { - return this.store.get(key) ?? null; - } - - removeItem(key: string) { - this.store.delete(key); - } - - setItem(key: string, value: string) { - this.store.set(key, value); - } -} - function createMockSource<T>(initial: T): PersistableSource<T> & { state: T } { let state = initial; const listeners = new Set<() => void>(); diff --git a/src/testing/memory-storage.ts b/src/testing/memory-storage.ts new file mode 100644 index 0000000..d88b8d9 --- /dev/null +++ b/src/testing/memory-storage.ts @@ -0,0 +1,25 @@ +import type { StateStorage } from "../core/persist-core"; + +/** + * In-memory `StateStorage` for tests — a `Map`-backed `localStorage` stand-in. + * Test-only; not a tsdown/typedoc entry, so not shipped in `dist/`. + */ +export class MemoryStorage implements StateStorage { + private store = new Map<string, string>(); + + clear() { + this.store.clear(); + } + + getItem(key: string) { + return this.store.get(key) ?? null; + } + + removeItem(key: string) { + this.store.delete(key); + } + + setItem(key: string, value: string) { + this.store.set(key, value); + } +} From 4716e44ae06529d1743681c0fee5988362ce1545 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 18:20:52 +0300 Subject: [PATCH 49/77] docs(plans): strike the memory test-fixture backlog item (shipped) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/plans/remaining-roi.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/plans/remaining-roi.md b/docs/plans/remaining-roi.md index c778643..efedc58 100644 --- a/docs/plans/remaining-roi.md +++ b/docs/plans/remaining-roi.md @@ -97,7 +97,6 @@ Framework adapters mount `HydrationSignal` into each framework's external-store From audit Appendix B.3. Each is a one-line composition over an existing seam; ship only if demand surfaces. - **sessionStorage named factory** (S) — already works via `createJSONStorage(() => sessionStorage)`; a named factory is pure DX/discoverability. Cross-tab is meaningless (per-tab). -- **memory test-fixture adapter** (S) — dedupes the `MemoryStorage` class copy-pasted across test files. Test-only; not published. - **Redis backend** (M) — server-side persistent state; async `StateStorage`. Pairs with `./backends/node-fs` for a real server story. - **Chrome `storage.area`** (S) — `local` / `sync` / `session` for MV3 extensions (`localStorage` is forbidden in MV3 service workers). - **cookies backend** (M) — server-rendered hydration; size limits + HTTP coupling make it awkward — likely a recipe, not a shipped peer. From 07fdb5bb4f7ee297136f40864bdcbdc829348c11 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 18:23:52 +0300 Subject: [PATCH 50/77] chore(deps): refresh devDeps + override uuid to clear CVE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- bun.lock | 127 ++++++++++++++++++++++++++++----------------------- package.json | 13 ++++-- 2 files changed, 79 insertions(+), 61 deletions(-) diff --git a/bun.lock b/bun.lock index baca3d8..14d74a8 100644 --- a/bun.lock +++ b/bun.lock @@ -12,15 +12,15 @@ "@react-native-async-storage/async-storage": "^3.1.1", "@size-limit/preset-small-lib": "12.1.0", "@stainless-code/codemap": "0.11.1", - "@tanstack/intent": "0.3.4", + "@tanstack/intent": "0.3.5", "@tanstack/store": "0.11.0", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.2", "@types/bun": "1.3.14", - "@types/node": "26.0.1", + "@types/node": "26.1.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", - "@typescript/native-preview": "7.0.0-dev.20260628.1", + "@typescript/native-preview": "7.0.0-dev.20260705.1", "expo-secure-store": "^57.0.0", "husky": "9.1.7", "idb-keyval": "6.2.6", @@ -29,8 +29,8 @@ "knip": "6.24.0", "lint-staged": "17.0.8", "mobx": "6.16.1", - "oxfmt": "0.56.0", - "oxlint": "1.71.0", + "oxfmt": "0.57.0", + "oxlint": "1.72.0", "preact": "10.29.4", "publint": "0.3.21", "react": "19.2.7", @@ -89,6 +89,9 @@ ], }, }, + "overrides": { + "uuid": ">=11.1.1", + }, "packages": { "@andrewbranch/untar.js": ["@andrewbranch/untar.js@1.0.3", "", {}, "sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw=="], @@ -540,81 +543,81 @@ "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.20.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw=="], - "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-CSCxi7ovYojgfdPOdUb9T508HKeAdDIKeRGg7x8IZwVJrWz9gVgX7MbUnFqtQAE4QvoNo07mj2JlwnOzJw4qqA=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.57.0", "", { "os": "android", "cpu": "arm" }, "sha512-qVBsEO+KugOsCmUHcO8iqNnqc65p7PCKpCs8M66mPZ+Ri+CWbcpoQOEJBg2OTu03+0qu++NK1jj6IzvQVs0Sig=="], - "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.56.0", "", { "os": "android", "cpu": "arm64" }, "sha512-HYJFnd+PkDwf6S9ZPGzXXtjNqvRWFnnhdbWaouh4mi/SxU8wmDuzlMn3xo/wDTGnr4Q1VA7ZzOaE/D4biW0W6A=="], + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.57.0", "", { "os": "android", "cpu": "arm64" }, "sha512-mp6PibWbao3aizijcheOeHQaYEhcUAt8pwLniYbtLfHxL/psFF0BykAwCj+s3c6qIpa8yN8keZICWrqtZ70w8g=="], - "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.56.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sftR/bEOr+t1gs+evwsHi/Xbq2FAPA2uU3VMr8n6ZU9PoK/IMSfnfu7+OEe/uy1+knhrFl4Wvy7Vkm3uo9mJ7g=="], + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.57.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-T+0stuCBqmUVY+aMIvrgXhzGhHO3sD5tNiiEcYqgSdPsnukskQqn2u5qOVD0sv1l7RLdFS5Z/f5Wi9Ktyjr3Eg=="], - "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.56.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-z66SdjLqa3MUPKvTp3Mbb5nSjKSbnYxJGeB+Wx987s8T5hPcIRiBMfnJ6zcPgYtQn3x5xjvdzNVkXrSeYH6ZFg=="], + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.57.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-O+3JbqWs/mCI2oi4xfhRO2IVPFJNDDEBV8Odo+ZpmsUOeKJfjXoNH7nDmBEQcDgK7NfjDIyE7kRgYSZcTLDO0A=="], - "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.56.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-t2tkrV1vtZyaItSQ71dTi2ZVKZEI39b/LqLT12V5KMfIeXK6N32TUC1jhOXKVQmhECq9j2ZXMQV3JeT1kh9Vmg=="], + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.57.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-pxwhxVC+JkLX9twOQ/8C/vbuOQcMZyKIDmiRDZfO7yITuVcIdZCiLRqqf4QOxb2+8FWrRXzQpm+1DBKcMpHSSQ=="], - "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+gCy+Tp3RHeXQ9y/QrS76lXIpZkbziTyp6hIgjB2MssCwfMph3vG/GEfkhO34Rai1vhYIaUkvv8UT1BcDorJPw=="], + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.57.0", "", { "os": "linux", "cpu": "arm" }, "sha512-pxBU4zH2imB/MDBfth2rOMeVxXUMjRQLCazagwLARIFH3hVlxZJBlM4nSnHXaIHJK4/qezoFCIORN6AY8Mra4A=="], - "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-0kKkVvQ2I+FJ2sxQyUu1zJ0yWP5kcWse/yVFnGQSFCXMwSSkfEaUGu0dW774O7nyy3jrcBGap7OSc8dZmU/CdA=="], + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.57.0", "", { "os": "linux", "cpu": "arm" }, "sha512-JAprOzt8tycYou36ZgEw14DlRHTiN8qdtKANdV3VZIRIvTI/lh/cX13c9pJ/EnDk2GT3FASH7KvCgQ2AufAifQ=="], - "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-npkA2siMbyWRh+wEhi1aTAx4RirukGcGNt8V4Ch86pG+xU9aurqS1MZOnKYMu03ISwat3rB6zkQx51SsB9obNw=="], + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.57.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ajtjaxSaj9xl4BW7REt+Cef/ttzbAq00Bq4z7JUDZEfgFXdwSjH8K9bF+IcIJzZB9lKqMfQ4eHuSFOvvlvtqOg=="], - "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UekqOjGkV4/MkqreCV9SPIB2jlR3/HbXrmhV1rVXJZ9wfDXMyCMriLtq3tHqLY4PkbVWNtfcm1kMojJ26KLSJw=="], + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.57.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-p4Y/+RYk9Bk5WO+zHSUXAClRmZ2fbJCejMuCAsU2HhyME4jqf6Ftt/mJYEwIah1wGCBDYOB7wEGV1x5bCEZ6hA=="], - "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.56.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XSzveSpeZMD5XJpew5lRFVtNnT04xd3rJxENXmk7wkZzN9oWzv2aFJyoNDhOtoz69BYaS/fg4SYl+CfEZRpB0Q=="], + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.57.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-By6tRALAZsno0F4zedmtG+wdMvJiJmJoXM4d3+A9zHE4HRXLqXITwRH8mgrlcXc5yJM2g2W3riRPwTYdgemZLQ=="], - "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-EkQ0nJa7k7HDDIVuPF7WY+k4k+bzdclLYtyIXNt7/OqVghfNiMym6YGppFBgx1XRIHW6QylxBz5OogumPjPJbQ=="], + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.57.0", "", { "os": "linux", "cpu": "none" }, "sha512-skYeG+RgvyzspqVEBsEprL90OYYZfoVNqB3HcCNR6QDJyXKOzfDRT3zncnHmUaFluIlBHuY23mU1b5WGgR98hA=="], - "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-dyjAGW8jKRge0ik6U/dgvQG0nVpA3iBlRskQTz5qJLvQWIrySxX5jpqzPetLBNIIZ231KA82fDdi1nLTk8ENCw=="], + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.57.0", "", { "os": "linux", "cpu": "none" }, "sha512-FFgACrZOXAXUh5KQh2mt1CDOVOZmn+QzHP71wM9QobNwyQvoFfyAeefVUltW83g3sm7LTiH3yfFqLLVUpA5ZFQ=="], - "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.56.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-60ZGH3LtfqlW8X6vcLdSFY4lvCQYINurttYBKaALnHCDVAUCYJ1LsUgS6p1XOzVlzEDx3yNUZvDF1Lvt59zoZw=="], + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.57.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Nm/BAOfQeFiiKd502mZn/GAVKJwtd0RdCg17G3Wz/WSOIQmDi3+7/SZH4BHn1Ye5KvTVH3ua8WvfwLLycNIuvA=="], - "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-u1suj1tgJHK4ZqB7buCtdbNef2n8+d0lXTPJwLHNmtyK6p+DTpsaoDvmqhQrA56fgKYv4LuRxNtL8YooebKOew=="], + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.57.0", "", { "os": "linux", "cpu": "x64" }, "sha512-BiSy5Ku3mQqyxS6YIqAJgd403wEUWvI7kerfzPxc2l/txZVmZM0pSj7oDM+4bGBExowxOi7o73jEam1W0EDTZg=="], - "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-aYGLvlQHt80y+qKEtfJY/Nm27G0125Lv+qyh9SJ4Cjc6lXnXjD+ndfhqQnbV24POpMi7rNRi0jvx/0d70FRpCQ=="], + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.57.0", "", { "os": "linux", "cpu": "x64" }, "sha512-BCRkJiotz5s9afLYD2LuMvzAoDYx9H17E/YbDyu4xK7l4zHDPeny9ErSXL//i/nJyaOwRk08x4b8cgJC00+JDg=="], - "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.56.0", "", { "os": "none", "cpu": "arm64" }, "sha512-H/re/gO+7ysVc+kywHNuzY3C33EN9sQcZhg0kp1ZwOZl7y998ZE5mhnBiuGR/nYI0pqLL5xQfrHVUOJ/cIJsCA=="], + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.57.0", "", { "os": "none", "cpu": "arm64" }, "sha512-4Oaxe1qrGgXfpCJ1C/ERJ2iCtV2rN1R79ga9fsfyVHfSQRu/hVW780u2KDqZWFZ/iGTHODJji0JemxqFZ63eIQ=="], - "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.56.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-6qLNXfXmtAs8jXDvYMkxk6Wec5SUJoew+ZX1uOZmqaR7ks0EJFbAohuOCELDyJMWyVlxotVG8Xf8m74Bfq0O2w=="], + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.57.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-MYLAsDnhdNsSGheLYhWgbk0vfIrlS84iQYun/y21fX6u0jj8iBtYtbpZMdiqYeuf8U12eVPUjVY2xE2NrCfJ0g=="], - "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.56.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UXEXuKphAe15bsob4AswNMArCw38XSmUIs3wk1s6e6MX9OWGW/IRWU95s1hZDiVg09STy1jHgyN2qkqbu1FT0w=="], + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.57.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-PBwdzZALJY/jcCx2E6is0yu+cuVXeySTDmwuseD+9j0mHqlRNxwlKgsyRTBed/woPeqfVfuXfWjoq4Cx2Zt3Eg=="], - "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-HPyNDjky+NIOuaMvHZflR+kst3YWdUOH2JUQYkf99grqZ5mEBTQM6h9iGy501Z8Xt5xMScrwHOuVCOlqDrktRw=="], + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bQJdH9i4RRfw55jm7+8/xS7GzHLLTbHx4huhrrDxQJaJtbSDbsyOnODvP1ftT7EG0KFKAYO2S+q6AcioXODx8w=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.71.0", "", { "os": "android", "cpu": "arm" }, "sha512-ImGmd1njEg4FEJH03jhRnveEegtO3czCtfptvaHivKAZQIYATbVFBrrzbaYMYv0oJioTnxZAZVSyV+oL7W8S2g=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.72.0", "", { "os": "android", "cpu": "arm" }, "sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.71.0", "", { "os": "android", "cpu": "arm64" }, "sha512-4A5BEexBrwY1YFF8Kiq/lp/wQPRG79G3BWIE1FuWaM5MvmpYSd+7ZySVcKkHdwo0UDzdQGddp6pD9mpctMqLnw=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.72.0", "", { "os": "android", "cpu": "arm64" }, "sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.71.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9wJA9GJulLwS2usU3CEisI/ESDO1n1z9eyTCvApMDrAkbJ1ve0mORgTMjcWWsKxkzkeZ2N/Gpra5IQE7x8tYgQ=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.72.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.71.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-PlLCjS06V0PeJMAJwzjrExw1sYNW9Gch3JtNlcwwZDXGlTYDuwHNN89zYH8LTXFfgkVtsYvs2nv0FqrzyuFDzg=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.72.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.71.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Lhil7bWre0ncxbUoDoxfS0JzpTz17BRQKW7iwoAUY8GJ66+WwJEfYPCFJ1P0WgVZR5/O/b3Q2pENlHOjeXLOGQ=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.72.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.71.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Oo9/L58PYD3RC0x05d2upAPLllHytTjHQGsnC06P6Ynn7jKkp5mdImQxXdJ3+FnBaKspNpGogzgVsi6g872LiA=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.72.0", "", { "os": "linux", "cpu": "arm" }, "sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.71.0", "", { "os": "linux", "cpu": "arm" }, "sha512-mSHfyfgJrEbyIR29ejaeS50BdPk+GoNPlC1dckpDiUZbJAIel68sjSMdOt4WY0/gva+ECC7FNITQkxMJU+vSBw=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.72.0", "", { "os": "linux", "cpu": "arm" }, "sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.71.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-n9yY4M2tiy3aij4AqtlnspzpfdpeT5JQfK2/w2d8oyp5W0FRwOb1dIeX99nORNcxGr08iD9bH8N5XFz3I2iy8w=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.72.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.71.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.72.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.71.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.72.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.71.0", "", { "os": "linux", "cpu": "none" }, "sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.72.0", "", { "os": "linux", "cpu": "none" }, "sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.71.0", "", { "os": "linux", "cpu": "none" }, "sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.72.0", "", { "os": "linux", "cpu": "none" }, "sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.71.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.72.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.71.0", "", { "os": "linux", "cpu": "x64" }, "sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.72.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.71.0", "", { "os": "linux", "cpu": "x64" }, "sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.72.0", "", { "os": "linux", "cpu": "x64" }, "sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.71.0", "", { "os": "none", "cpu": "arm64" }, "sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.72.0", "", { "os": "none", "cpu": "arm64" }, "sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.71.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-bd5kI8spYwTm3BILDtGhi73zoup5dw8MlPQNT8YB3BD5UIsjNe3K9/4ctrzQMX4SZMoK5HgzVLkLJzacEXB7fA=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.72.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.71.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-W4HvOHGzVLHcrmFu+bMrJlho+/yrlX5ZNdJZqGe8MEldkQG+RHYhxxad9P4jvWAYFmIqUA5i9DQ8QsJqSU9GIw=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.72.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.71.0", "", { "os": "win32", "cpu": "x64" }, "sha512-D2kyEIPHk/G/wiZLnwTVC/sVst+T/lKldVOjAFpgTIBUAOlry72e5OiapDbDBF4LfJLkN5ypJb/8Eu6yJzkveQ=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.72.0", "", { "os": "win32", "cpu": "x64" }, "sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA=="], "@publint/pack": ["@publint/pack@0.1.5", "", { "dependencies": { "tinyexec": "^1.2.4" } }, "sha512-edgyN2pP07uXiP4tJs0s8KVmU8M8i60YPbbI0/WDeok1mIJHRXz+CgD8I0nelwDkoCh3EWL/G5kGfbuHjsdbvw=="], @@ -702,7 +705,7 @@ "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="], - "@tanstack/intent": ["@tanstack/intent@0.3.4", "", { "dependencies": { "cac": "^6.7.14", "jsonc-parser": "^3.3.1", "semver": "^7.8.4", "std-env": "^4.1.0", "yaml": "2.9.0" }, "bin": { "intent": "dist/cli.mjs" } }, "sha512-yK1VRa118Xdcj+YmuVPVVkSLtyabYnr7Wcn6sBMdHhFEfav/QHIP0G7NdNOA7qSHgbrXiKmLMyYJCQpJ8OLgfQ=="], + "@tanstack/intent": ["@tanstack/intent@0.3.5", "", { "dependencies": { "cac": "^6.7.14", "jsonc-parser": "^3.3.1", "semver": "^7.8.4", "std-env": "^4.1.0", "yaml": "2.9.0" }, "bin": { "intent": "dist/cli.mjs" } }, "sha512-OVeyLejsN+LYGVNCJNhcCOlFkpqdkWG/ZB6mDDVOzfoLYDn/9uKaCmZkV6GsIkF0aKY7uu05RTJNibZ+uaZ1ig=="], "@tanstack/store": ["@tanstack/store@0.11.0", "", {}, "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw=="], @@ -732,7 +735,7 @@ "@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="], - "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], + "@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="], "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], @@ -746,21 +749,21 @@ "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], - "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260628.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260628.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260628.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260628.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260628.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260628.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260628.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260628.1" }, "bin": { "tsgo": "bin/tsgo" } }, "sha512-359WmBk3vA/bJxfeWyLbFeeejmky7Wssc8HMu0Iabu490WJLj/FqkDC51V65yuDp+anMAEkgeKO43fj6pMb/ZA=="], + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260705.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260705.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260705.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260705.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260705.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260705.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260705.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260705.1" }, "bin": { "tsgo": "bin/tsgo" } }, "sha512-mXc3tDkRCe8UinMcl5yqUfLAP5Vk5MFuZNNaNsrwRehu1d+lnL+Zu+K7g6kSMOV8NOY9bn9tGFN4/cqhlK4VwA=="], - "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260628.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eHHDHAZjbZ681sHyW87tg8mH4+xIs+Q7cHKIlrdafqeny1KYWorj3O9Qfnjvcl2Yd2Eq+IzJxffF6Tepy13L4Q=="], + "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260705.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OemjoGm+5nLCaUyL0zwWcSDhyQYQnO6/DcOidm2EzOqK7dYXgaCerDFUAz6iOGjOFtTAQFU50CV+ZbtUxodk9A=="], - "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260628.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-xIsJSXa0Fsv0pPfQ0YYa7nUQJ/nGRF/r8p60e0Aa29SexxgOXMsu3YhOnUnJEdbvaPzqlKqa1GqNGpbGnLQcLA=="], + "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260705.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-jYEsBtcVOin3ARa2xAkwM21KOlmr9s3FGxcY28gESigXPJOGooRaxZlJ45tFM3TczCMEGmML88XCXuQaH2z1kw=="], - "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260628.1", "", { "os": "linux", "cpu": "arm" }, "sha512-BoclteE+MBOnfK0Qh21mQgrvYPy/v2k7CPTPufcNp1g1fsSvsF3Xv6K8I/grEjo3ZjNrgIvVxivoAqaQhSMlGA=="], + "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260705.1", "", { "os": "linux", "cpu": "arm" }, "sha512-abTOyfyx5SW5szf1YeZf5K9vUWEE7nbbqUUthl8OgRrFrJzlZnDIRf/FV0h6QGqQIN7XTf8lgKjdFe4piNFLww=="], - "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260628.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-p3yj2a70vkaFB3OD+Vt4oNUaiE7I30fwiXs6LVNAW6k0GSHNc4ucYcWVlpcp8+cej9RBQgxMnMH5MSVYmNhUvw=="], + "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260705.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-kmWaZkuqb5RGTHt4AFIdPJ53/wO8vuZbmjSizNFNpvx1owYgQ8QZNu51zKfLQZbMs2FR7NAzVuQT14mhBaQW5Q=="], - "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260628.1", "", { "os": "linux", "cpu": "x64" }, "sha512-LKNKDoTnM8aacpbt1u8kJR1feXpBuLlvKKbVt0RYBL4j1OA148TXKjLtbVu37I0lcVxjqERYuAybpvut2xq31w=="], + "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260705.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oNA9I3MFLqdFVbwolnsFObjrQ1AsBXc4WXPcNyKcoaauAmFTwjxOBPKtiaqFKj4j4Abum7KvjVj0fgBaO5gRPA=="], - "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260628.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-XUGCYlDAfeA4PIm7ZSZtVHmvffVoMct0LhTA/CoALhSQFnFnJdipOfsZghSyU6TCpTuzBoOhWCjBufrQC23xOQ=="], + "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260705.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-K6hTgnqAEBbrIGH6feXM//FHbk0ilSAQDpLct+3IHfd+tg2sKop5lK2rTZkl/ptHM7Vlh0CF77m0RL73an+oAw=="], - "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260628.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rJMZ+YaRv9XybOZBYAsJt7x/K2IWmX9bgRatHobl0wwkKmfKd3giNnRXcDwOqYeCaWzunCbUhAirUtuUprRcnQ=="], + "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260705.1", "", { "os": "win32", "cpu": "x64" }, "sha512-gAwPJgfQWtJnZlMrJ8NCA+vdI42N+XLev+jqZnpcMqt+Jd2zMtoNpppHB0I6URMVys+R2WpMiOOSlHcnvOMyTQ=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.2", "", {}, "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA=="], @@ -1510,9 +1513,9 @@ "oxc-resolver": ["oxc-resolver@11.20.0", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.20.0", "@oxc-resolver/binding-android-arm64": "11.20.0", "@oxc-resolver/binding-darwin-arm64": "11.20.0", "@oxc-resolver/binding-darwin-x64": "11.20.0", "@oxc-resolver/binding-freebsd-x64": "11.20.0", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.20.0", "@oxc-resolver/binding-linux-arm-musleabihf": "11.20.0", "@oxc-resolver/binding-linux-arm64-gnu": "11.20.0", "@oxc-resolver/binding-linux-arm64-musl": "11.20.0", "@oxc-resolver/binding-linux-ppc64-gnu": "11.20.0", "@oxc-resolver/binding-linux-riscv64-gnu": "11.20.0", "@oxc-resolver/binding-linux-riscv64-musl": "11.20.0", "@oxc-resolver/binding-linux-s390x-gnu": "11.20.0", "@oxc-resolver/binding-linux-x64-gnu": "11.20.0", "@oxc-resolver/binding-linux-x64-musl": "11.20.0", "@oxc-resolver/binding-openharmony-arm64": "11.20.0", "@oxc-resolver/binding-wasm32-wasi": "11.20.0", "@oxc-resolver/binding-win32-arm64-msvc": "11.20.0", "@oxc-resolver/binding-win32-x64-msvc": "11.20.0" } }, "sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g=="], - "oxfmt": ["oxfmt@0.56.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.56.0", "@oxfmt/binding-android-arm64": "0.56.0", "@oxfmt/binding-darwin-arm64": "0.56.0", "@oxfmt/binding-darwin-x64": "0.56.0", "@oxfmt/binding-freebsd-x64": "0.56.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.56.0", "@oxfmt/binding-linux-arm-musleabihf": "0.56.0", "@oxfmt/binding-linux-arm64-gnu": "0.56.0", "@oxfmt/binding-linux-arm64-musl": "0.56.0", "@oxfmt/binding-linux-ppc64-gnu": "0.56.0", "@oxfmt/binding-linux-riscv64-gnu": "0.56.0", "@oxfmt/binding-linux-riscv64-musl": "0.56.0", "@oxfmt/binding-linux-s390x-gnu": "0.56.0", "@oxfmt/binding-linux-x64-gnu": "0.56.0", "@oxfmt/binding-linux-x64-musl": "0.56.0", "@oxfmt/binding-openharmony-arm64": "0.56.0", "@oxfmt/binding-win32-arm64-msvc": "0.56.0", "@oxfmt/binding-win32-ia32-msvc": "0.56.0", "@oxfmt/binding-win32-x64-msvc": "0.56.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-9Dv0wV3zKiyvhjD7bRKaInKmHQ1sPx3RGOjQkGFJbbdQ16576yf8qhMSO9Q9cvHcs+1NpBsRTkuDDYFFPTJ6gw=="], + "oxfmt": ["oxfmt@0.57.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.57.0", "@oxfmt/binding-android-arm64": "0.57.0", "@oxfmt/binding-darwin-arm64": "0.57.0", "@oxfmt/binding-darwin-x64": "0.57.0", "@oxfmt/binding-freebsd-x64": "0.57.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.57.0", "@oxfmt/binding-linux-arm-musleabihf": "0.57.0", "@oxfmt/binding-linux-arm64-gnu": "0.57.0", "@oxfmt/binding-linux-arm64-musl": "0.57.0", "@oxfmt/binding-linux-ppc64-gnu": "0.57.0", "@oxfmt/binding-linux-riscv64-gnu": "0.57.0", "@oxfmt/binding-linux-riscv64-musl": "0.57.0", "@oxfmt/binding-linux-s390x-gnu": "0.57.0", "@oxfmt/binding-linux-x64-gnu": "0.57.0", "@oxfmt/binding-linux-x64-musl": "0.57.0", "@oxfmt/binding-openharmony-arm64": "0.57.0", "@oxfmt/binding-win32-arm64-msvc": "0.57.0", "@oxfmt/binding-win32-ia32-msvc": "0.57.0", "@oxfmt/binding-win32-x64-msvc": "0.57.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-ZB7Bi+rGDSqmVIo9jwcLyFgjxXvQhDdU+jx+ZrVy6VRiVXK2+CHc4hO3J4dUQjHe7V0ymHB+MDuv5z+NhK07HA=="], - "oxlint": ["oxlint@1.71.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.71.0", "@oxlint/binding-android-arm64": "1.71.0", "@oxlint/binding-darwin-arm64": "1.71.0", "@oxlint/binding-darwin-x64": "1.71.0", "@oxlint/binding-freebsd-x64": "1.71.0", "@oxlint/binding-linux-arm-gnueabihf": "1.71.0", "@oxlint/binding-linux-arm-musleabihf": "1.71.0", "@oxlint/binding-linux-arm64-gnu": "1.71.0", "@oxlint/binding-linux-arm64-musl": "1.71.0", "@oxlint/binding-linux-ppc64-gnu": "1.71.0", "@oxlint/binding-linux-riscv64-gnu": "1.71.0", "@oxlint/binding-linux-riscv64-musl": "1.71.0", "@oxlint/binding-linux-s390x-gnu": "1.71.0", "@oxlint/binding-linux-x64-gnu": "1.71.0", "@oxlint/binding-linux-x64-musl": "1.71.0", "@oxlint/binding-openharmony-arm64": "1.71.0", "@oxlint/binding-win32-arm64-msvc": "1.71.0", "@oxlint/binding-win32-ia32-msvc": "1.71.0", "@oxlint/binding-win32-x64-msvc": "1.71.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-U1m1X+C0vDj7DC1e13IoZULzEcPczE7UOMTs8VlZGHUEIUaSTZKo5qkPsQEfzpgnQ29Pea/w3Xntk62UCecxZw=="], + "oxlint": ["oxlint@1.72.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.72.0", "@oxlint/binding-android-arm64": "1.72.0", "@oxlint/binding-darwin-arm64": "1.72.0", "@oxlint/binding-darwin-x64": "1.72.0", "@oxlint/binding-freebsd-x64": "1.72.0", "@oxlint/binding-linux-arm-gnueabihf": "1.72.0", "@oxlint/binding-linux-arm-musleabihf": "1.72.0", "@oxlint/binding-linux-arm64-gnu": "1.72.0", "@oxlint/binding-linux-arm64-musl": "1.72.0", "@oxlint/binding-linux-ppc64-gnu": "1.72.0", "@oxlint/binding-linux-riscv64-gnu": "1.72.0", "@oxlint/binding-linux-riscv64-musl": "1.72.0", "@oxlint/binding-linux-s390x-gnu": "1.72.0", "@oxlint/binding-linux-x64-gnu": "1.72.0", "@oxlint/binding-linux-x64-musl": "1.72.0", "@oxlint/binding-openharmony-arm64": "1.72.0", "@oxlint/binding-win32-arm64-msvc": "1.72.0", "@oxlint/binding-win32-ia32-msvc": "1.72.0", "@oxlint/binding-win32-x64-msvc": "1.72.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA=="], "p-filter": ["p-filter@2.1.0", "", { "dependencies": { "p-map": "^2.0.0" } }, "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw=="], @@ -1866,7 +1869,7 @@ "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], - "uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="], + "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], "validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="], @@ -1968,6 +1971,8 @@ "@expo/ws-tunnel/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "@jest/types/@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], + "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], "@manypkg/find-root/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], @@ -1998,6 +2003,12 @@ "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "bun-types/@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], + + "chrome-launcher/@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], + + "chromium-edge-launcher/@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], + "cli-highlight/parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], "cli-highlight/yargs": ["yargs@16.2.2", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w=="], @@ -2022,10 +2033,14 @@ "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "jest-util/@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], + "jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "jest-validate/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "jest-worker/@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], + "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "knip/oxc-parser": ["oxc-parser@0.137.0", "", { "dependencies": { "@oxc-project/types": "^0.137.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.137.0", "@oxc-parser/binding-android-arm64": "0.137.0", "@oxc-parser/binding-darwin-arm64": "0.137.0", "@oxc-parser/binding-darwin-x64": "0.137.0", "@oxc-parser/binding-freebsd-x64": "0.137.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.137.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.137.0", "@oxc-parser/binding-linux-arm64-gnu": "0.137.0", "@oxc-parser/binding-linux-arm64-musl": "0.137.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.137.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.137.0", "@oxc-parser/binding-linux-riscv64-musl": "0.137.0", "@oxc-parser/binding-linux-s390x-gnu": "0.137.0", "@oxc-parser/binding-linux-x64-gnu": "0.137.0", "@oxc-parser/binding-linux-x64-musl": "0.137.0", "@oxc-parser/binding-openharmony-arm64": "0.137.0", "@oxc-parser/binding-wasm32-wasi": "0.137.0", "@oxc-parser/binding-win32-arm64-msvc": "0.137.0", "@oxc-parser/binding-win32-ia32-msvc": "0.137.0", "@oxc-parser/binding-win32-x64-msvc": "0.137.0" } }, "sha512-yFImD+WLElJpLKy8llG1qe4DCmMsL18peRp8XP1JKfig/gISbJkglnpDtX2aTmAn10kZF7164HbN2H8QPsXxGg=="], diff --git a/package.json b/package.json index d11ae43..1b9a214 100644 --- a/package.json +++ b/package.json @@ -186,15 +186,15 @@ "@react-native-async-storage/async-storage": "^3.1.1", "@size-limit/preset-small-lib": "12.1.0", "@stainless-code/codemap": "0.11.1", - "@tanstack/intent": "0.3.4", + "@tanstack/intent": "0.3.5", "@tanstack/store": "0.11.0", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.2", "@types/bun": "1.3.14", - "@types/node": "26.0.1", + "@types/node": "26.1.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", - "@typescript/native-preview": "7.0.0-dev.20260628.1", + "@typescript/native-preview": "7.0.0-dev.20260705.1", "expo-secure-store": "^57.0.0", "husky": "9.1.7", "idb-keyval": "6.2.6", @@ -203,8 +203,8 @@ "knip": "6.24.0", "lint-staged": "17.0.8", "mobx": "6.16.1", - "oxfmt": "0.56.0", - "oxlint": "1.71.0", + "oxfmt": "0.57.0", + "oxlint": "1.72.0", "preact": "10.29.4", "publint": "0.3.21", "react": "19.2.7", @@ -295,6 +295,9 @@ "optional": true } }, + "overrides": { + "uuid": ">=11.1.1" + }, "engines": { "bun": ">=1.0.0", "node": "^20.19.0 || >=22.12.0" From 841b4d9d93675497559ba743c58fd391bb9af98f Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 18:32:18 +0300 Subject: [PATCH 51/77] ci(supply-chain): Dependabot + SHA-pin hand-written actions + least-priv CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/dependabot.yml | 27 +++++++++++++++++++-------- .github/workflows/ci.yml | 24 +++++++++++++++--------- .github/workflows/release.yml | 4 ++-- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a38d499..f4f30c9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,12 +1,23 @@ -# Keep GitHub Actions versions current. npm deps are managed via changesets -# and `bun run check-updates`; dependabot only watches the workflow toolchain. version: 2 + updates: + # npm — group devDep bumps so they land in one weekly PR (devDeps only; + # the package has no runtime deps). Peer deps are intentionally not bumped + # (their ranges are the public surface). + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + groups: + dev-deps: + dependency-type: development + + # GitHub Actions — keep the SHA-pinned actions current. Bumps the commit SHA + # (and the trailing `# vN` comment) so a pinned action's latest major stays + # tamper-resistant without manual tracking. - package-ecosystem: github-actions - directory: / + directory: "/" schedule: - interval: monthly - commit-message: - prefix: "chore(ci): " - labels: - - dependencies + interval: weekly + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6c16f9..4f04431 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,12 @@ on: branches: - main +# Least privilege: every CI job is read-only (no repo writes). The Release +# workflow carries its own elevated `permissions` (contents / pull-requests / +# id-token) — this default does not extend to it. +permissions: + contents: read + jobs: skip-ci: name: Gate (skip full CI for version PRs?) @@ -39,7 +45,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup uses: ./.github/actions/setup @@ -54,7 +60,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup uses: ./.github/actions/setup @@ -69,7 +75,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup uses: ./.github/actions/setup @@ -84,7 +90,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup uses: ./.github/actions/setup @@ -99,7 +105,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup uses: ./.github/actions/setup @@ -114,7 +120,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup uses: ./.github/actions/setup @@ -129,7 +135,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup uses: ./.github/actions/setup @@ -144,7 +150,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup uses: ./.github/actions/setup @@ -163,7 +169,7 @@ jobs: continue-on-error: true steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup uses: ./.github/actions/setup diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe6127a..726c000 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: environment: release steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 0 @@ -37,7 +37,7 @@ jobs: # or a real publish failure — not assumed from the runner alone. - name: Create Release Pull Request or Publish to npm id: changesets - uses: changesets/action@v1 + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1 with: # Runs `changeset version` then oxfmt on CHANGELOG.md so `format:check` passes. version: bun run version From ac9a4c92df984651432d80a1fa1833230d910e17 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 18:34:30 +0300 Subject: [PATCH 52/77] 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. --- .github/dependabot.yml | 11 +++++------ .github/workflows/ci.yml | 6 +++--- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f4f30c9..4f4f82b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,9 +1,8 @@ version: 2 updates: - # npm — group devDep bumps so they land in one weekly PR (devDeps only; - # the package has no runtime deps). Peer deps are intentionally not bumped - # (their ranges are the public surface). + # npm — group devDep bumps into one weekly PR (avoids one-PR-per-dep noise). + # Peer ranges are the public surface — not bumped. - package-ecosystem: npm directory: "/" schedule: @@ -13,9 +12,9 @@ updates: dev-deps: dependency-type: development - # GitHub Actions — keep the SHA-pinned actions current. Bumps the commit SHA - # (and the trailing `# vN` comment) so a pinned action's latest major stays - # tamper-resistant without manual tracking. + # GitHub Actions — bumps the SHA-pinned actions' commits (and the trailing + # `# vN` comment), so a pinned major stays tamper-resistant without manual + # tracking. - package-ecosystem: github-actions directory: "/" schedule: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f04431..e2a3f06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,9 +15,9 @@ on: branches: - main -# Least privilege: every CI job is read-only (no repo writes). The Release -# workflow carries its own elevated `permissions` (contents / pull-requests / -# id-token) — this default does not extend to it. +# CI jobs are read-only; the Release workflow carries its own elevated +# `permissions` (contents / pull-requests / id-token) — this default doesn't +# extend to it. permissions: contents: read From e067ed9ab7accf095c0e2db122c709ca176e358a Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 18:39:28 +0300 Subject: [PATCH 53/77] ci(audit): block on high/critical, keep moderate/low non-blocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .github/workflows/ci.yml | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2a3f06..bf57278 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,14 +159,11 @@ jobs: run: bun run size audit: - # Non-blocking — known-CVE visibility only; does not catch supply-chain malware. - # Team policy: if this job is red on a dep/lockfile PR, triage before merge - # (overrides, fork swaps, or accepted risk with issue) even though CI stays green. - # Promote to blocking (drop continue-on-error) after main stays clean for a - # few weeks — Dependabot handles action bumps; dep bumps are intentional via bun update -i. - name: 🛡 Audit (non-blocking) + # `bun audit` exits 0 regardless of severity — the scan below decides. + # An advisory-API outage doesn't block; malware is out of scope + # (provenance + frozen lockfile cover that). + name: 🛡 Audit runs-on: ubuntu-latest - continue-on-error: true steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -174,8 +171,16 @@ jobs: - name: Setup uses: ./.github/actions/setup - - name: bun audit - run: bun audit + - name: bun audit (block on high/critical) + run: | + set +e + # bun emits ANSI color even when piped — strip it or the severity grep misses it. + NO_COLOR=1 bun audit 2>&1 | sed -E 's/\x1b\[[0-9;]*m//g' > /tmp/bun-audit.log + cat /tmp/bun-audit.log + if grep -E '(^|[[:space:]])(high|critical):[[:space:]]' /tmp/bun-audit.log; then + echo "::error::high or critical vulnerabilities found — blocking" + exit 1 + fi ci-complete: name: CI complete @@ -190,6 +195,7 @@ jobs: build, check-pack, size, + audit, ] if: always() runs-on: ubuntu-latest @@ -204,7 +210,8 @@ jobs: needs['test-dom'].result != 'success' || needs.build.result != 'success' || needs['check-pack'].result != 'success' || - needs.size.result != 'success' + needs.size.result != 'success' || + needs.audit.result != 'success' ) run: exit 1 From bd248e4e525d4d238fd5d44a6bc4f7a02a8fcff3 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 18:40:29 +0300 Subject: [PATCH 54/77] =?UTF-8?q?docs(plans):=20hydrate=20remaining-roi=20?= =?UTF-8?q?sequencing=20=E2=80=94=20#4=20done?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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. --- docs/plans/remaining-roi.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/plans/remaining-roi.md b/docs/plans/remaining-roi.md index efedc58..4c9e377 100644 --- a/docs/plans/remaining-roi.md +++ b/docs/plans/remaining-roi.md @@ -104,11 +104,12 @@ From audit Appendix B.3. Each is a one-line composition over an existing seam; s ## Sequencing -1. **#4 (provenance)** — S, unblocks supply-chain integrity; do first. -2. **#1 (Query bridge) + #6 (migration-chain)** — M each, pure code, high adoption payoff, no deps. -3. **#5 (real-browser + SSR matrix)** — M, de-risks the hydration-critical paths before more surface lands. -4. **#2 (examples/) → #3 (docs site) → #9 (playground)** — the docs/demo arc; sequence so each builds on the prior. -5. **#7 (React ergonomics) + #8 (OPFS/SQLite/Cloudflare)** — strategic; decide ship-vs-recipe per item. +(#4 is implemented; pending first-release verification — strike once the npm Provenance badge lands.) + +1. **#1 (Query bridge) + #6 (migration-chain)** — M each, pure code, high adoption payoff, no deps. Best next pick. +2. **#5 (real-browser + SSR matrix)** — M, de-risks the hydration-critical paths before more surface lands. +3. **#2 (examples/) → #3 (docs site) → #9 (playground)** — the docs/demo arc; sequence so each builds on the prior. +4. **#7 (React ergonomics) + #8 (OPFS/SQLite/Cloudflare)** — strategic; decide ship-vs-recipe per item. ## Reference From 2e9247f8989d0341a10489b3b592cc961370c90b Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 19:11:37 +0300 Subject: [PATCH 55/77] =?UTF-8?q?feat(core):=20add=20createMigrationChain?= =?UTF-8?q?=20=E2=80=94=20versioned=20migrate=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/migration-chain.md | 9 ++ README.md | 19 +++ src/core/persist-core.test.ts | 226 ++++++++++++++++++++++++++++++++++ src/core/persist-core.ts | 115 +++++++++++++++++ 4 files changed, 369 insertions(+) create mode 100644 .changeset/migration-chain.md diff --git a/.changeset/migration-chain.md b/.changeset/migration-chain.md new file mode 100644 index 0000000..2a09182 --- /dev/null +++ b/.changeset/migration-chain.md @@ -0,0 +1,9 @@ +--- +"@stainless-code/persist": minor +--- + +Add `createMigrationChain` — a zero-dep core helper that builds a `migrate` callback from a per-version step chain. The returned function walks `steps[fromVersion]` → `steps[fromVersion+1]` → … → `steps[version-1]`, awaiting each, so a payload at any supported older version migrates to the current one. Plug it into `PersistOptions.migrate`. + +- Options bag: `version` (current), `steps` (keyed by the version each step transforms _from_), `onNewer` (default `"throw"` — a downgrade is a bug), `onOlder` (default `"discard"` — you dropped support for that version). +- Eager construction validation: a gap in the covered range, an out-of-range step key, or a non-integer version throws now, not on a payload months later. +- Beyond TanStack Persist's `buster` (which discards on mismatch) — this transforms instead. diff --git a/README.md b/README.md index f82a59a..a0c7638 100644 --- a/README.md +++ b/README.md @@ -675,6 +675,25 @@ persistStore(store, { }); ``` +### Migration chain + +```ts +import { createMigrationChain } from "@stainless-code/persist"; + +// Per-version steps: steps[N] takes vN → v(N+1). The chain walks from the +// stored version to the current one, awaiting each step. Drop support for +// old versions by starting the chain at a higher key (onOlder discards). +const migrate = createMigrationChain<Prefs>({ + version: 3, + steps: { + 0: (s) => ({ ...s, theme: "light" }), + 1: (s) => ({ ...s, filters: [] }), + 2: (s) => ({ ...s, layout: "grid" }), + }, +}); +persistStore(store, { name: "app:prefs:v3", version: 3, storage, migrate }); +``` + ### Wrapping your store Every shipped source adapter is a thin `persistSource` wrapper — import the subpath, pass your store, wire storage. **Naming is shape-based, not library-based** (`persistStore` / `persistAtom` / `persistProxy` / `persistObservable`): same persistable shape → same name → same merge semantics, regardless of library; the subpath carries the library. Importing two same-shape adapters into one module? Alias one: `import { persistStore as persistZustand } from "@stainless-code/persist/sources/zustand"`. Redux, signals, hand-rolled atoms: same seam — pass a custom `PersistableSource` to `persistSource` directly. diff --git a/src/core/persist-core.test.ts b/src/core/persist-core.test.ts index 452bb98..79537c3 100644 --- a/src/core/persist-core.test.ts +++ b/src/core/persist-core.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { MemoryStorage } from "../testing/memory-storage"; import { createJSONStorage, + createMigrationChain, createPersistRegistry, createStorage, identityCodec, @@ -2054,3 +2055,228 @@ describe("PersistRegistry", () => { expect(await jsonStorage.getItem("no-registry")).not.toBeNull(); }); }); + +describe("createMigrationChain", () => { + it("walks every step from the stored version to the current one", async () => { + const migrate = createMigrationChain<{ + count?: number; + theme?: string; + filters?: string[]; + layout?: string; + }>({ + version: 3, + steps: { + 0: (s) => ({ ...s, theme: "light" }), + 1: (s) => ({ ...s, filters: [] }), + 2: (s) => ({ ...s, layout: "grid" }), + }, + }); + // v0 payload → runs all three steps in order. + expect(await migrate({ count: 7 }, 0)).toEqual({ + count: 7, + theme: "light", + filters: [], + layout: "grid", + }); + }); + + it("runs only the steps from the stored version onward (partial walk)", async () => { + const migrate = createMigrationChain<{ + count?: number; + theme?: string; + filters?: string[]; + layout?: string; + }>({ + version: 3, + steps: { + 0: (s) => ({ ...s, theme: "light" }), + 1: (s) => ({ ...s, filters: [] }), + 2: (s) => ({ ...s, layout: "grid" }), + }, + }); + // v2 payload → only steps[2] runs. + expect( + await migrate({ count: 7, theme: "dark", filters: ["x"] }, 2), + ).toEqual({ count: 7, theme: "dark", filters: ["x"], layout: "grid" }); + }); + + it("awaits async steps sequentially", async () => { + const order: number[] = []; + const migrate = createMigrationChain<{ v?: number }>({ + version: 3, + steps: { + 0: async (s) => { + await new Promise((r) => setTimeout(r, 5)); + order.push(0); + return { ...s, v: 1 }; + }, + 1: async (s) => { + order.push(1); + return { ...s, v: 2 }; + }, + 2: async (s) => { + order.push(2); + return { ...s, v: 3 }; + }, + }, + }); + await migrate({}, 0); + expect(order).toEqual([0, 1, 2]); + }); + + it("discards (returns undefined) when the stored version is older than the chain's earliest step", async () => { + const migrate = createMigrationChain<{ x?: number }>({ + version: 3, + steps: { 2: (s) => ({ ...s, x: 1 }) }, // minKey=2 → v0/v1 unsupported + }); + expect(await migrate({ count: 7 }, 0)).toBeUndefined(); + }); + + it("onOlder: 'throw' → migrate throws", async () => { + const migrate = createMigrationChain<{ x?: number }>({ + version: 3, + steps: { 2: (s) => ({ ...s, x: 1 }) }, + onOlder: "throw", + }); + await expect(migrate({}, 0)).rejects.toThrow( + /older than the chain's earliest/, + ); + }); + + it("onNewer: default 'throw' when the stored version is newer than current", async () => { + const migrate = createMigrationChain<{ x?: number }>({ + version: 3, + steps: { 0: (s) => s, 1: (s) => s, 2: (s) => s }, + }); + await expect(migrate({}, 5)).rejects.toThrow(/newer than current 3/); + }); + + it("onNewer: 'discard' → returns undefined", async () => { + const migrate = createMigrationChain<{ x?: number }>({ + version: 3, + steps: { 0: (s) => s, 1: (s) => s, 2: (s) => s }, + onNewer: "discard", + }); + expect(await migrate({}, 5)).toBeUndefined(); + }); + + it("propagates a throwing step", async () => { + const migrate = createMigrationChain<{ x?: number }>({ + version: 2, + steps: { + 0: (s) => s, + 1: () => { + throw new Error("bad step"); + }, + }, + }); + await expect(migrate({}, 0)).rejects.toThrow("bad step"); + }); + + it("eagerly throws at construction on a gap in the covered range", () => { + expect(() => + createMigrationChain<{ x?: number }>({ + version: 3, + steps: { 0: (s) => s, 2: (s) => s }, // missing 1 + }), + ).toThrow(/missing migration step from v1/); + }); + + it("eagerly throws at construction on an out-of-range step key", () => { + expect(() => + createMigrationChain<{ x?: number }>({ + version: 3, + steps: { 0: (s) => s, 1: (s) => s, 2: (s) => s, 3: (s) => s }, // 3 >= version + }), + ).toThrow(/out of range/); + }); + + it("eagerly throws at construction on a non-integer / negative version", () => { + expect(() => createMigrationChain({ version: 2.5, steps: {} })).toThrow( + /non-negative integer/, + ); + expect(() => createMigrationChain({ version: -1, steps: {} })).toThrow( + /non-negative integer/, + ); + }); + + it("end-to-end: a v0 payload hydrates through the chain to the current version", async () => { + const memory = new MemoryStorage(); + const jsonStorage = createJSONStorage<{ + theme?: string; + filters?: string[]; + }>(() => memory)!; + await jsonStorage.setItem("prefs", { + state: { theme: "dark" }, + version: 0, + }); + + const source = createMockSource({ theme: "light", filters: ["default"] }); + const persist = persistSource(source, { + name: "prefs", + version: 2, + storage: jsonStorage, + migrate: createMigrationChain<{ theme?: string; filters?: string[] }>({ + version: 2, + steps: { + 0: (s) => ({ ...s, theme: "light" }), + 1: (s) => ({ ...s, filters: [] }), + }, + }), + }); + await waitForHydration(persist.hasHydrated); + expect(source.state).toEqual({ theme: "light", filters: [] }); + // The migrated state writes back at the current version. + expect((await jsonStorage.getItem("prefs"))?.version).toBe(2); + }); + + it("end-to-end: onOlder discard keeps the initial state (no onError)", async () => { + const memory = new MemoryStorage(); + const jsonStorage = createJSONStorage<{ x?: number }>(() => memory)!; + await jsonStorage.setItem("dropped", { state: { x: 7 }, version: 0 }); + + const errors: Array<{ phase: string }> = []; + const source = createMockSource({ x: 99 }); + const persist = persistSource(source, { + name: "dropped", + version: 3, + storage: jsonStorage, + migrate: createMigrationChain<{ x?: number }>({ + version: 3, + steps: { 2: (s) => s }, // minKey=2 → v0 unsupported + }), + onError: (_e, ctx) => errors.push({ phase: ctx.phase }), + }); + await waitForHydration(persist.hasHydrated); + // Discarded → keeps initial state, no error reported. + expect(source.state).toEqual({ x: 99 }); + expect(errors).toEqual([]); + }); + + it("end-to-end: a throwing step routes to onError phase 'migrate'", async () => { + const memory = new MemoryStorage(); + const jsonStorage = createJSONStorage<{ x?: number }>(() => memory)!; + await jsonStorage.setItem("bad-step", { state: { x: 1 }, version: 0 }); + + const errors: Array<{ phase: string; message: string }> = []; + const source = createMockSource({ x: 0 }); + const persist = persistSource(source, { + name: "bad-step", + version: 2, + storage: jsonStorage, + migrate: createMigrationChain<{ x?: number }>({ + version: 2, + steps: { + 0: (s) => s, + 1: () => { + throw new Error("bad step"); + }, + }, + }), + onError: (e, ctx) => + errors.push({ phase: ctx.phase, message: (e as Error).message }), + }); + await waitForHydration(persist.hasHydrated); + expect(errors).toContainEqual({ phase: "migrate", message: "bad step" }); + }); +}); diff --git a/src/core/persist-core.ts b/src/core/persist-core.ts index 0bb7283..2377a30 100644 --- a/src/core/persist-core.ts +++ b/src/core/persist-core.ts @@ -398,6 +398,121 @@ export function createPersistRegistry(): PersistRegistry { }; } +/** + * Options for {@link createMigrationChain}. + */ +export interface CreateMigrationChainOptions<S> { + /** Current schema version — must equal {@link PersistOptions.version}. */ + version: number; + /** + * Per-version migration steps, keyed by the version each step transforms + * *from*: `steps[N]` takes vN state → v(N+1). The covered range + * `[minKey, version-1]` must be gap-free (validated eagerly at + * construction); `minKey > 0` drops support for older versions. Each + * step may be sync or Promise. + */ + steps: Record<number, (state: S) => S | Promise<S>>; + /** + * Stored payload's version is *newer* than {@link version} (a downgrade). + * `"throw"` (default) → the returned `migrate` throws → persist-core + * routes it to `onError` phase `"migrate"`. `"discard"` → `migrate` + * resolves `undefined` → hydrate keeps the current/initial state. + */ + onNewer?: "discard" | "throw"; + /** + * Stored payload's version is older than the chain's earliest step + * (`minKey > 0` — you dropped support for that version). `"discard"` + * (default) → hydrate keeps the current/initial state. `"throw"` → + * `onError` phase `"migrate"`. + */ + onOlder?: "discard" | "throw"; +} + +/** + * Build a `migrate` callback from a per-version step chain. The returned + * function walks `steps[fromVersion]` → `steps[fromVersion+1]` → … → + * `steps[version-1]`, awaiting each, so a payload at any supported older + * version migrates to the current one. Plug it straight into + * {@link PersistOptions.migrate}. + * + * Beyond TanStack Persist's `buster` (which discards on mismatch) — this + * transforms instead. The chain is validated eagerly at construction: a + * gap in the covered range, an out-of-range key, or a non-integer version + * throws now, not on a payload months later. + * + * @example + * ```ts + * import { createMigrationChain } from "@stainless-code/persist"; + * import { persistStore } from "@stainless-code/persist/sources/tanstack-store"; + * + * const migrate = createMigrationChain<Prefs>({ + * version: 3, + * steps: { + * 0: (s) => ({ ...s, theme: "light" }), + * 1: (s) => ({ ...s, filters: [] }), + * 2: (s) => ({ ...s, layout: "grid" }), + * }, + * }); + * persistStore(store, { name: "app:prefs:v3", version: 3, migrate }); + * ``` + */ +export function createMigrationChain<S>( + options: CreateMigrationChainOptions<S>, +): (state: unknown, fromVersion: number) => Promise<S> { + const { version, steps, onNewer = "throw", onOlder = "discard" } = options; + + if (!Number.isInteger(version) || version < 0) { + throw new Error( + `[createMigrationChain] version must be a non-negative integer, got ${version}`, + ); + } + + const fromKeys = Object.keys(steps) + .map(Number) + .filter((n) => Number.isInteger(n)) + .sort((a, b) => a - b); + for (const from of fromKeys) { + if (from < 0 || from >= version) { + throw new Error( + `[createMigrationChain] step key ${from} is out of range [0, ${version - 1}]`, + ); + } + } + // The covered range is [minKey, version-1]; no gaps within it. + const minKey = fromKeys.length ? fromKeys[0] : 0; + for (let from = minKey; from < version; from++) { + if (typeof steps[from] !== "function") { + throw new Error( + `[createMigrationChain] missing migration step from v${from} (gap in [${minKey}, ${version - 1}])`, + ); + } + } + + return async (state, fromVersion) => { + if (fromVersion > version) { + if (onNewer === "throw") { + throw new Error( + `[createMigrationChain] stored version ${fromVersion} is newer than current ${version} (downgrade not supported)`, + ); + } + return undefined as unknown as S; + } + if (fromVersion < minKey) { + if (onOlder === "throw") { + throw new Error( + `[createMigrationChain] stored version ${fromVersion} is older than the chain's earliest supported v${minKey}`, + ); + } + return undefined as unknown as S; + } + let current = state as S; + for (let from = fromVersion; from < version; from++) { + current = await steps[from](current); + } + return current; + }; +} + /** * JSON codec — no `Set` / `Map` / `Date` round-trip. Accepts the standard * `JSON.parse` reviver / `JSON.stringify` replacer. From 2327e68047ffc12e57d6b7674ef0b0db128b3011 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 19:12:52 +0300 Subject: [PATCH 56/77] =?UTF-8?q?docs(plans):=20strike=20#6=20(migration-c?= =?UTF-8?q?hain)=20=E2=80=94=20shipped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/plans/remaining-roi.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/plans/remaining-roi.md b/docs/plans/remaining-roi.md index 4c9e377..5756fbc 100644 --- a/docs/plans/remaining-roi.md +++ b/docs/plans/remaining-roi.md @@ -63,12 +63,9 @@ Framework adapters mount `HydrationSignal` into each framework's external-store - **Acceptance:** CI `Test (Browser)` job runs Playwright green; `Test (SSR)` job runs a Next.js app green; both gated by `CI complete`. Co-locate fixtures under `tests-browser/` and `tests-ssr/` (outside `bun test ./src`'s scan, like `tests-dom/`). - **Lands:** `.github/workflows/ci.yml` + new test dirs; `docs/architecture.md` § Test matrix updated. No changeset (test-only). -### 6. Migration-chain helper — Tier 4, M +### 6. Migration-chain helper — ✅ shipped -- **What:** a `createMigrationChain({ 0: fn, 1: fn, 2: fn })` helper (in `core/` or a `./codecs/`-style utility subpath) that walks v0→v1→…→current, feeding each step's output to the next, and produces a single `migrate` callback for `persistSource`. -- **Why:** today `migrate` is a single callback receiving the stored version; multi-step v0→v1→v2 chaining is user-written and error-prone (off-by-one on the stored version, skipped steps). -- **Acceptance:** helper ships with tests covering forward chaining + skip-from-older-than-chain + a throwing step; README recipe under "Recipes". Decide core vs subpath: if it stays zero-dep it can live in `core/`; if it needs no peer, a `core/` export is fine (no new subpath). -- **Lands:** `src/core/persist-core.ts` (or a new utility) + JSDoc + README recipe. Changeset: `minor` (new public export). +Shipped in `2e9247f` — `createMigrationChain` in `src/core/persist-core.ts` + README recipe + changeset. Options-bag API (`version` / `steps` / `onNewer` default throw / `onOlder` default discard), eager gap-validation, beyond TanStack `buster` (transforms, not discards). ### 7. React ergonomics layer — Tier 4, M-L @@ -106,7 +103,7 @@ From audit Appendix B.3. Each is a one-line composition over an existing seam; s (#4 is implemented; pending first-release verification — strike once the npm Provenance badge lands.) -1. **#1 (Query bridge) + #6 (migration-chain)** — M each, pure code, high adoption payoff, no deps. Best next pick. +1. **#1 (Query bridge)** — M, pure code, high adoption payoff, no deps. Best next pick. (#6 migration-chain shipped.) 2. **#5 (real-browser + SSR matrix)** — M, de-risks the hydration-critical paths before more surface lands. 3. **#2 (examples/) → #3 (docs site) → #9 (playground)** — the docs/demo arc; sequence so each builds on the prior. 4. **#7 (React ergonomics) + #8 (OPFS/SQLite/Cloudflare)** — strategic; decide ship-vs-recipe per item. From 73a4a42afcd7891136eef51869af4ce39b60bd26 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 19:15:35 +0300 Subject: [PATCH 57/77] docs: authoring-discipline + docs-governance pass on the #6 work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/migration-chain.md | 9 +++++---- README.md | 5 ++--- docs/plans/remaining-roi.md | 6 +----- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/.changeset/migration-chain.md b/.changeset/migration-chain.md index 2a09182..c49497c 100644 --- a/.changeset/migration-chain.md +++ b/.changeset/migration-chain.md @@ -2,8 +2,9 @@ "@stainless-code/persist": minor --- -Add `createMigrationChain` — a zero-dep core helper that builds a `migrate` callback from a per-version step chain. The returned function walks `steps[fromVersion]` → `steps[fromVersion+1]` → … → `steps[version-1]`, awaiting each, so a payload at any supported older version migrates to the current one. Plug it into `PersistOptions.migrate`. +Add `createMigrationChain` — a zero-dep core helper that builds a `migrate` callback from a per-version step chain. Plug into `PersistOptions.migrate`. -- Options bag: `version` (current), `steps` (keyed by the version each step transforms _from_), `onNewer` (default `"throw"` — a downgrade is a bug), `onOlder` (default `"discard"` — you dropped support for that version). -- Eager construction validation: a gap in the covered range, an out-of-range step key, or a non-integer version throws now, not on a payload months later. -- Beyond TanStack Persist's `buster` (which discards on mismatch) — this transforms instead. +- `steps[N]` takes vN → v(N+1); the chain walks from the stored version to `version`, awaiting each. +- `onNewer` (default `"throw"` — a downgrade is a bug) / `onOlder` (default `"discard"` — dropped support for that version). +- Eager construction validation: a gap in the covered range, an out-of-range key, or a non-integer version throws now. +- Beyond TanStack `buster` (discards on mismatch) — transforms instead. diff --git a/README.md b/README.md index a0c7638..9c24dc1 100644 --- a/README.md +++ b/README.md @@ -680,9 +680,8 @@ persistStore(store, { ```ts import { createMigrationChain } from "@stainless-code/persist"; -// Per-version steps: steps[N] takes vN → v(N+1). The chain walks from the -// stored version to the current one, awaiting each step. Drop support for -// old versions by starting the chain at a higher key (onOlder discards). +// steps[N] takes vN → v(N+1). Start at a higher key to drop support for +// old versions (onOlder discards by default). const migrate = createMigrationChain<Prefs>({ version: 3, steps: { diff --git a/docs/plans/remaining-roi.md b/docs/plans/remaining-roi.md index 5756fbc..328f218 100644 --- a/docs/plans/remaining-roi.md +++ b/docs/plans/remaining-roi.md @@ -63,10 +63,6 @@ Framework adapters mount `HydrationSignal` into each framework's external-store - **Acceptance:** CI `Test (Browser)` job runs Playwright green; `Test (SSR)` job runs a Next.js app green; both gated by `CI complete`. Co-locate fixtures under `tests-browser/` and `tests-ssr/` (outside `bun test ./src`'s scan, like `tests-dom/`). - **Lands:** `.github/workflows/ci.yml` + new test dirs; `docs/architecture.md` § Test matrix updated. No changeset (test-only). -### 6. Migration-chain helper — ✅ shipped - -Shipped in `2e9247f` — `createMigrationChain` in `src/core/persist-core.ts` + README recipe + changeset. Options-bag API (`version` / `steps` / `onNewer` default throw / `onOlder` default discard), eager gap-validation, beyond TanStack `buster` (transforms, not discards). - ### 7. React ergonomics layer — Tier 4, M-L - **What:** a `./frameworks/react` ergonomics companion (or a new `./frameworks/react-context` subpath) — `<PersistProvider>` + React context + `usePersisted(store, selector)` selector binding + auto-`destroy()` on unmount. The existing `useHydrated` stays the reference primitive. @@ -103,7 +99,7 @@ From audit Appendix B.3. Each is a one-line composition over an existing seam; s (#4 is implemented; pending first-release verification — strike once the npm Provenance badge lands.) -1. **#1 (Query bridge)** — M, pure code, high adoption payoff, no deps. Best next pick. (#6 migration-chain shipped.) +1. **#1 (Query bridge)** — M, pure code, high adoption payoff, no deps. Best next pick. 2. **#5 (real-browser + SSR matrix)** — M, de-risks the hydration-critical paths before more surface lands. 3. **#2 (examples/) → #3 (docs site) → #9 (playground)** — the docs/demo arc; sequence so each builds on the prior. 4. **#7 (React ergonomics) + #8 (OPFS/SQLite/Cloudflare)** — strategic; decide ship-vs-recipe per item. From cb102e48bc29e07b5e4428de42492c5771d6ab6a Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 19:42:35 +0300 Subject: [PATCH 58/77] ci: fix check:pack + size gates for the new core surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .size-limit.json | 2 +- knip.json | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.size-limit.json b/.size-limit.json index 0296123..554690e 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -2,7 +2,7 @@ { "name": "core", "path": "dist/core/index.mjs", - "limit": "2.5 KB", + "limit": "3 KB", "gzip": true }, { diff --git a/knip.json b/knip.json index da8a285..1ed0366 100644 --- a/knip.json +++ b/knip.json @@ -6,5 +6,7 @@ "vitest.config.ts", "tsdown.config.ts", "lint-staged.config.js" - ] + ], + "ignore": [".codemap/**"], + "ignoreDependencies": ["@stainless-code/codemap"] } From 47d801429f7bbe379a6899fb0cd9e882615464ac Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 19:51:03 +0300 Subject: [PATCH 59/77] ci: build dist/ before check-pack + size (each job is a fresh runner) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf57278..302841b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,6 +140,9 @@ jobs: - name: Setup uses: ./.github/actions/setup + - name: Build + run: bun run build + - name: Run pack validation run: bun run check:pack @@ -155,6 +158,9 @@ jobs: - name: Setup uses: ./.github/actions/setup + - name: Build + run: bun run build + - name: Run size limit run: bun run size From 131b2135176a5107daf94ab6a23f425ecd6a9741 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 20:18:25 +0300 Subject: [PATCH 60/77] =?UTF-8?q?harden:=20full=20review=20pass=201=20?= =?UTF-8?q?=E2=80=94=20correctness,=20doc=20drift,=20public=20API,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .agents/skills/harden-pr/LEDGER.md | 7 +++ .../improve-codebase-architecture/LANGUAGE.md | 2 +- .changeset/rn-storage-adapters.md | 2 +- .changeset/subpath-mirror-folders.md | 11 ++-- .github/CONTRIBUTING.md | 2 +- .github/ISSUE_TEMPLATE/1_bug.yml | 2 +- README.md | 52 +++++++++--------- docs/audits/2026-07-04-docs-adapters-roi.md | 4 +- docs/roadmap.md | 12 ++--- package.json | 1 + src/adapters/backends/async-storage.test.ts | 2 +- src/adapters/backends/async-storage.ts | 8 +-- src/adapters/backends/encrypted.ts | 8 +-- src/adapters/backends/idb.test.ts | 2 +- src/adapters/backends/mmkv.test.ts | 2 +- src/adapters/backends/mmkv.ts | 6 ++- src/adapters/backends/node-fs.ts | 13 ++++- src/adapters/backends/secure-store.test.ts | 2 +- src/adapters/backends/secure-store.ts | 4 +- src/adapters/transport/crosstab.ts | 32 +++++++---- src/core/persist-core.test.ts | 54 ++++++++++++++++++- src/core/persist-core.ts | 27 ++++++---- 22 files changed, 171 insertions(+), 84 deletions(-) diff --git a/.agents/skills/harden-pr/LEDGER.md b/.agents/skills/harden-pr/LEDGER.md index 4c623b8..05be274 100644 --- a/.agents/skills/harden-pr/LEDGER.md +++ b/.agents/skills/harden-pr/LEDGER.md @@ -14,6 +14,8 @@ By-design or false-positive findings — do not re-raise. - **[correctness]** `src/core/persist-core.ts:147` — sync-first read path: by-design — sync backends settle pre-paint; async rides the same getItem Promise branch. --> +- **[docs]** `docs/audits/2026-07-04-docs-adapters-roi.md` — "5 subpath entries" / historical-state counts: by-design — dated audit record (2026-07-04); counts are accurate to the audit date; the doc header carries the date. + ## Deferred Capped or out-of-scope-for-now — reconcile re-vets; remove lines when fixed. @@ -21,3 +23,8 @@ Capped or out-of-scope-for-now — reconcile re-vets; remove lines when fixed. ```markdown - **[severity]** `file:line` — finding (deferred: out of scope | cap | blocked) ``` + +- **[info]** `src/adapters/transport/crosstab.test.ts` — no test for `removeItem` broadcast → receiving-tab `onCrossTabRemove` (deferred: coverage gap, not blocking; the setItem-broadcast path is covered). +- **[nit]** `src/adapters/**/*.ts` `@example` blocks — reference symbols (e.g. `createJSONStorage`) without importing them (deferred: large surface; the `@example` import _paths_ resolve per the repo's stated bar; importing every used symbol is a stricter standard beyond it). +- **[info]** `package.json` peerDependencies — `./frameworks/svelte` declares `svelte >=5.0.0` in source/README but `package.json` has `svelte >=3.0.0` shared with `./frameworks/svelte-store` (deferred: out of bounds — peer deps are package-level, not subpath-level; can't fix without splitting the svelte subpath into a separate package). +- **[info]** `src/adapters/**/*.test.ts` — `createMockSource` + `waitForHydration` still copy-pasted per file (deferred: out of scope for this PR — not a regression; same dedup pattern as the shipped `MemoryStorage` fixture). diff --git a/.agents/skills/improve-codebase-architecture/LANGUAGE.md b/.agents/skills/improve-codebase-architecture/LANGUAGE.md index 9ce415e..2c87a53 100644 --- a/.agents/skills/improve-codebase-architecture/LANGUAGE.md +++ b/.agents/skills/improve-codebase-architecture/LANGUAGE.md @@ -31,7 +31,7 @@ _Examples in this repo_: the three seams in `docs/architecture.md` — **backend **Adapter** A concrete thing that satisfies an interface at a seam. Describes _role_ (what slot it fills), not substance (what's inside). -_Examples in this repo_: each backend (`localStorage` adapter, `idb-keyval` adapter) is an adapter at the `StateStorage<TRaw>` seam; each codec (`persist-seroval`, a JSON passthrough) is an adapter at the `StorageCodec` seam; `useHydrated` is the React adapter at the `HydrationSignal` seam. Two adapters per seam = real seam (sync backend + async backend); one adapter = hypothetical. +_Examples in this repo_: each backend (`localStorage` adapter, `idb-keyval` adapter) is an adapter at the `StateStorage<TRaw>` seam; each codec (`seroval`, a JSON passthrough) is an adapter at the `StorageCodec` seam; `useHydrated` is the React adapter at the `HydrationSignal` seam. Two adapters per seam = real seam (sync backend + async backend); one adapter = hypothetical. **Leverage** What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests. diff --git a/.changeset/rn-storage-adapters.md b/.changeset/rn-storage-adapters.md index 622a53f..4d41c3f 100644 --- a/.changeset/rn-storage-adapters.md +++ b/.changeset/rn-storage-adapters.md @@ -4,7 +4,7 @@ Add three React Native storage subpaths over the `StateStorage` seam, mirroring the `./backends/idb` template (own subpath, optional peer, no cross-entry value imports): -- `./backends/async-storage` (peer `@react-native-async-storage/async-storage >=1.0.0`) — `asyncStorageStateStorage` / `createAsyncStorage`. Fully async, string-wire; `useHydrated` gating mandatory. Accepts a custom instance (`getLegacyStorage()`, `createAsyncStorage(name)`) to namespace. +- `./backends/async-storage` (peer `@react-native-async-storage/async-storage >=1.0.0`) — `asyncStorageStateStorage` / `createAsyncStorage`. Fully async, string-wire; `useHydrated` gating mandatory. Accepts a custom `AsyncStorage` instance (e.g. `getLegacyStorage()`) to namespace. - `./backends/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. Pair `encryptionKey` for secrets-at-rest. - `./backends/secure-store` (peer `expo-secure-store >=12.0.0`) — `secureStoreStateStorage` / `createSecureStoreStorage`. OS keychain/keystore, async, **~2KB value limit per key** — for small secrets (auth tokens), not large state; pair `partialize` to persist a tiny slice. diff --git a/.changeset/subpath-mirror-folders.md b/.changeset/subpath-mirror-folders.md index 31d008d..dd9404b 100644 --- a/.changeset/subpath-mirror-folders.md +++ b/.changeset/subpath-mirror-folders.md @@ -2,18 +2,13 @@ "@stainless-code/persist": minor --- -Reorganize the public subpath namespace to mirror the folder structure (`src/core/` + `src/adapters/<seam>/`). Adapter subpaths are now category-prefixed so the public surface is 1:1 with the source layout — a contributor adding `adapters/backends/opfs.ts` knows the subpath is `./backends/opfs` with zero mental mapping. **Breaking** (early package; consumers must update imports): +Reorganize the public subpath namespace to mirror the folder structure (`src/core/` + `src/adapters/<seam>/`). Adapter subpaths are now category-prefixed so the public surface is 1:1 with the source layout — a contributor adding `adapters/backends/opfs.ts` knows the subpath is `./backends/opfs` with zero mental mapping. **Breaking** (early package; consumers must update imports) — the four subpaths that existed on `main` are renamed: - `./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 rest of the surface (`./codecs/zod`, `./backends/{async-storage,mmkv,secure-store,encrypted,compressed,node-fs}`, `./transport/crosstab`, `./sources/{zustand,jotai,valtio,mobx}`, `./frameworks/{solid,vue,svelte,svelte-store,angular,preact}`) is NEW — added by sibling changesets, not renames. The `.` (core) entry is unchanged. `dist/` mirrors `src/` (`dist/<seam>/<name>.mjs` via tsdown's record-form `entry` keyed by `<seam>/<name>`) — src folder → tsdown key → dist path → subpath, all 1:1. Internal `src/` was refolded into `core/` + `adapters/<seam>/` with the `persist-` filename prefix dropped (folder is the category, file is the subpath basename). diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ef3f622..02dff2c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -46,7 +46,7 @@ Match Oxfmt/Oxlint; prefer **straight-line code** and extracted helpers over lon ### Releases -[@changesets/cli](https://github.com/changesets/changesets) — run **`bun run changeset`** when your PR should bump the version, and commit the `.changeset/*.md` file. The Release workflow opens a "Version packages" PR and publishes to npm on merge (requires `NPM_TOKEN`). +[@changesets/cli](https://github.com/changesets/changesets) — run **`bun run changeset`** when your PR should bump the version, and commit the `.changeset/*.md` file. The Release workflow opens a "Version packages" PR and publishes to npm on merge via trusted publishing (GitHub OIDC; no `NPM_TOKEN`). ### Issues diff --git a/.github/ISSUE_TEMPLATE/1_bug.yml b/.github/ISSUE_TEMPLATE/1_bug.yml index 1f883cf..794c869 100644 --- a/.github/ISSUE_TEMPLATE/1_bug.yml +++ b/.github/ISSUE_TEMPLATE/1_bug.yml @@ -5,7 +5,7 @@ body: - type: markdown attributes: value: | - **Core** means the shared middleware: `persistSource`, the hydration lifecycle, `createStorage` / codecs, shipped backends (`./codecs/seroval`, `./backends/idb`), the TanStack Store adapters, or the React `useHydrated` hook. For a new backend / codec / framework adapter, use **Feature / adapter proposal** instead. + **Core** means the shared middleware: `persistSource`, the hydration lifecycle, `createStorage` / codecs, shipped codecs (`./codecs/seroval`) / backends (`./backends/idb`), the TanStack Store adapters, or the React `useHydrated` hook. For a new backend / codec / framework adapter, use **Feature / adapter proposal** instead. - type: textarea id: summary diff --git a/README.md b/README.md index 9c24dc1..cd60947 100644 --- a/README.md +++ b/README.md @@ -398,31 +398,31 @@ Persistence middleware for any `getState`/`setState`/`subscribe` store (TanStack ## Entry points (one subpath = one optional peer) -| Subpath | Symbols | Optional peer | -| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | -| `@stainless-code/persist` | `persistSource`, `PersistApi`, `createStorage`, `jsonCodec`, `identityCodec`, registry, `HydrationSignal` (`hydration`) | none | -| `@stainless-code/persist/codecs/seroval` | `serovalCodec`, `createSerovalStorage` | `seroval` | -| `@stainless-code/persist/codecs/zod` | `zodCodec`, `createZodStorage` | `zod` | -| `@stainless-code/persist/backends/idb` | `idbStateStorage`, `createIdbStorage` (structured-clone mode) | `idb-keyval` | -| `@stainless-code/persist/backends/async-storage` | `asyncStorageStateStorage`, `createAsyncStorage` | `@react-native-async-storage/async-storage` | -| `@stainless-code/persist/backends/mmkv` | `mmkvStateStorage`, `createMmkvStorage` | `react-native-mmkv` | -| `@stainless-code/persist/backends/secure-store` | `secureStoreStateStorage`, `createSecureStoreStorage` | `expo-secure-store` | -| `@stainless-code/persist/backends/encrypted` | `createEncryptedStorage` (AES-GCM WebCrypto) | none (web global) | -| `@stainless-code/persist/backends/compressed` | `createCompressedStorage` (gzip/deflate/deflate-raw) | none (web global) | -| `@stainless-code/persist/backends/node-fs` | `nodeFsStateStorage` (one file per key) | none (Node built-in) | -| `@stainless-code/persist/transport/crosstab` | `createBroadcastCrossTab` | none (web global) | -| `@stainless-code/persist/sources/tanstack-store` | `persistStore`, `persistAtom` | `@tanstack/store` (types only) | -| `@stainless-code/persist/sources/zustand` | `persistStore` | `zustand` | -| `@stainless-code/persist/sources/jotai` | `persistAtom` | `jotai` | -| `@stainless-code/persist/sources/valtio` | `persistProxy` | `valtio` | -| `@stainless-code/persist/sources/mobx` | `persistObservable` | `mobx` | -| `@stainless-code/persist/frameworks/react` | `useHydrated` React hook | `react` | -| `@stainless-code/persist/frameworks/solid` | `useHydrated` (Solid `Accessor<boolean>`) | `solid-js` | -| `@stainless-code/persist/frameworks/vue` | `useHydrated` (Vue `Ref<boolean>`) | `vue` | -| `@stainless-code/persist/frameworks/svelte` | `hydratedRune` (Svelte 5 runes `current`) | `svelte` (>=5) | -| `@stainless-code/persist/frameworks/svelte-store` | `hydratedStore` (Svelte `Readable<boolean>`) | `svelte` (>=3) | -| `@stainless-code/persist/frameworks/angular` | `useHydrated` (Angular `Signal<boolean>`) | `@angular/core` (>=17) | -| `@stainless-code/persist/frameworks/preact` | `useHydrated` (Preact `{ hydrated: boolean }`) | `preact` (>=10.19) | +| Subpath | Symbols | Optional peer | +| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| `@stainless-code/persist` | `persistSource`, `PersistApi`, `createStorage`, `createJSONStorage`, `createMigrationChain`, `jsonCodec`, `identityCodec`, registry, `HydrationSignal` (`hydration`) | none | +| `@stainless-code/persist/codecs/seroval` | `serovalCodec`, `createSerovalStorage` | `seroval` | +| `@stainless-code/persist/codecs/zod` | `zodCodec`, `createZodStorage` | `zod` | +| `@stainless-code/persist/backends/idb` | `idbStateStorage`, `createIdbStorage` (structured-clone mode) | `idb-keyval` | +| `@stainless-code/persist/backends/async-storage` | `asyncStorageStateStorage`, `createAsyncStorage` | `@react-native-async-storage/async-storage` | +| `@stainless-code/persist/backends/mmkv` | `mmkvStateStorage`, `createMmkvStorage` | `react-native-mmkv` | +| `@stainless-code/persist/backends/secure-store` | `secureStoreStateStorage`, `createSecureStoreStorage` | `expo-secure-store` | +| `@stainless-code/persist/backends/encrypted` | `createEncryptedStorage` (AES-GCM WebCrypto) | none (web global) | +| `@stainless-code/persist/backends/compressed` | `createCompressedStorage` (gzip/deflate/deflate-raw) | none (web global) | +| `@stainless-code/persist/backends/node-fs` | `nodeFsStateStorage` (one file per key) | none (Node built-in) | +| `@stainless-code/persist/transport/crosstab` | `createBroadcastCrossTab` | none (web global) | +| `@stainless-code/persist/sources/tanstack-store` | `persistStore`, `persistAtom` | `@tanstack/store` (types only) | +| `@stainless-code/persist/sources/zustand` | `persistStore` | `zustand` | +| `@stainless-code/persist/sources/jotai` | `persistAtom` | `jotai` | +| `@stainless-code/persist/sources/valtio` | `persistProxy` | `valtio` | +| `@stainless-code/persist/sources/mobx` | `persistObservable` | `mobx` | +| `@stainless-code/persist/frameworks/react` | `useHydrated` React hook | `react` | +| `@stainless-code/persist/frameworks/solid` | `useHydrated` (Solid `Accessor<boolean>`) | `solid-js` | +| `@stainless-code/persist/frameworks/vue` | `useHydrated` (Vue `Ref<boolean>`) | `vue` | +| `@stainless-code/persist/frameworks/svelte` | `hydratedRune` (Svelte 5 runes `current`) | `svelte` (>=5) | +| `@stainless-code/persist/frameworks/svelte-store` | `hydratedStore` (Svelte `Readable<boolean>`) | `svelte` (>=3) | +| `@stainless-code/persist/frameworks/angular` | `useHydrated` (Angular `Signal<boolean>`) | `@angular/core` (>=17) | +| `@stainless-code/persist/frameworks/preact` | `useHydrated` (Preact `{ hydrated: boolean }`) | `preact` (>=10.19) | No barrel — importing a subpath is the dependency opt-in. @@ -857,7 +857,7 @@ Optional peer ranges (frameworks, stores, codecs, backends) live in `package.jso ## FAQ **Why does my UI flash?** -Async backend (IndexedDB, AsyncStorage, SecureStore, Node fs) without a hydration gate. Gate with your framework adapter — [`useHydrated`](#what-does-hydration-aware-mean), `hydratedRune`, or `hydratedStore`. Don't manually defer writes — the middleware already gates them until hydrated; double-gating drops legitimate updates. +Async backend (IndexedDB, AsyncStorage, SecureStore, Node fs) without a hydration gate. Gate with your framework adapter — [`useHydrated`](#writing-a-framework-adapter), `hydratedRune`, or `hydratedStore`. Don't manually defer writes — the middleware already gates them until hydrated; double-gating drops legitimate updates. **IDB cross-tab isn't syncing** IndexedDB fires no `storage` events. Use `@stainless-code/persist/transport/crosstab` (`createBroadcastCrossTab`) as the `crossTabEventTarget` + `bridge.wrap(...)`. See [Cross-tab over IndexedDB](#cross-tab-over-indexeddb). diff --git a/docs/audits/2026-07-04-docs-adapters-roi.md b/docs/audits/2026-07-04-docs-adapters-roi.md index cf0746f..41a02cf 100644 --- a/docs/audits/2026-07-04-docs-adapters-roi.md +++ b/docs/audits/2026-07-04-docs-adapters-roi.md @@ -426,7 +426,7 @@ export interface HydrationSource { ## B.1 Current adapters / entry points -The `exports` map in `package.json` ships 5 subpath entries. Each optional peer is isolated behind its own subpath entry — importing the subpath IS the dep opt-in (enforced by a test at `src/adapters/backends/idb.test.ts:175-199`). +The `exports` map in `package.json` ships 5 subpath entries. Each optional peer is isolated behind its own subpath entry — importing the subpath IS the dep opt-in (enforced by a per-entry dependency-isolation test, e.g. `src/adapters/backends/idb.test.ts`). ### `.` (the core entry) @@ -578,7 +578,7 @@ Adapter contract (`src/core/hydration.ts:14-27`): multiple concurrent subscriber - Inline `@example` JSDoc blocks in each adapter module (`src/adapters/backends/idb.ts:67-72`, `src/adapters/codecs/seroval.ts:29-35`, `src/adapters/sources/tanstack-store.ts:13-22`, `src/adapters/frameworks/react.ts:25-35`). - `README.md` and `docs/architecture.md` prose snippets. -The `package.json:25-29` `files` array ships `dist` + `skills` only — no examples are published either. There is no end-to-end runnable app demonstrating wiring (store + storage + codec + hydration gate) outside the test suite. +The `package.json` `files` array ships `dist` + `skills` only — no examples are published either. There is no end-to-end runnable app demonstrating wiring (store + storage + codec + hydration gate) outside the test suite. ## B.5 `./frameworks/react` entry nuance diff --git a/docs/roadmap.md b/docs/roadmap.md index 1cf674f..d8513d9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,18 +6,18 @@ Forward-looking plans only — **not** a mirror of `src/`. **Doc index:** [READM ## Next -- **Remaining ROI work** — actionable items not yet shipped from the [2026-07-04 audit](./audits/2026-07-04-docs-adapters-roi.md): TanStack Query bridge, `examples/` workspace, docs site, npm provenance, real-browser + SSR test matrix, migration-chain helper, React ergonomics layer, OPFS/SQLite/Cloudflare adapters, playground. Plan: [`plans/remaining-roi.md`](./plans/remaining-roi.md). +- **Remaining ROI work** — actionable items not yet shipped from the [2026-07-04 audit](./audits/2026-07-04-docs-adapters-roi.md): TanStack Query bridge, `examples/` workspace, docs site, real-browser + SSR test matrix, React ergonomics layer, OPFS/SQLite/Cloudflare adapters, playground. Plan: [`plans/remaining-roi.md`](./plans/remaining-roi.md). - **Upstream TanStack Persist collaboration** — pitch the `persistSource` middleware model (structural `PersistableSource` + first-class hydration lifecycle) to the TanStack Persist maintainers as a merge target, after the stainless-code publish stabilises. Draft: [`plans/upstream-tanstack-pitch.md`](./plans/upstream-tanstack-pitch.md). --- ## Strategy -| Layer | Role | -| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Core** (`persist-core` + `hydration`) | Zero-dep middleware: `persistSource`, `createStorage`, codecs, registry, hydration signal. No value imports (gate-test enforced). | -| **Codec / backend subpaths** | Own their optional peer (`seroval`, `idb-keyval`); compose into `createStorage`. | -| **Framework adapters** | One entry per framework (`./sources/tanstack-store`, `./frameworks/react`); each adapter is ~20 lines over `HydrationSignal`. React/Solid/Vue/Svelte shipped. | +| Layer | Role | +| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| **Core** (`persist-core` + `hydration`) | Zero-dep middleware: `persistSource`, `createStorage`, codecs, registry, hydration signal. No value imports (gate-test enforced). | +| **Codec / backend subpaths** | Own their optional peer (`seroval`, `idb-keyval`); compose into `createStorage`. | +| **Framework adapters** | One entry per framework (`./frameworks/<fw>`); each is ~20 lines over `HydrationSignal`. | ## Non-goals (v1) diff --git a/package.json b/package.json index 1b9a214..4e6f039 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "jotai", "localstorage", "middleware", + "migration", "mmkv", "mobx", "persistence", diff --git a/src/adapters/backends/async-storage.test.ts b/src/adapters/backends/async-storage.test.ts index c36f3e6..f097cf3 100644 --- a/src/adapters/backends/async-storage.test.ts +++ b/src/adapters/backends/async-storage.test.ts @@ -60,7 +60,7 @@ function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { }); } -describe("persist-asyncstorage", () => { +describe("createAsyncStorage", () => { beforeEach(() => store.clear()); it("asyncStorageStateStorage maps the async backend", async () => { diff --git a/src/adapters/backends/async-storage.ts b/src/adapters/backends/async-storage.ts index 07d2f47..f64c28e 100644 --- a/src/adapters/backends/async-storage.ts +++ b/src/adapters/backends/async-storage.ts @@ -2,13 +2,13 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import type { AsyncStorage as AsyncStorageInstance } from "@react-native-async-storage/async-storage"; -import type { StateStorage } from "../../core/persist-core"; +import type { PersistStorage, StateStorage } from "../../core/persist-core"; import { createJSONStorage } from "../../core/persist-core"; /** * `StateStorage` over React Native `AsyncStorage` — fully async, string-wire. - * Pass a custom instance (e.g. `getLegacyStorage()`, or `createAsyncStorage(name)`) - * to namespace away from the default; defaults to the module singleton. + * Pass a custom `AsyncStorage` instance (e.g. `getLegacyStorage()`) to + * namespace away from the default; defaults to the module singleton. * * @example * ```ts @@ -38,6 +38,6 @@ export function asyncStorageStateStorage( */ export function createAsyncStorage<S>( storage: AsyncStorageInstance = AsyncStorage, -) { +): PersistStorage<S> | undefined { return createJSONStorage<S>(() => asyncStorageStateStorage(storage)); } diff --git a/src/adapters/backends/encrypted.ts b/src/adapters/backends/encrypted.ts index 8ba6d0c..6521000 100644 --- a/src/adapters/backends/encrypted.ts +++ b/src/adapters/backends/encrypted.ts @@ -9,9 +9,11 @@ export interface CreateEncryptedStorageOptions { /** * AES-GCM encryption over a string-wire `StateStorage` (WebCrypto). Each * stored value is `base64(iv).base64(ciphertext)` (12-byte IV prepended); the - * auth tag means a wrong key or tampered ciphertext throws on decrypt → - * persist-core's corrupt-payload path returns `null` (or `clearCorruptOnFailure` - * removes the key). + * AES-GCM auth tag means a wrong key or tampered ciphertext throws on decrypt. + * That throw surfaces in the backend's async `getItem` → persist-core reports + * it via `onError` phase `"hydrate"` (NOT the corrupt-payload self-heal — + * `clearCorruptOnFailure` only fires when the *codec* throws parsing a + * non-corrupt raw, not when the *backend* rejects reading it). * * A backend **wrapper**, not a sync `StorageCodec`, because `crypto.subtle` * is async — the codec serializes (sync), this encrypts the string (async). diff --git a/src/adapters/backends/idb.test.ts b/src/adapters/backends/idb.test.ts index 9ff53d1..0772913 100644 --- a/src/adapters/backends/idb.test.ts +++ b/src/adapters/backends/idb.test.ts @@ -76,7 +76,7 @@ function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { }); } -describe("persist-idb", () => { +describe("createIdbStorage", () => { beforeEach(() => { defaultStore.clear(); }); diff --git a/src/adapters/backends/mmkv.test.ts b/src/adapters/backends/mmkv.test.ts index 166a3db..e744f5e 100644 --- a/src/adapters/backends/mmkv.test.ts +++ b/src/adapters/backends/mmkv.test.ts @@ -71,7 +71,7 @@ function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { }); } -describe("persist-mmkv", () => { +describe("createMmkvStorage", () => { beforeEach(() => instances.clear()); it("mmkvStateStorage round-trips synchronously", () => { diff --git a/src/adapters/backends/mmkv.ts b/src/adapters/backends/mmkv.ts index f016a4e..ee96630 100644 --- a/src/adapters/backends/mmkv.ts +++ b/src/adapters/backends/mmkv.ts @@ -2,7 +2,7 @@ import { createMMKV } from "react-native-mmkv"; import type { MMKV } from "react-native-mmkv"; -import type { StateStorage } from "../../core/persist-core"; +import type { PersistStorage, StateStorage } from "../../core/persist-core"; import { createJSONStorage } from "../../core/persist-core"; /** @@ -48,7 +48,9 @@ export interface MmkvStorageOptions { * persistStore(store, { name: "app:prefs:v1", storage }); * ``` */ -export function createMmkvStorage<S>(options: MmkvStorageOptions) { +export function createMmkvStorage<S>( + options: MmkvStorageOptions, +): PersistStorage<S> | undefined { const instance = createMMKV({ id: options.id, path: options.path, diff --git a/src/adapters/backends/node-fs.ts b/src/adapters/backends/node-fs.ts index c280ad4..3faba37 100644 --- a/src/adapters/backends/node-fs.ts +++ b/src/adapters/backends/node-fs.ts @@ -25,8 +25,17 @@ export function nodeFsStateStorage( ): StateStorage<string> { const { dir } = options; - const pathFor = (name: string) => - join(dir, name.replace(/[^a-zA-Z0-9._-]/g, "_")); + const pathFor = (name: string) => { + const safe = name.replace(/[^a-zA-Z0-9._-]/g, "_"); + // `.` survives the sanitizer, so `name === ".."` / `"."` would resolve + // outside `dir` (parent / self). Refuse rather than EISDIR at I/O time. + if (safe === ".." || safe === "." || safe === "") { + throw new Error( + `[nodeFsStateStorage] key "${name}" sanitizes to "${safe}" — refusing to resolve outside dir`, + ); + } + return join(dir, safe); + }; return { getItem: async (name) => { diff --git a/src/adapters/backends/secure-store.test.ts b/src/adapters/backends/secure-store.test.ts index aeb7481..d1ad872 100644 --- a/src/adapters/backends/secure-store.test.ts +++ b/src/adapters/backends/secure-store.test.ts @@ -55,7 +55,7 @@ function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { }); } -describe("persist-securestore", () => { +describe("createSecureStoreStorage", () => { beforeEach(() => store.clear()); it("secureStoreStateStorage maps the async backend", async () => { diff --git a/src/adapters/backends/secure-store.ts b/src/adapters/backends/secure-store.ts index da79358..d527dc9 100644 --- a/src/adapters/backends/secure-store.ts +++ b/src/adapters/backends/secure-store.ts @@ -1,7 +1,7 @@ // expo-secure-store backend — peer `expo-secure-store` >=12.0.0. ~2KB/key limit — for small secrets. import * as SecureStore from "expo-secure-store"; -import type { StateStorage } from "../../core/persist-core"; +import type { PersistStorage, StateStorage } from "../../core/persist-core"; import { createJSONStorage } from "../../core/persist-core"; /** @@ -37,6 +37,6 @@ export function secureStoreStateStorage(): StateStorage { * persistStore(store, { name: "auth:token:v1", storage, partialize: (s) => s.token }); * ``` */ -export function createSecureStoreStorage<S>() { +export function createSecureStoreStorage<S>(): PersistStorage<S> | undefined { return createJSONStorage<S>(() => secureStoreStateStorage()); } diff --git a/src/adapters/transport/crosstab.ts b/src/adapters/transport/crosstab.ts index 2cd2139..9169e7d 100644 --- a/src/adapters/transport/crosstab.ts +++ b/src/adapters/transport/crosstab.ts @@ -85,27 +85,39 @@ export function createBroadcastCrossTab<S>( raw: storage.raw, setItem(name, value) { const result = storage.setItem(name, value); - Promise.resolve(result).then(() => { - postMessage({ - key: name, - newValue: 1 as unknown as string, - storageArea: null, + Promise.resolve(result) + .then(() => { + postMessage({ + key: name, + newValue: 1 as unknown as string, + storageArea: null, + }); + }) + .catch(() => { + // write failed — persist-core handles the error via `result`; + // don't broadcast a write that didn't land (and don't leak the rejection). }); - }); return result; }, removeItem(name) { const result = storage.removeItem(name); - Promise.resolve(result).then(() => { - postMessage({ key: name, newValue: null, storageArea: null }); - }); + Promise.resolve(result) + .then(() => { + postMessage({ key: name, newValue: null, storageArea: null }); + }) + .catch(() => { + // remove failed — don't broadcast a removal that didn't land. + }); return result; }, }; }, close() { - channel.close(); + for (const handler of handlerMap.values()) { + channel.removeEventListener("message", handler); + } handlerMap.clear(); + channel.close(); }, }; } diff --git a/src/core/persist-core.test.ts b/src/core/persist-core.test.ts index 79537c3..4e81e15 100644 --- a/src/core/persist-core.test.ts +++ b/src/core/persist-core.test.ts @@ -2188,7 +2188,7 @@ describe("createMigrationChain", () => { version: 3, steps: { 0: (s) => s, 1: (s) => s, 2: (s) => s, 3: (s) => s }, // 3 >= version }), - ).toThrow(/out of range/); + ).toThrow(/step key must be a non-negative integer/); }); it("eagerly throws at construction on a non-integer / negative version", () => { @@ -2279,4 +2279,56 @@ describe("createMigrationChain", () => { await waitForHydration(persist.hasHydrated); expect(errors).toContainEqual({ phase: "migrate", message: "bad step" }); }); + + it("fromVersion === version is a no-op (state returned unchanged)", async () => { + const migrate = createMigrationChain<{ x?: number }>({ + version: 3, + steps: { 0: (s) => s, 1: (s) => s, 2: (s) => s }, + }); + // persist-core only calls migrate on a version mismatch, but the returned + // function must handle the equal-version case (zero iterations) cleanly. + expect(await migrate({ x: 7 }, 3)).toEqual({ x: 7 }); + }); + + it("version: 0 with empty steps constructs and is a no-op", async () => { + const migrate = createMigrationChain<{ x?: number }>({ + version: 0, + steps: {}, + }); + expect(await migrate({ x: 7 }, 0)).toEqual({ x: 7 }); + }); + + it("propagates an async step rejection", async () => { + const migrate = createMigrationChain<{ x?: number }>({ + version: 2, + steps: { + 0: (s) => s, + 1: () => Promise.reject(new Error("async step failed")), + }, + }); + await expect(migrate({}, 0)).rejects.toThrow("async step failed"); + }); + + it("rejects a non-integer step key at construction", () => { + expect(() => + createMigrationChain<{ x?: number }>({ + version: 3, + steps: { + 0: (s) => s, + 1.5: (s) => s, + 2: (s) => s, + } as Record<number, (state: { x?: number }) => { x?: number }>, + }), + ).toThrow(/step key must be a non-negative integer/); + }); + + it("rejects a non-integer stored version at runtime", async () => { + const migrate = createMigrationChain<{ x?: number }>({ + version: 3, + steps: { 0: (s) => s, 1: (s) => s, 2: (s) => s }, + }); + await expect(migrate({}, 2.5)).rejects.toThrow( + /stored version must be an integer/, + ); + }); }); diff --git a/src/core/persist-core.ts b/src/core/persist-core.ts index 2377a30..068f514 100644 --- a/src/core/persist-core.ts +++ b/src/core/persist-core.ts @@ -416,14 +416,16 @@ export interface CreateMigrationChainOptions<S> { * Stored payload's version is *newer* than {@link version} (a downgrade). * `"throw"` (default) → the returned `migrate` throws → persist-core * routes it to `onError` phase `"migrate"`. `"discard"` → `migrate` - * resolves `undefined` → hydrate keeps the current/initial state. + * resolves `undefined` → hydrate keeps the current/initial state and + * writes it back, replacing the stored payload. */ onNewer?: "discard" | "throw"; /** * Stored payload's version is older than the chain's earliest step * (`minKey > 0` — you dropped support for that version). `"discard"` - * (default) → hydrate keeps the current/initial state. `"throw"` → - * `onError` phase `"migrate"`. + * (default) → hydrate keeps the current/initial state and writes it + * back, replacing the stored payload. `"throw"` → `onError` phase + * `"migrate"`. */ onOlder?: "discard" | "throw"; } @@ -467,17 +469,17 @@ export function createMigrationChain<S>( ); } - const fromKeys = Object.keys(steps) - .map(Number) - .filter((n) => Number.isInteger(n)) - .sort((a, b) => a - b); - for (const from of fromKeys) { - if (from < 0 || from >= version) { + const fromKeys: number[] = []; + for (const key of Object.keys(steps)) { + const from = Number(key); + if (!Number.isInteger(from) || from < 0 || from >= version) { throw new Error( - `[createMigrationChain] step key ${from} is out of range [0, ${version - 1}]`, + `[createMigrationChain] step key must be a non-negative integer in [0, ${version - 1}], got "${key}"`, ); } + fromKeys.push(from); } + fromKeys.sort((a, b) => a - b); // The covered range is [minKey, version-1]; no gaps within it. const minKey = fromKeys.length ? fromKeys[0] : 0; for (let from = minKey; from < version; from++) { @@ -489,6 +491,11 @@ export function createMigrationChain<S>( } return async (state, fromVersion) => { + if (!Number.isInteger(fromVersion)) { + throw new Error( + `[createMigrationChain] stored version must be an integer, got ${fromVersion}`, + ); + } if (fromVersion > version) { if (onNewer === "throw") { throw new Error( From d66e00adf174eb646447c3cde51c6e823441845d Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 20:59:05 +0300 Subject: [PATCH 61/77] harden: reconcile 3 deferred LEDGER items (crosstab test, @example imports, test-helper dedup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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. --- .agents/skills/harden-pr/LEDGER.md | 3 - src/adapters/backends/async-storage.test.ts | 40 +------- src/adapters/backends/async-storage.ts | 5 + src/adapters/backends/compressed.test.ts | 45 +-------- src/adapters/backends/compressed.ts | 4 + src/adapters/backends/encrypted.test.ts | 45 +-------- src/adapters/backends/encrypted.ts | 5 + src/adapters/backends/idb.test.ts | 42 +------- src/adapters/backends/mmkv.test.ts | 42 +------- src/adapters/backends/mmkv.ts | 6 ++ src/adapters/backends/node-fs.test.ts | 24 +---- src/adapters/backends/node-fs.ts | 3 + src/adapters/backends/secure-store.test.ts | 40 +------- src/adapters/backends/secure-store.ts | 5 + src/adapters/codecs/seroval.test.ts | 46 +-------- src/adapters/codecs/zod.test.ts | 45 +-------- src/adapters/frameworks/react.test.ts | 45 +-------- src/adapters/sources/jotai.test.ts | 19 +--- src/adapters/sources/mobx.test.ts | 19 +--- src/adapters/sources/tanstack-store.test.ts | 21 +--- src/adapters/sources/valtio.test.ts | 19 +--- src/adapters/sources/zustand.test.ts | 19 +--- src/adapters/transport/crosstab.test.ts | 100 +++++++++++--------- src/adapters/transport/crosstab.ts | 4 + src/core/hydration.test.ts | 45 +-------- src/core/persist-core.test.ts | 45 +-------- src/testing/mock-source.ts | 30 ++++++ src/testing/wait-for-hydration.ts | 28 ++++++ 28 files changed, 178 insertions(+), 616 deletions(-) create mode 100644 src/testing/mock-source.ts create mode 100644 src/testing/wait-for-hydration.ts diff --git a/.agents/skills/harden-pr/LEDGER.md b/.agents/skills/harden-pr/LEDGER.md index 05be274..59f9194 100644 --- a/.agents/skills/harden-pr/LEDGER.md +++ b/.agents/skills/harden-pr/LEDGER.md @@ -24,7 +24,4 @@ Capped or out-of-scope-for-now — reconcile re-vets; remove lines when fixed. - **[severity]** `file:line` — finding (deferred: out of scope | cap | blocked) ``` -- **[info]** `src/adapters/transport/crosstab.test.ts` — no test for `removeItem` broadcast → receiving-tab `onCrossTabRemove` (deferred: coverage gap, not blocking; the setItem-broadcast path is covered). -- **[nit]** `src/adapters/**/*.ts` `@example` blocks — reference symbols (e.g. `createJSONStorage`) without importing them (deferred: large surface; the `@example` import _paths_ resolve per the repo's stated bar; importing every used symbol is a stricter standard beyond it). - **[info]** `package.json` peerDependencies — `./frameworks/svelte` declares `svelte >=5.0.0` in source/README but `package.json` has `svelte >=3.0.0` shared with `./frameworks/svelte-store` (deferred: out of bounds — peer deps are package-level, not subpath-level; can't fix without splitting the svelte subpath into a separate package). -- **[info]** `src/adapters/**/*.test.ts` — `createMockSource` + `waitForHydration` still copy-pasted per file (deferred: out of scope for this PR — not a regression; same dedup pattern as the shipped `MemoryStorage` fixture). diff --git a/src/adapters/backends/async-storage.test.ts b/src/adapters/backends/async-storage.test.ts index f097cf3..5f863c5 100644 --- a/src/adapters/backends/async-storage.test.ts +++ b/src/adapters/backends/async-storage.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { createMockSource } from "../../testing/mock-source"; +import { waitForHydration } from "../../testing/wait-for-hydration"; + // bun has no React Native runtime — fake AsyncStorage with a Map-backed // implementation. The module under test only maps shapes; real RN behavior is // AsyncStorage's concern. @@ -23,43 +26,6 @@ const { asyncStorageStateStorage, createAsyncStorage } = await import("./async-storage"); const { persistSource } = await import("../../core/persist-core"); -function createMockSource<T>(initial: T) { - let state = initial; - const listeners = new Set<() => void>(); - return { - get state() { - return state; - }, - getState: () => state, - setState: (updater: (prev: T) => T) => { - state = updater(state); - listeners.forEach((listener) => listener()); - }, - subscribe: (listener: () => void) => { - listeners.add(listener); - return { unsubscribe: () => listeners.delete(listener) }; - }, - }; -} - -function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { - return new Promise<void>((resolve, reject) => { - let ticks = 0; - const tick = () => { - if (hasHydrated()) { - resolve(); - return; - } - if (++ticks > maxTicks) { - reject(new Error("waitForHydration: never hydrated")); - return; - } - queueMicrotask(tick); - }; - tick(); - }); -} - describe("createAsyncStorage", () => { beforeEach(() => store.clear()); diff --git a/src/adapters/backends/async-storage.ts b/src/adapters/backends/async-storage.ts index f64c28e..0455f60 100644 --- a/src/adapters/backends/async-storage.ts +++ b/src/adapters/backends/async-storage.ts @@ -12,6 +12,8 @@ import { createJSONStorage } from "../../core/persist-core"; * * @example * ```ts + * import { asyncStorageStateStorage } from "@stainless-code/persist/backends/async-storage"; + * * const storage = asyncStorageStateStorage(); * ``` */ @@ -32,6 +34,9 @@ export function asyncStorageStateStorage( * * @example * ```ts + * import { createAsyncStorage } from "@stainless-code/persist/backends/async-storage"; + * import { persistStore } from "@stainless-code/persist/sources/tanstack-store"; + * * const storage = createAsyncStorage<Prefs>()!; * persistStore(store, { name: "app:prefs:v1", storage }); * ``` diff --git a/src/adapters/backends/compressed.test.ts b/src/adapters/backends/compressed.test.ts index e663dee..d3c3620 100644 --- a/src/adapters/backends/compressed.test.ts +++ b/src/adapters/backends/compressed.test.ts @@ -1,53 +1,12 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { createStorage, persistSource } from "../../core/persist-core"; -import type { PersistableSource } from "../../core/persist-core"; import { MemoryStorage } from "../../testing/memory-storage"; +import { createMockSource } from "../../testing/mock-source"; +import { waitForHydration } from "../../testing/wait-for-hydration"; import { serovalCodec } from "../codecs/seroval"; import { createCompressedStorage } from "./compressed"; -function createMockSource<T>(initial: T): PersistableSource<T> & { state: T } { - let state = initial; - const listeners = new Set<() => void>(); - - return { - get state() { - return state; - }, - getState: () => state, - setState: (updater) => { - state = updater(state); - listeners.forEach((listener) => listener()); - }, - subscribe: (listener) => { - listeners.add(listener); - return { - unsubscribe: () => listeners.delete(listener), - }; - }, - }; -} - -function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { - return new Promise<void>((resolve, reject) => { - let ticks = 0; - const tick = () => { - if (hasHydrated()) { - resolve(); - return; - } - // Bounded: a hydration regression fails loudly here instead of hanging - // the suite until the runner's opaque timeout. - if (++ticks > maxTicks) { - reject(new Error("waitForHydration: never hydrated")); - return; - } - queueMicrotask(tick); - }; - tick(); - }); -} - describe("createCompressedStorage", () => { let memory: MemoryStorage; diff --git a/src/adapters/backends/compressed.ts b/src/adapters/backends/compressed.ts index 852aedf..5e5ab4d 100644 --- a/src/adapters/backends/compressed.ts +++ b/src/adapters/backends/compressed.ts @@ -20,6 +20,10 @@ export interface CreateCompressedStorageOptions { * * @example * ```ts + * import { createStorage } from "@stainless-code/persist"; + * import { createCompressedStorage } from "@stainless-code/persist/backends/compressed"; + * import { serovalCodec } from "@stainless-code/persist/codecs/seroval"; + * * const storage = createStorage<Prefs>( * () => createCompressedStorage(() => localStorage)!, * serovalCodec(), diff --git a/src/adapters/backends/encrypted.test.ts b/src/adapters/backends/encrypted.test.ts index 663ec61..ecc225e 100644 --- a/src/adapters/backends/encrypted.test.ts +++ b/src/adapters/backends/encrypted.test.ts @@ -1,53 +1,12 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { createStorage, persistSource } from "../../core/persist-core"; -import type { PersistableSource } from "../../core/persist-core"; import { MemoryStorage } from "../../testing/memory-storage"; +import { createMockSource } from "../../testing/mock-source"; +import { waitForHydration } from "../../testing/wait-for-hydration"; import { serovalCodec } from "../codecs/seroval"; import { createEncryptedStorage } from "./encrypted"; -function createMockSource<T>(initial: T): PersistableSource<T> & { state: T } { - let state = initial; - const listeners = new Set<() => void>(); - - return { - get state() { - return state; - }, - getState: () => state, - setState: (updater) => { - state = updater(state); - listeners.forEach((listener) => listener()); - }, - subscribe: (listener) => { - listeners.add(listener); - return { - unsubscribe: () => listeners.delete(listener), - }; - }, - }; -} - -function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { - return new Promise<void>((resolve, reject) => { - let ticks = 0; - const tick = () => { - if (hasHydrated()) { - resolve(); - return; - } - // Bounded: a hydration regression fails loudly here instead of hanging - // the suite until the runner's opaque timeout. - if (++ticks > maxTicks) { - reject(new Error("waitForHydration: never hydrated")); - return; - } - queueMicrotask(tick); - }; - tick(); - }); -} - async function makeKey(): Promise<CryptoKey> { return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [ "encrypt", diff --git a/src/adapters/backends/encrypted.ts b/src/adapters/backends/encrypted.ts index 6521000..da49fe9 100644 --- a/src/adapters/backends/encrypted.ts +++ b/src/adapters/backends/encrypted.ts @@ -22,6 +22,11 @@ export interface CreateEncryptedStorageOptions { * * @example * ```ts + * import { createStorage } from "@stainless-code/persist"; + * import { createEncryptedStorage } from "@stainless-code/persist/backends/encrypted"; + * import { serovalCodec } from "@stainless-code/persist/codecs/seroval"; + * import { persistStore } from "@stainless-code/persist/sources/tanstack-store"; + * * const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]); * const storage = createStorage<Prefs>( * () => createEncryptedStorage(() => localStorage, { key })!, diff --git a/src/adapters/backends/idb.test.ts b/src/adapters/backends/idb.test.ts index 0772913..b07a9b4 100644 --- a/src/adapters/backends/idb.test.ts +++ b/src/adapters/backends/idb.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { createMockSource } from "../../testing/mock-source"; +import { waitForHydration } from "../../testing/wait-for-hydration"; + // bun has no IndexedDB — fake idb-keyval with a Map-backed implementation // honoring the optional per-call `store` param (idb-keyval's own seam). The // module under test only maps shapes; the real IDB behavior is idb-keyval's @@ -37,45 +40,6 @@ const { createStorage, persistSource } = await import("../../core/persist-core"); const { serovalCodec } = await import("../codecs/seroval"); -function createMockSource<T>(initial: T) { - let state = initial; - const listeners = new Set<() => void>(); - return { - get state() { - return state; - }, - getState: () => state, - setState: (updater: (prev: T) => T) => { - state = updater(state); - listeners.forEach((listener) => listener()); - }, - subscribe: (listener: () => void) => { - listeners.add(listener); - return { unsubscribe: () => listeners.delete(listener) }; - }, - }; -} - -function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { - return new Promise<void>((resolve, reject) => { - let ticks = 0; - const tick = () => { - if (hasHydrated()) { - resolve(); - return; - } - // Bounded: a hydration regression fails loudly here instead of hanging - // the suite until the runner's opaque timeout. - if (++ticks > maxTicks) { - reject(new Error("waitForHydration: never hydrated")); - return; - } - queueMicrotask(tick); - }; - tick(); - }); -} - describe("createIdbStorage", () => { beforeEach(() => { defaultStore.clear(); diff --git a/src/adapters/backends/mmkv.test.ts b/src/adapters/backends/mmkv.test.ts index e744f5e..98795a6 100644 --- a/src/adapters/backends/mmkv.test.ts +++ b/src/adapters/backends/mmkv.test.ts @@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; import type { MMKV } from "react-native-mmkv"; +import { createMockSource } from "../../testing/mock-source"; +import { waitForHydration } from "../../testing/wait-for-hydration"; + // Fake MMKV instance backed by a Map; createMMKV returns one per id. Cast to // `MMKV` — the adapter only calls `getString`/`set`/`remove`; the HybridObject // base props (`name`/`equals`/`dispose`/…) are unused in tests. @@ -32,45 +35,6 @@ mock.module("react-native-mmkv", () => ({ const { mmkvStateStorage, createMmkvStorage } = await import("./mmkv"); const { persistSource } = await import("../../core/persist-core"); -function createMockSource<T>(initial: T) { - let state = initial; - const listeners = new Set<() => void>(); - return { - get state() { - return state; - }, - getState: () => state, - setState: (updater: (prev: T) => T) => { - state = updater(state); - listeners.forEach((listener) => listener()); - }, - subscribe: (listener: () => void) => { - listeners.add(listener); - return { unsubscribe: () => listeners.delete(listener) }; - }, - }; -} - -function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { - return new Promise<void>((resolve, reject) => { - let ticks = 0; - const tick = () => { - if (hasHydrated()) { - resolve(); - return; - } - // Bounded: a hydration regression fails loudly here instead of hanging - // the suite until the runner's opaque timeout. - if (++ticks > maxTicks) { - reject(new Error("waitForHydration: never hydrated")); - return; - } - queueMicrotask(tick); - }; - tick(); - }); -} - describe("createMmkvStorage", () => { beforeEach(() => instances.clear()); diff --git a/src/adapters/backends/mmkv.ts b/src/adapters/backends/mmkv.ts index ee96630..716b3db 100644 --- a/src/adapters/backends/mmkv.ts +++ b/src/adapters/backends/mmkv.ts @@ -11,6 +11,9 @@ import { createJSONStorage } from "../../core/persist-core"; * * @example * ```ts + * import { createMMKV } from "react-native-mmkv"; + * import { mmkvStateStorage } from "@stainless-code/persist/backends/mmkv"; + * * const instance = createMMKV({ id: "app-prefs" }); * const storage = mmkvStateStorage(instance); * ``` @@ -44,6 +47,9 @@ export interface MmkvStorageOptions { * * @example * ```ts + * import { createMmkvStorage } from "@stainless-code/persist/backends/mmkv"; + * import { persistStore } from "@stainless-code/persist/sources/tanstack-store"; + * * const storage = createMmkvStorage<Prefs>({ id: "app-prefs" })!; * persistStore(store, { name: "app:prefs:v1", storage }); * ``` diff --git a/src/adapters/backends/node-fs.test.ts b/src/adapters/backends/node-fs.test.ts index ba450e3..37753b7 100644 --- a/src/adapters/backends/node-fs.test.ts +++ b/src/adapters/backends/node-fs.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createStorage, persistSource } from "../../core/persist-core"; -import type { PersistableSource } from "../../core/persist-core"; +import { createMockSource } from "../../testing/mock-source"; import { serovalCodec } from "../codecs/seroval"; import { nodeFsStateStorage } from "./node-fs"; @@ -12,28 +12,6 @@ async function tempDir(): Promise<string> { return mkdtemp(join(tmpdir(), "persist-nodefs-")); } -function createMockSource<T>(initial: T): PersistableSource<T> & { state: T } { - let state = initial; - const listeners = new Set<() => void>(); - - return { - get state() { - return state; - }, - getState: () => state, - setState: (updater) => { - state = updater(state); - listeners.forEach((listener) => listener()); - }, - subscribe: (listener) => { - listeners.add(listener); - return { - unsubscribe: () => listeners.delete(listener), - }; - }, - }; -} - function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { return new Promise<void>((resolve, reject) => { let ticks = 0; diff --git a/src/adapters/backends/node-fs.ts b/src/adapters/backends/node-fs.ts index 3faba37..e911807 100644 --- a/src/adapters/backends/node-fs.ts +++ b/src/adapters/backends/node-fs.ts @@ -16,7 +16,10 @@ export interface NodeFsStorageOptions { * * @example * ```ts + * import { createStorage } from "@stainless-code/persist"; + * import { serovalCodec } from "@stainless-code/persist/codecs/seroval"; * import { nodeFsStateStorage } from "@stainless-code/persist/backends/node-fs"; + * * const storage = createStorage<Prefs>(() => nodeFsStateStorage({ dir: "./.persist" }), serovalCodec()); * ``` */ diff --git a/src/adapters/backends/secure-store.test.ts b/src/adapters/backends/secure-store.test.ts index d1ad872..e837624 100644 --- a/src/adapters/backends/secure-store.test.ts +++ b/src/adapters/backends/secure-store.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { createMockSource } from "../../testing/mock-source"; +import { waitForHydration } from "../../testing/wait-for-hydration"; + const store = new Map<string, string>(); mock.module("expo-secure-store", () => ({ @@ -18,43 +21,6 @@ const { secureStoreStateStorage, createSecureStoreStorage } = await import("./secure-store"); const { persistSource } = await import("../../core/persist-core"); -function createMockSource<T>(initial: T) { - let state = initial; - const listeners = new Set<() => void>(); - return { - get state() { - return state; - }, - getState: () => state, - setState: (updater: (prev: T) => T) => { - state = updater(state); - listeners.forEach((listener) => listener()); - }, - subscribe: (listener: () => void) => { - listeners.add(listener); - return { unsubscribe: () => listeners.delete(listener) }; - }, - }; -} - -function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { - return new Promise<void>((resolve, reject) => { - let ticks = 0; - const tick = () => { - if (hasHydrated()) { - resolve(); - return; - } - if (++ticks > maxTicks) { - reject(new Error("waitForHydration: never hydrated")); - return; - } - queueMicrotask(tick); - }; - tick(); - }); -} - describe("createSecureStoreStorage", () => { beforeEach(() => store.clear()); diff --git a/src/adapters/backends/secure-store.ts b/src/adapters/backends/secure-store.ts index d527dc9..1c7855f 100644 --- a/src/adapters/backends/secure-store.ts +++ b/src/adapters/backends/secure-store.ts @@ -14,6 +14,8 @@ import { createJSONStorage } from "../../core/persist-core"; * * @example * ```ts + * import { secureStoreStateStorage } from "@stainless-code/persist/backends/secure-store"; + * * const storage = secureStoreStateStorage(); * ``` */ @@ -33,6 +35,9 @@ export function secureStoreStateStorage(): StateStorage { * * @example * ```ts + * import { createSecureStoreStorage } from "@stainless-code/persist/backends/secure-store"; + * import { persistStore } from "@stainless-code/persist/sources/tanstack-store"; + * * const storage = createSecureStoreStorage<AuthToken>()!; * persistStore(store, { name: "auth:token:v1", storage, partialize: (s) => s.token }); * ``` diff --git a/src/adapters/codecs/seroval.test.ts b/src/adapters/codecs/seroval.test.ts index 11b94da..712b34b 100644 --- a/src/adapters/codecs/seroval.test.ts +++ b/src/adapters/codecs/seroval.test.ts @@ -1,52 +1,12 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { createStorage, persistSource } from "../../core/persist-core"; -import type { PersistableSource, StateStorage } from "../../core/persist-core"; +import type { StateStorage } from "../../core/persist-core"; import { MemoryStorage } from "../../testing/memory-storage"; +import { createMockSource } from "../../testing/mock-source"; +import { waitForHydration } from "../../testing/wait-for-hydration"; import { createSerovalStorage, serovalCodec } from "./seroval"; -function createMockSource<T>(initial: T): PersistableSource<T> & { state: T } { - let state = initial; - const listeners = new Set<() => void>(); - - return { - get state() { - return state; - }, - getState: () => state, - setState: (updater) => { - state = updater(state); - listeners.forEach((listener) => listener()); - }, - subscribe: (listener) => { - listeners.add(listener); - return { - unsubscribe: () => listeners.delete(listener), - }; - }, - }; -} - -function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { - return new Promise<void>((resolve, reject) => { - let ticks = 0; - const tick = () => { - if (hasHydrated()) { - resolve(); - return; - } - // Bounded: a hydration regression fails loudly here instead of hanging - // the suite until the runner's opaque timeout. - if (++ticks > maxTicks) { - reject(new Error("waitForHydration: never hydrated")); - return; - } - queueMicrotask(tick); - }; - tick(); - }); -} - describe("createSerovalStorage", () => { let memory: MemoryStorage; diff --git a/src/adapters/codecs/zod.test.ts b/src/adapters/codecs/zod.test.ts index 3b42fd2..ec7b306 100644 --- a/src/adapters/codecs/zod.test.ts +++ b/src/adapters/codecs/zod.test.ts @@ -3,52 +3,11 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { z } from "zod"; import { createStorage, persistSource } from "../../core/persist-core"; -import type { PersistableSource } from "../../core/persist-core"; import { MemoryStorage } from "../../testing/memory-storage"; +import { createMockSource } from "../../testing/mock-source"; +import { waitForHydration } from "../../testing/wait-for-hydration"; import { createZodStorage, zodCodec } from "./zod"; -function createMockSource<T>(initial: T): PersistableSource<T> & { state: T } { - let state = initial; - const listeners = new Set<() => void>(); - - return { - get state() { - return state; - }, - getState: () => state, - setState: (updater) => { - state = updater(state); - listeners.forEach((listener) => listener()); - }, - subscribe: (listener) => { - listeners.add(listener); - return { - unsubscribe: () => listeners.delete(listener), - }; - }, - }; -} - -function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { - return new Promise<void>((resolve, reject) => { - let ticks = 0; - const tick = () => { - if (hasHydrated()) { - resolve(); - return; - } - // Bounded: a hydration regression fails loudly here instead of hanging - // the suite until the runner's opaque timeout. - if (++ticks > maxTicks) { - reject(new Error("waitForHydration: never hydrated")); - return; - } - queueMicrotask(tick); - }; - tick(); - }); -} - describe("zodCodec", () => { let memory: MemoryStorage; diff --git a/src/adapters/frameworks/react.test.ts b/src/adapters/frameworks/react.test.ts index b9456fb..434a865 100644 --- a/src/adapters/frameworks/react.test.ts +++ b/src/adapters/frameworks/react.test.ts @@ -5,8 +5,9 @@ import { renderToString } from "react-dom/server"; import { alwaysHydratedSignal, toHydrationSignal } from "../../core/hydration"; import { createJSONStorage, persistSource } from "../../core/persist-core"; -import type { PersistableSource } from "../../core/persist-core"; import { MemoryStorage } from "../../testing/memory-storage"; +import { createMockSource } from "../../testing/mock-source"; +import { waitForHydration } from "../../testing/wait-for-hydration"; import { useHydrated } from "./react"; /** @@ -23,48 +24,6 @@ import { useHydrated } from "./react"; * subscribe/notify contract that wiring rides on. */ -function createMockSource<T>(initial: T): PersistableSource<T> & { state: T } { - let state = initial; - const listeners = new Set<() => void>(); - - return { - get state() { - return state; - }, - getState: () => state, - setState: (updater) => { - state = updater(state); - listeners.forEach((listener) => listener()); - }, - subscribe: (listener) => { - listeners.add(listener); - return { - unsubscribe: () => listeners.delete(listener), - }; - }, - }; -} - -function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { - return new Promise<void>((resolve, reject) => { - let ticks = 0; - const tick = () => { - if (hasHydrated()) { - resolve(); - return; - } - // Bounded: a hydration regression fails loudly here instead of hanging - // the suite until the runner's opaque timeout. - if (++ticks > maxTicks) { - reject(new Error("waitForHydration: never hydrated")); - return; - } - queueMicrotask(tick); - }; - tick(); - }); -} - function Probe({ signal }: { signal: Parameters<typeof useHydrated>[0] }) { const { hydrated } = useHydrated(signal); return React.createElement("span", null, String(hydrated)); diff --git a/src/adapters/sources/jotai.test.ts b/src/adapters/sources/jotai.test.ts index 7bce931..f3c3dbe 100644 --- a/src/adapters/sources/jotai.test.ts +++ b/src/adapters/sources/jotai.test.ts @@ -4,6 +4,7 @@ import type { WritableAtom } from "jotai"; import { createJSONStorage } from "../../core/persist-core"; import { MemoryStorage } from "../../testing/memory-storage"; +import { waitForHydration } from "../../testing/wait-for-hydration"; import { persistAtom } from "./jotai"; function createMockJotaiStore<T>(initialValue: T) { @@ -38,24 +39,6 @@ function createMockJotaiStore<T>(initialValue: T) { }; } -function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { - return new Promise<void>((resolve, reject) => { - let ticks = 0; - const tick = () => { - if (hasHydrated()) { - resolve(); - return; - } - if (++ticks > maxTicks) { - reject(new Error("waitForHydration: never hydrated")); - return; - } - queueMicrotask(tick); - }; - tick(); - }); -} - describe("persistAtom", () => { let memory: MemoryStorage; diff --git a/src/adapters/sources/mobx.test.ts b/src/adapters/sources/mobx.test.ts index 30dfed6..3b35976 100644 --- a/src/adapters/sources/mobx.test.ts +++ b/src/adapters/sources/mobx.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; import { MemoryStorage } from "../../testing/memory-storage"; +import { waitForHydration } from "../../testing/wait-for-hydration"; const observableMap = new WeakMap<object, Set<() => void>>(); @@ -40,24 +41,6 @@ function listenerCount(observable: object) { return observableMap.get(observable)?.size ?? 0; } -function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { - return new Promise<void>((resolve, reject) => { - let ticks = 0; - const tick = () => { - if (hasHydrated()) { - resolve(); - return; - } - if (++ticks > maxTicks) { - reject(new Error("waitForHydration: never hydrated")); - return; - } - queueMicrotask(tick); - }; - tick(); - }); -} - describe("persistObservable", () => { let memory: MemoryStorage; diff --git a/src/adapters/sources/tanstack-store.test.ts b/src/adapters/sources/tanstack-store.test.ts index 1bbe737..2c19f64 100644 --- a/src/adapters/sources/tanstack-store.test.ts +++ b/src/adapters/sources/tanstack-store.test.ts @@ -5,28 +5,9 @@ import type { Atom } from "@tanstack/store"; import { createJSONStorage } from "../../core/persist-core"; import { MemoryStorage } from "../../testing/memory-storage"; +import { waitForHydration } from "../../testing/wait-for-hydration"; import { persistAtom, persistStore } from "./tanstack-store"; -function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { - return new Promise<void>((resolve, reject) => { - let ticks = 0; - const tick = () => { - if (hasHydrated()) { - resolve(); - return; - } - // Bounded: a hydration regression fails loudly here instead of hanging - // the suite until the runner's opaque timeout. - if (++ticks > maxTicks) { - reject(new Error("waitForHydration: never hydrated")); - return; - } - queueMicrotask(tick); - }; - tick(); - }); -} - describe("persistStore", () => { let memory: MemoryStorage; diff --git a/src/adapters/sources/valtio.test.ts b/src/adapters/sources/valtio.test.ts index 6a89e8f..60808e9 100644 --- a/src/adapters/sources/valtio.test.ts +++ b/src/adapters/sources/valtio.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; import { MemoryStorage } from "../../testing/memory-storage"; +import { waitForHydration } from "../../testing/wait-for-hydration"; type MockProxy<T extends object> = T & { __listeners: Set<() => void>; @@ -43,24 +44,6 @@ function createMockProxy<T extends object>(initial: T): MockProxy<T> { return proxy; } -function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { - return new Promise<void>((resolve, reject) => { - let ticks = 0; - const tick = () => { - if (hasHydrated()) { - resolve(); - return; - } - if (++ticks > maxTicks) { - reject(new Error("waitForHydration: never hydrated")); - return; - } - queueMicrotask(tick); - }; - tick(); - }); -} - describe("persistProxy", () => { let memory: MemoryStorage; diff --git a/src/adapters/sources/zustand.test.ts b/src/adapters/sources/zustand.test.ts index 29c4943..1d28e1b 100644 --- a/src/adapters/sources/zustand.test.ts +++ b/src/adapters/sources/zustand.test.ts @@ -4,6 +4,7 @@ import type { StoreApi } from "zustand"; import { createJSONStorage } from "../../core/persist-core"; import { MemoryStorage } from "../../testing/memory-storage"; +import { waitForHydration } from "../../testing/wait-for-hydration"; import { persistStore } from "./zustand"; function createMockStore<T>(initial: T) { @@ -27,24 +28,6 @@ function createMockStore<T>(initial: T) { }; } -function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { - return new Promise<void>((resolve, reject) => { - let ticks = 0; - const tick = () => { - if (hasHydrated()) { - resolve(); - return; - } - if (++ticks > maxTicks) { - reject(new Error("waitForHydration: never hydrated")); - return; - } - queueMicrotask(tick); - }; - tick(); - }); -} - describe("persistStore", () => { let memory: MemoryStorage; diff --git a/src/adapters/transport/crosstab.test.ts b/src/adapters/transport/crosstab.test.ts index b7768ef..f16d5e3 100644 --- a/src/adapters/transport/crosstab.test.ts +++ b/src/adapters/transport/crosstab.test.ts @@ -1,11 +1,9 @@ import { describe, expect, it } from "bun:test"; import { persistSource } from "../../core/persist-core"; -import type { - PersistableSource, - PersistStorage, - StorageValue, -} from "../../core/persist-core"; +import type { PersistStorage, StorageValue } from "../../core/persist-core"; +import { createMockSource } from "../../testing/mock-source"; +import { waitForHydration } from "../../testing/wait-for-hydration"; import { createBroadcastCrossTab } from "./crosstab"; function createSharedAsyncStorage<S>( @@ -25,46 +23,6 @@ function createSharedAsyncStorage<S>( }; } -function createMockSource<T>(initial: T): PersistableSource<T> & { state: T } { - let state = initial; - const listeners = new Set<() => void>(); - - return { - get state() { - return state; - }, - getState: () => state, - setState: (updater) => { - state = updater(state); - listeners.forEach((listener) => listener()); - }, - subscribe: (listener) => { - listeners.add(listener); - return { - unsubscribe: () => listeners.delete(listener), - }; - }, - }; -} - -function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { - return new Promise<void>((resolve, reject) => { - let ticks = 0; - const tick = () => { - if (hasHydrated()) { - resolve(); - return; - } - if (++ticks > maxTicks) { - reject(new Error("waitForHydration: never hydrated")); - return; - } - queueMicrotask(tick); - }; - tick(); - }); -} - function waitForState<T>( getState: () => T, expected: T, @@ -145,6 +103,58 @@ describe("createBroadcastCrossTab", () => { bridgeB.close(); }); + it("removeItem broadcast → receiving-tab onCrossTabRemove", async () => { + const shared = new Map<string, StorageValue<{ count: number }>>(); + const channelName = `remove-${Math.random()}`; + + const bridgeA = createBroadcastCrossTab<{ count: number }>({ + channelName, + })!; + const bridgeB = createBroadcastCrossTab<{ count: number }>({ + channelName, + })!; + + const sourceA = createMockSource({ count: 0 }); + const sourceB = createMockSource({ count: 0 }); + + const persistA = persistSource(sourceA, { + name: "remove-key", + storage: bridgeA.wrap(createSharedAsyncStorage(shared)), + crossTab: true, + crossTabEventTarget: bridgeA.crossTabEventTarget, + }); + + let removeCalls = 0; + const persistB = persistSource(sourceB, { + name: "remove-key", + storage: bridgeB.wrap(createSharedAsyncStorage(shared)), + skipHydration: true, + crossTab: true, + crossTabEventTarget: bridgeB.crossTabEventTarget, + onCrossTabRemove: () => { + removeCalls++; + sourceB.setState(() => ({ count: 0 })); + }, + }); + + await waitForHydration(persistA.hasHydrated); + // Seed a non-default value so the removal is meaningful. + sourceA.setState(() => ({ count: 7 })); + await new Promise((resolve) => setTimeout(resolve, 0)); + await waitForHydration(persistB.hasHydrated); + + // Tab A clears → bridge posts newValue:null → tab B's onCrossTabRemove fires. + await persistA.clearStorage(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(removeCalls).toBe(1); + + persistA.destroy(); + persistB.destroy(); + bridgeA.close(); + bridgeB.close(); + }); + it("wrap preserves raw", () => { const sentinel = {}; const storage: PersistStorage<{ x: number }> = { diff --git a/src/adapters/transport/crosstab.ts b/src/adapters/transport/crosstab.ts index 9169e7d..5bc48f6 100644 --- a/src/adapters/transport/crosstab.ts +++ b/src/adapters/transport/crosstab.ts @@ -29,6 +29,10 @@ export interface BroadcastCrossTab<S> { * * @example * ```ts + * import { createBroadcastCrossTab } from "@stainless-code/persist/transport/crosstab"; + * import { createIdbStorage } from "@stainless-code/persist/backends/idb"; + * import { persistStore } from "@stainless-code/persist/sources/tanstack-store"; + * * const bridge = createBroadcastCrossTab({ channelName: "app:prefs" })!; * const persist = persistStore(store, { * name: "app:prefs:v1", diff --git a/src/core/hydration.test.ts b/src/core/hydration.test.ts index 481f755..9966166 100644 --- a/src/core/hydration.test.ts +++ b/src/core/hydration.test.ts @@ -1,52 +1,11 @@ import { describe, expect, it } from "bun:test"; import { MemoryStorage } from "../testing/memory-storage"; +import { createMockSource } from "../testing/mock-source"; +import { waitForHydration } from "../testing/wait-for-hydration"; import { alwaysHydratedSignal, toHydrationSignal } from "./hydration"; import type { HydrationSource } from "./hydration"; import { createJSONStorage, persistSource } from "./persist-core"; -import type { PersistableSource } from "./persist-core"; - -function createMockSource<T>(initial: T): PersistableSource<T> & { state: T } { - let state = initial; - const listeners = new Set<() => void>(); - - return { - get state() { - return state; - }, - getState: () => state, - setState: (updater) => { - state = updater(state); - listeners.forEach((listener) => listener()); - }, - subscribe: (listener) => { - listeners.add(listener); - return { - unsubscribe: () => listeners.delete(listener), - }; - }, - }; -} - -function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { - return new Promise<void>((resolve, reject) => { - let ticks = 0; - const tick = () => { - if (hasHydrated()) { - resolve(); - return; - } - // Bounded: a hydration regression fails loudly here instead of hanging - // the suite until the runner's opaque timeout. - if (++ticks > maxTicks) { - reject(new Error("waitForHydration: never hydrated")); - return; - } - queueMicrotask(tick); - }; - tick(); - }); -} describe("toHydrationSignal", () => { it("delegates isHydrated to the persist api", async () => { diff --git a/src/core/persist-core.test.ts b/src/core/persist-core.test.ts index 4e81e15..a5cf4ab 100644 --- a/src/core/persist-core.test.ts +++ b/src/core/persist-core.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { MemoryStorage } from "../testing/memory-storage"; +import { createMockSource } from "../testing/mock-source"; +import { waitForHydration } from "../testing/wait-for-hydration"; import { createJSONStorage, createMigrationChain, @@ -13,7 +15,6 @@ import { import type { CrossTabEventTarget, CrossTabStorageEvent, - PersistableSource, StateStorage, StorageValue, } from "./persist-core"; @@ -46,48 +47,6 @@ describe("persist-core zero-dep gate", () => { }); }); -function createMockSource<T>(initial: T): PersistableSource<T> & { state: T } { - let state = initial; - const listeners = new Set<() => void>(); - - return { - get state() { - return state; - }, - getState: () => state, - setState: (updater) => { - state = updater(state); - listeners.forEach((listener) => listener()); - }, - subscribe: (listener) => { - listeners.add(listener); - return { - unsubscribe: () => listeners.delete(listener), - }; - }, - }; -} - -function waitForHydration(hasHydrated: () => boolean, maxTicks = 10_000) { - return new Promise<void>((resolve, reject) => { - let ticks = 0; - const tick = () => { - if (hasHydrated()) { - resolve(); - return; - } - // Bounded: a hydration regression fails loudly here instead of hanging - // the suite until the runner's opaque timeout. - if (++ticks > maxTicks) { - reject(new Error("waitForHydration: never hydrated")); - return; - } - queueMicrotask(tick); - }; - tick(); - }); -} - describe("createJSONStorage codec seam", () => { let memory: MemoryStorage; diff --git a/src/testing/mock-source.ts b/src/testing/mock-source.ts new file mode 100644 index 0000000..24b626f --- /dev/null +++ b/src/testing/mock-source.ts @@ -0,0 +1,30 @@ +import type { PersistableSource } from "../core/persist-core"; + +/** + * A minimal `PersistableSource` for tests — a plain object with + * `getState` / `setState` / `subscribe` + a `state` getter for assertions. + * Test-only; not a tsdown/typedoc entry, so not shipped in `dist/`. + */ +export function createMockSource<T>( + initial: T, +): PersistableSource<T> & { state: T } { + let state = initial; + const listeners = new Set<() => void>(); + + return { + get state() { + return state; + }, + getState: () => state, + setState: (updater) => { + state = updater(state); + listeners.forEach((listener) => listener()); + }, + subscribe: (listener) => { + listeners.add(listener); + return { + unsubscribe: () => listeners.delete(listener), + }; + }, + }; +} diff --git a/src/testing/wait-for-hydration.ts b/src/testing/wait-for-hydration.ts new file mode 100644 index 0000000..5400ab6 --- /dev/null +++ b/src/testing/wait-for-hydration.ts @@ -0,0 +1,28 @@ +/** + * Poll `hasHydrated()` on microtasks until true (or `maxTicks` exceeded). + * For backends that settle on **macrotasks** (e.g. `node:fs` I/O), write a + * local `setTimeout`-based variant — microtasks never yield there. + * Test-only; not shipped in `dist/`. + */ +export function waitForHydration( + hasHydrated: () => boolean, + maxTicks = 10_000, +): Promise<void> { + return new Promise<void>((resolve, reject) => { + let ticks = 0; + const tick = () => { + if (hasHydrated()) { + resolve(); + return; + } + // Bounded: a hydration regression fails loudly here instead of hanging + // the suite until the runner's opaque timeout. + if (++ticks > maxTicks) { + reject(new Error("waitForHydration: never hydrated")); + return; + } + queueMicrotask(tick); + }; + tick(); + }); +} From 1208078260d8252534462e34f6948f29d736ba46 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Sun, 5 Jul 2026 21:09:23 +0300 Subject: [PATCH 62/77] =?UTF-8?q?harden:=20CodeRabbit=20review=20triage=20?= =?UTF-8?q?=E2=80=94=20apply=206,=204=20already-fixed,=201=20push-back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .agents/skills/harden-pr/LEDGER.md | 2 +- .changeset/node-fs-pack-gate.md | 2 +- .changeset/svelte-hydration.md | 2 +- README.md | 52 +++++++++++++-------------- docs/architecture.md | 2 +- src/adapters/backends/compressed.ts | 2 +- src/adapters/backends/node-fs.test.ts | 23 ++++++++++-- src/adapters/backends/node-fs.ts | 12 ++++++- src/adapters/frameworks/angular.ts | 4 +++ src/adapters/frameworks/svelte.ts | 2 +- 10 files changed, 68 insertions(+), 35 deletions(-) diff --git a/.agents/skills/harden-pr/LEDGER.md b/.agents/skills/harden-pr/LEDGER.md index 59f9194..93e8903 100644 --- a/.agents/skills/harden-pr/LEDGER.md +++ b/.agents/skills/harden-pr/LEDGER.md @@ -24,4 +24,4 @@ Capped or out-of-scope-for-now — reconcile re-vets; remove lines when fixed. - **[severity]** `file:line` — finding (deferred: out of scope | cap | blocked) ``` -- **[info]** `package.json` peerDependencies — `./frameworks/svelte` declares `svelte >=5.0.0` in source/README but `package.json` has `svelte >=3.0.0` shared with `./frameworks/svelte-store` (deferred: out of bounds — peer deps are package-level, not subpath-level; can't fix without splitting the svelte subpath into a separate package). +- **[info]** `package.json` peerDependencies — `./frameworks/svelte` declares `svelte >=5.7.0` in source/README but `package.json` has `svelte >=3.0.0` shared with `./frameworks/svelte-store` (deferred: out of bounds — peer deps are package-level, not subpath-level; can't fix without splitting the svelte subpath into a separate package). diff --git a/.changeset/node-fs-pack-gate.md b/.changeset/node-fs-pack-gate.md index caea9e1..d02019e 100644 --- a/.changeset/node-fs-pack-gate.md +++ b/.changeset/node-fs-pack-gate.md @@ -2,6 +2,6 @@ "@stainless-code/persist": minor --- -Add `./backends/node-fs` — `nodeFsStateStorage({ dir })`, an async `StateStorage<string>` over Node `fs.promises` (one file per key under `dir`). No peer dep (`node:fs` is a Node built-in). Keys are sanitized to filename-safe segments (`app:prefs:v1` → `app_prefs_v1`); missing files map to `null` (no throw); the dir is created lazily on first write. Compose with `createStorage(() => nodeFsStateStorage({ dir }), codec)`. Unblocks server / SSR / CLI persistence. +Add `./backends/node-fs` — `nodeFsStateStorage({ dir })`, an async `StateStorage<string>` over Node `fs.promises` (one file per key under `dir`). No peer dep (`node:fs` is a Node built-in). Keys are sanitized to filename-safe segments with a short hash suffix (`app:prefs:v1` → `app_prefs_v1.<hash>`) so distinct keys that sanitize to the same segment don't collide on one file; `..`/`.`/empty keys are refused; missing files map to `null` (no throw); the dir is created lazily on first write. Compose with `createStorage(() => nodeFsStateStorage({ dir }), codec)`. Unblocks server / SSR / CLI persistence. Also adds a pack-validation + semver gate (`check:pack`: `@arethetypeswrong/cli` + `publint` + `knip`) wired into CI (`check-pack` job) + `prepublishOnly`; and README storage + codec decision matrices. diff --git a/.changeset/svelte-hydration.md b/.changeset/svelte-hydration.md index 6003101..e522d0e 100644 --- a/.changeset/svelte-hydration.md +++ b/.changeset/svelte-hydration.md @@ -4,7 +4,7 @@ Add Svelte hydration adapters over the `HydrationSignal` seam, covering both pre- and post-runes Svelte. Two subpaths because `svelte/reactivity` (runes) is Svelte 5+ and would break a Svelte 4 import — each subpath owns its dep range: -- `./frameworks/svelte` (peer `svelte >=5.0.0`) — `hydratedRune(signal)` via `svelte/reactivity` `createSubscriber`. Returns `{ readonly current: boolean }`; read `current` inside a reactive context (`$derived`/`$effect`/component/`{#if}`). Subscription owned by the reactive context, cleaned up on context dispose. Post-runes. +- `./frameworks/svelte` (peer `svelte >=5.7.0` — `createSubscriber` from `svelte/reactivity` landed in 5.7) — `hydratedRune(signal)` via `svelte/reactivity` `createSubscriber`. Returns `{ readonly current: boolean }`; read `current` inside a reactive context (`$derived`/`$effect`/component/`{#if}`). Subscription owned by the reactive context, cleaned up on context 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 (for users who prefer the store API). Subscription tied to the store's subscriber lifecycle. Both render `true` on the server (no-op `PersistApi` is always-hydrated) — matching the `HydrationSignal` adapter contract. Each is its own subpath with `svelte` optional, no cross-entry value imports. diff --git a/README.md b/README.md index cd60947..caf9201 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Each subpath owns its dependency as an **optional peer** — import only the ent bun add seroval idb-keyval @tanstack/store react ``` -Any package manager works — engines require Node ≥20.19 (or Bun ≥1). +Any package manager works — engines require Node ^20.19.0 || >=22.12.0 (or Bun >=1.0.0). ```bash npm install @stainless-code/persist # pnpm add / yarn add — same pattern @@ -398,31 +398,31 @@ Persistence middleware for any `getState`/`setState`/`subscribe` store (TanStack ## Entry points (one subpath = one optional peer) -| Subpath | Symbols | Optional peer | -| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | -| `@stainless-code/persist` | `persistSource`, `PersistApi`, `createStorage`, `createJSONStorage`, `createMigrationChain`, `jsonCodec`, `identityCodec`, registry, `HydrationSignal` (`hydration`) | none | -| `@stainless-code/persist/codecs/seroval` | `serovalCodec`, `createSerovalStorage` | `seroval` | -| `@stainless-code/persist/codecs/zod` | `zodCodec`, `createZodStorage` | `zod` | -| `@stainless-code/persist/backends/idb` | `idbStateStorage`, `createIdbStorage` (structured-clone mode) | `idb-keyval` | -| `@stainless-code/persist/backends/async-storage` | `asyncStorageStateStorage`, `createAsyncStorage` | `@react-native-async-storage/async-storage` | -| `@stainless-code/persist/backends/mmkv` | `mmkvStateStorage`, `createMmkvStorage` | `react-native-mmkv` | -| `@stainless-code/persist/backends/secure-store` | `secureStoreStateStorage`, `createSecureStoreStorage` | `expo-secure-store` | -| `@stainless-code/persist/backends/encrypted` | `createEncryptedStorage` (AES-GCM WebCrypto) | none (web global) | -| `@stainless-code/persist/backends/compressed` | `createCompressedStorage` (gzip/deflate/deflate-raw) | none (web global) | -| `@stainless-code/persist/backends/node-fs` | `nodeFsStateStorage` (one file per key) | none (Node built-in) | -| `@stainless-code/persist/transport/crosstab` | `createBroadcastCrossTab` | none (web global) | -| `@stainless-code/persist/sources/tanstack-store` | `persistStore`, `persistAtom` | `@tanstack/store` (types only) | -| `@stainless-code/persist/sources/zustand` | `persistStore` | `zustand` | -| `@stainless-code/persist/sources/jotai` | `persistAtom` | `jotai` | -| `@stainless-code/persist/sources/valtio` | `persistProxy` | `valtio` | -| `@stainless-code/persist/sources/mobx` | `persistObservable` | `mobx` | -| `@stainless-code/persist/frameworks/react` | `useHydrated` React hook | `react` | -| `@stainless-code/persist/frameworks/solid` | `useHydrated` (Solid `Accessor<boolean>`) | `solid-js` | -| `@stainless-code/persist/frameworks/vue` | `useHydrated` (Vue `Ref<boolean>`) | `vue` | -| `@stainless-code/persist/frameworks/svelte` | `hydratedRune` (Svelte 5 runes `current`) | `svelte` (>=5) | -| `@stainless-code/persist/frameworks/svelte-store` | `hydratedStore` (Svelte `Readable<boolean>`) | `svelte` (>=3) | -| `@stainless-code/persist/frameworks/angular` | `useHydrated` (Angular `Signal<boolean>`) | `@angular/core` (>=17) | -| `@stainless-code/persist/frameworks/preact` | `useHydrated` (Preact `{ hydrated: boolean }`) | `preact` (>=10.19) | +| Subpath | Symbols | Optional peer | +| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| `@stainless-code/persist` | `persistSource`, `PersistApi`, `createStorage`, `createJSONStorage`, `createMigrationChain`, `jsonCodec`, `identityCodec`, `createPersistRegistry`, `HydrationSignal` (`hydration`) | none | +| `@stainless-code/persist/codecs/seroval` | `serovalCodec`, `createSerovalStorage` | `seroval` | +| `@stainless-code/persist/codecs/zod` | `zodCodec`, `createZodStorage` | `zod` | +| `@stainless-code/persist/backends/idb` | `idbStateStorage`, `createIdbStorage` (structured-clone mode) | `idb-keyval` | +| `@stainless-code/persist/backends/async-storage` | `asyncStorageStateStorage`, `createAsyncStorage` | `@react-native-async-storage/async-storage` | +| `@stainless-code/persist/backends/mmkv` | `mmkvStateStorage`, `createMmkvStorage` | `react-native-mmkv` | +| `@stainless-code/persist/backends/secure-store` | `secureStoreStateStorage`, `createSecureStoreStorage` | `expo-secure-store` | +| `@stainless-code/persist/backends/encrypted` | `createEncryptedStorage` (AES-GCM WebCrypto) | none (web global) | +| `@stainless-code/persist/backends/compressed` | `createCompressedStorage` (gzip/deflate/deflate-raw) | none (web global) | +| `@stainless-code/persist/backends/node-fs` | `nodeFsStateStorage` (one file per key) | none (Node built-in) | +| `@stainless-code/persist/transport/crosstab` | `createBroadcastCrossTab` | none (web global) | +| `@stainless-code/persist/sources/tanstack-store` | `persistStore`, `persistAtom` | `@tanstack/store` (types only) | +| `@stainless-code/persist/sources/zustand` | `persistStore` | `zustand` | +| `@stainless-code/persist/sources/jotai` | `persistAtom` | `jotai` | +| `@stainless-code/persist/sources/valtio` | `persistProxy` | `valtio` | +| `@stainless-code/persist/sources/mobx` | `persistObservable` | `mobx` | +| `@stainless-code/persist/frameworks/react` | `useHydrated` React hook | `react` | +| `@stainless-code/persist/frameworks/solid` | `useHydrated` (Solid `Accessor<boolean>`) | `solid-js` | +| `@stainless-code/persist/frameworks/vue` | `useHydrated` (Vue `Ref<boolean>`) | `vue` | +| `@stainless-code/persist/frameworks/svelte` | `hydratedRune` (Svelte 5 runes `current`) | `svelte` (>=5.7) | +| `@stainless-code/persist/frameworks/svelte-store` | `hydratedStore` (Svelte `Readable<boolean>`) | `svelte` (>=3) | +| `@stainless-code/persist/frameworks/angular` | `useHydrated` (Angular `Signal<boolean>`) | `@angular/core` (>=17) | +| `@stainless-code/persist/frameworks/preact` | `useHydrated` (Preact `{ hydrated: boolean }`) | `preact` (>=10.19) | No barrel — importing a subpath is the dependency opt-in. diff --git a/docs/architecture.md b/docs/architecture.md index 488ba2f..4e01fe6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,7 +37,7 @@ Persistence is bound to a structural `PersistableSource` (`getState` / `setState | `@stainless-code/persist/frameworks/react` | `adapters/frameworks/react` | `react` | | `@stainless-code/persist/frameworks/solid` | `adapters/frameworks/solid` | `solid-js` | | `@stainless-code/persist/frameworks/vue` | `adapters/frameworks/vue` | `vue` | -| `@stainless-code/persist/frameworks/svelte` | `adapters/frameworks/svelte` | `svelte` (>=5 runes) | +| `@stainless-code/persist/frameworks/svelte` | `adapters/frameworks/svelte` | `svelte` (>=5.7 runes) | | `@stainless-code/persist/frameworks/svelte-store` | `adapters/frameworks/svelte-store` | `svelte` (>=3 store) | | `@stainless-code/persist/frameworks/angular` | `adapters/frameworks/angular` | `@angular/core` (>=17) | | `@stainless-code/persist/frameworks/preact` | `adapters/frameworks/preact` | `preact` (>=10.19) | diff --git a/src/adapters/backends/compressed.ts b/src/adapters/backends/compressed.ts index 5e5ab4d..2f607cc 100644 --- a/src/adapters/backends/compressed.ts +++ b/src/adapters/backends/compressed.ts @@ -1,4 +1,4 @@ -// Compressed storage wrapper — no peer dep (`CompressionStream`/`DecompressionStream` are web globals, browsers + Node 18+). String-wire backends; output is base64. +// Compressed storage wrapper — no peer dep (`CompressionStream`/`DecompressionStream` are web globals, browsers + Node 18+ for gzip/deflate; Node 20.12+ for `deflate-raw`). String-wire backends; output is base64. import type { StateStorage } from "../../core/persist-core"; export type CompressionFormat = "gzip" | "deflate" | "deflate-raw"; diff --git a/src/adapters/backends/node-fs.test.ts b/src/adapters/backends/node-fs.test.ts index 37753b7..92b736b 100644 --- a/src/adapters/backends/node-fs.test.ts +++ b/src/adapters/backends/node-fs.test.ts @@ -63,7 +63,9 @@ describe("nodeFsStateStorage", () => { try { const storage = nodeFsStateStorage({ dir }); await storage.setItem("k", "v"); - expect(await readFile(join(dir, "k"), "utf8")).toBe("v"); + const file = (await readdir(dir)).find((f) => f.startsWith("k.")); + expect(file).toBeTruthy(); + expect(await readFile(join(dir, file!), "utf8")).toBe("v"); expect(await storage.getItem("k")).toBe("v"); } finally { await rm(base, { recursive: true, force: true }); @@ -76,13 +78,30 @@ describe("nodeFsStateStorage", () => { const storage = nodeFsStateStorage({ dir }); await storage.setItem("app:prefs:v1", "prefs"); const files = await readdir(dir); - expect(files).toContain("app_prefs_v1"); + // Filename = sanitized segment + hash suffix (collision-resistant). + expect(files.find((f) => f.startsWith("app_prefs_v1."))).toBeTruthy(); expect(await storage.getItem("app:prefs:v1")).toBe("prefs"); } finally { await rm(dir, { recursive: true, force: true }); } }); + it("distinct keys that sanitize to the same segment don't collide", async () => { + const dir = await tempDir(); + try { + const storage = nodeFsStateStorage({ dir }); + await storage.setItem("app:prefs", "a"); + await storage.setItem("app_prefs", "b"); + const files = await readdir(dir); + // Both sanitize to `app_prefs` but the hash suffix keeps them distinct. + expect(files.length).toBe(2); + expect(await storage.getItem("app:prefs")).toBe("a"); + expect(await storage.getItem("app_prefs")).toBe("b"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + it("composes with createStorage + persistSource end-to-end", async () => { const dir = await tempDir(); try { diff --git a/src/adapters/backends/node-fs.ts b/src/adapters/backends/node-fs.ts index e911807..1c20207 100644 --- a/src/adapters/backends/node-fs.ts +++ b/src/adapters/backends/node-fs.ts @@ -9,6 +9,16 @@ export interface NodeFsStorageOptions { dir: string; } +// djb2 — a short, dependency-free hash. Appended to the sanitized filename so +// distinct keys that sanitize to the same segment (e.g. `app:prefs` and +// `app_prefs`) don't collide on one file. Not cryptographic; sufficient for +// app-defined store names. +const hashKey = (s: string): string => { + let h = 5381; + for (let i = 0; i < s.length; i++) h = (h * 33) ^ s.charCodeAt(i); + return (h >>> 0).toString(16); +}; + /** * Node `fs` storage backend — one file per key under `dir` (async `fs.promises`). * Keys are sanitized to filename-safe segments (`app:prefs:v1` → `app_prefs_v1`). @@ -37,7 +47,7 @@ export function nodeFsStateStorage( `[nodeFsStateStorage] key "${name}" sanitizes to "${safe}" — refusing to resolve outside dir`, ); } - return join(dir, safe); + return join(dir, `${safe}.${hashKey(name)}`); }; return { diff --git a/src/adapters/frameworks/angular.ts b/src/adapters/frameworks/angular.ts index 359fe15..5566d5d 100644 --- a/src/adapters/frameworks/angular.ts +++ b/src/adapters/frameworks/angular.ts @@ -24,6 +24,10 @@ export function useHydrated( if (!signalSource) return alwaysTrue; const hydrated = signal(signalSource.isHydrated()); effect((onCleanup) => { + // `effect()` runs after the current change-detection cycle, so a hydration + // transition between signal creation and effect attach would leave the + // signal stale. Re-read at attach time to close that gap. + hydrated.set(signalSource.isHydrated()); const unsubscribe = signalSource.subscribeHydrated(() => { hydrated.set(signalSource.isHydrated()); }); diff --git a/src/adapters/frameworks/svelte.ts b/src/adapters/frameworks/svelte.ts index 1b46fdb..321f4d4 100644 --- a/src/adapters/frameworks/svelte.ts +++ b/src/adapters/frameworks/svelte.ts @@ -1,4 +1,4 @@ -// Svelte 5 (runes) hydration adapter — peer `svelte` >=5.0.0. Svelte 4 (pre-runes): `./frameworks/svelte-store`. +// Svelte 5 (runes) hydration adapter — peer `svelte` >=5.7.0 (`createSubscriber` from `svelte/reactivity` landed in 5.7). Svelte 4 (pre-runes): `./frameworks/svelte-store`. import { createSubscriber } from "svelte/reactivity"; import type { HydrationSignal } from "../../core/hydration"; From ea9075eee096657507e23b4e5796a8b7b6677bb3 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Mon, 6 Jul 2026 09:21:52 +0300 Subject: [PATCH 63/77] refactor(test): dedupe 'imports only from core' check into src/testing helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/adapters/backends/async-storage.test.ts | 13 ++----------- src/adapters/backends/compressed.test.ts | 13 ++----------- src/adapters/backends/encrypted.test.ts | 13 ++----------- src/adapters/backends/idb.test.ts | 11 ++--------- src/adapters/backends/mmkv.test.ts | 11 ++--------- src/adapters/backends/node-fs.test.ts | 13 ++----------- src/adapters/backends/secure-store.test.ts | 13 ++----------- src/adapters/codecs/seroval.test.ts | 13 ++----------- src/adapters/codecs/zod.test.ts | 11 ++--------- src/adapters/frameworks/angular.test.ts | 14 +++----------- src/adapters/frameworks/preact.test.ts | 14 +++----------- src/adapters/frameworks/react.test.ts | 13 ++----------- src/adapters/frameworks/solid.test.ts | 14 +++----------- src/adapters/frameworks/svelte-store.test.ts | 13 ++----------- src/adapters/frameworks/svelte.test.ts | 13 ++----------- src/adapters/frameworks/vue.test.ts | 13 +++---------- src/adapters/sources/jotai.test.ts | 13 ++----------- src/adapters/sources/mobx.test.ts | 11 ++--------- src/adapters/sources/tanstack-store.test.ts | 13 ++----------- src/adapters/sources/valtio.test.ts | 13 ++----------- src/adapters/sources/zustand.test.ts | 13 ++----------- src/adapters/transport/crosstab.test.ts | 13 ++----------- src/testing/assert-core-only-imports.ts | 19 +++++++++++++++++++ 23 files changed, 67 insertions(+), 233 deletions(-) create mode 100644 src/testing/assert-core-only-imports.ts diff --git a/src/adapters/backends/async-storage.test.ts b/src/adapters/backends/async-storage.test.ts index 5f863c5..3f627b6 100644 --- a/src/adapters/backends/async-storage.test.ts +++ b/src/adapters/backends/async-storage.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; import { createMockSource } from "../../testing/mock-source"; import { waitForHydration } from "../../testing/wait-for-hydration"; @@ -64,15 +65,5 @@ describe("createAsyncStorage", () => { persist2.destroy(); }); - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file( - new URL("./async-storage.ts", import.meta.url), - ).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((match) => match[1]); - for (const importPath of relativeImports) { - expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./async-storage.ts", import.meta.url)); }); diff --git a/src/adapters/backends/compressed.test.ts b/src/adapters/backends/compressed.test.ts index d3c3620..33f92a0 100644 --- a/src/adapters/backends/compressed.test.ts +++ b/src/adapters/backends/compressed.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { createStorage, persistSource } from "../../core/persist-core"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; import { MemoryStorage } from "../../testing/memory-storage"; import { createMockSource } from "../../testing/mock-source"; import { waitForHydration } from "../../testing/wait-for-hydration"; @@ -103,15 +104,5 @@ describe("createCompressedStorage", () => { } }); - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file( - new URL("./compressed.ts", import.meta.url), - ).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((match) => match[1]); - for (const importPath of relativeImports) { - expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./compressed.ts", import.meta.url)); }); diff --git a/src/adapters/backends/encrypted.test.ts b/src/adapters/backends/encrypted.test.ts index ecc225e..603087d 100644 --- a/src/adapters/backends/encrypted.test.ts +++ b/src/adapters/backends/encrypted.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { createStorage, persistSource } from "../../core/persist-core"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; import { MemoryStorage } from "../../testing/memory-storage"; import { createMockSource } from "../../testing/mock-source"; import { waitForHydration } from "../../testing/wait-for-hydration"; @@ -116,15 +117,5 @@ describe("createEncryptedStorage", () => { } }); - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file( - new URL("./encrypted.ts", import.meta.url), - ).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((match) => match[1]); - for (const importPath of relativeImports) { - expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./encrypted.ts", import.meta.url)); }); diff --git a/src/adapters/backends/idb.test.ts b/src/adapters/backends/idb.test.ts index b07a9b4..0e4923f 100644 --- a/src/adapters/backends/idb.test.ts +++ b/src/adapters/backends/idb.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; import { createMockSource } from "../../testing/mock-source"; import { waitForHydration } from "../../testing/wait-for-hydration"; @@ -137,13 +138,5 @@ describe("createIdbStorage", () => { expect(defaultStore.has("shared-key")).toBe(false); }); - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file(new URL("./idb.ts", import.meta.url)).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((match) => match[1]); - for (const importPath of relativeImports) { - expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./idb.ts", import.meta.url)); }); diff --git a/src/adapters/backends/mmkv.test.ts b/src/adapters/backends/mmkv.test.ts index 98795a6..3bf60d6 100644 --- a/src/adapters/backends/mmkv.test.ts +++ b/src/adapters/backends/mmkv.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; import type { MMKV } from "react-native-mmkv"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; import { createMockSource } from "../../testing/mock-source"; import { waitForHydration } from "../../testing/wait-for-hydration"; @@ -75,13 +76,5 @@ describe("createMmkvStorage", () => { persist2.destroy(); }); - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file(new URL("./mmkv.ts", import.meta.url)).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((match) => match[1]); - for (const importPath of relativeImports) { - expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./mmkv.ts", import.meta.url)); }); diff --git a/src/adapters/backends/node-fs.test.ts b/src/adapters/backends/node-fs.test.ts index 92b736b..8713faf 100644 --- a/src/adapters/backends/node-fs.test.ts +++ b/src/adapters/backends/node-fs.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createStorage, persistSource } from "../../core/persist-core"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; import { createMockSource } from "../../testing/mock-source"; import { serovalCodec } from "../codecs/seroval"; import { nodeFsStateStorage } from "./node-fs"; @@ -137,15 +138,5 @@ describe("nodeFsStateStorage", () => { } }); - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file( - new URL("./node-fs.ts", import.meta.url), - ).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((m) => m[1]); - for (const imp of relativeImports) { - expect(imp).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./node-fs.ts", import.meta.url)); }); diff --git a/src/adapters/backends/secure-store.test.ts b/src/adapters/backends/secure-store.test.ts index e837624..8dd7651 100644 --- a/src/adapters/backends/secure-store.test.ts +++ b/src/adapters/backends/secure-store.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; import { createMockSource } from "../../testing/mock-source"; import { waitForHydration } from "../../testing/wait-for-hydration"; @@ -59,15 +60,5 @@ describe("createSecureStoreStorage", () => { persist2.destroy(); }); - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file( - new URL("./secure-store.ts", import.meta.url), - ).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((match) => match[1]); - for (const importPath of relativeImports) { - expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./secure-store.ts", import.meta.url)); }); diff --git a/src/adapters/codecs/seroval.test.ts b/src/adapters/codecs/seroval.test.ts index 712b34b..7c18bc7 100644 --- a/src/adapters/codecs/seroval.test.ts +++ b/src/adapters/codecs/seroval.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { createStorage, persistSource } from "../../core/persist-core"; import type { StateStorage } from "../../core/persist-core"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; import { MemoryStorage } from "../../testing/memory-storage"; import { createMockSource } from "../../testing/mock-source"; import { waitForHydration } from "../../testing/wait-for-hydration"; @@ -121,15 +122,5 @@ describe("serovalCodec direct seam", () => { }); describe("seroval dependency isolation", () => { - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file( - new URL("./seroval.ts", import.meta.url), - ).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((match) => match[1]); - for (const importPath of relativeImports) { - expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./seroval.ts", import.meta.url)); }); diff --git a/src/adapters/codecs/zod.test.ts b/src/adapters/codecs/zod.test.ts index ec7b306..69bb6a9 100644 --- a/src/adapters/codecs/zod.test.ts +++ b/src/adapters/codecs/zod.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { z } from "zod"; import { createStorage, persistSource } from "../../core/persist-core"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; import { MemoryStorage } from "../../testing/memory-storage"; import { createMockSource } from "../../testing/mock-source"; import { waitForHydration } from "../../testing/wait-for-hydration"; @@ -97,13 +98,5 @@ describe("zodCodec direct seam", () => { }); describe("zod dependency isolation", () => { - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file(new URL("./zod.ts", import.meta.url)).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((match) => match[1]); - for (const importPath of relativeImports) { - expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./zod.ts", import.meta.url)); }); diff --git a/src/adapters/frameworks/angular.test.ts b/src/adapters/frameworks/angular.test.ts index d26630e..ba34376 100644 --- a/src/adapters/frameworks/angular.test.ts +++ b/src/adapters/frameworks/angular.test.ts @@ -1,5 +1,7 @@ import { beforeAll, describe, expect, it, mock } from "bun:test"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; + // Minimal Angular signals stub — signal() + effect() without an injection // context. effect() runs immediately + registers cleanup via its callback arg. const cleanups: Array<() => void> = []; @@ -82,15 +84,5 @@ describe("useHydrated", () => { }); describe("angular dependency isolation", () => { - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file( - new URL("./angular.ts", import.meta.url), - ).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((match) => match[1]); - for (const importPath of relativeImports) { - expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./angular.ts", import.meta.url)); }); diff --git a/src/adapters/frameworks/preact.test.ts b/src/adapters/frameworks/preact.test.ts index d08c33a..ccb149e 100644 --- a/src/adapters/frameworks/preact.test.ts +++ b/src/adapters/frameworks/preact.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, mock } from "bun:test"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; + mock.module("preact/compat", () => ({ useSyncExternalStore: ( _subscribe: (listener: () => void) => () => void, @@ -44,15 +46,5 @@ describe("useHydrated", () => { }); describe("preact dependency isolation", () => { - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file( - new URL("./preact.ts", import.meta.url), - ).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((m) => m[1]); - for (const imp of relativeImports) { - expect(imp).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./preact.ts", import.meta.url)); }); diff --git a/src/adapters/frameworks/react.test.ts b/src/adapters/frameworks/react.test.ts index 434a865..6442a52 100644 --- a/src/adapters/frameworks/react.test.ts +++ b/src/adapters/frameworks/react.test.ts @@ -5,6 +5,7 @@ import { renderToString } from "react-dom/server"; import { alwaysHydratedSignal, toHydrationSignal } from "../../core/hydration"; import { createJSONStorage, persistSource } from "../../core/persist-core"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; import { MemoryStorage } from "../../testing/memory-storage"; import { createMockSource } from "../../testing/mock-source"; import { waitForHydration } from "../../testing/wait-for-hydration"; @@ -115,15 +116,5 @@ describe("useHydrated", () => { }); describe("react dependency isolation", () => { - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file( - new URL("./react.ts", import.meta.url), - ).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((match) => match[1]); - for (const importPath of relativeImports) { - expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./react.ts", import.meta.url)); }); diff --git a/src/adapters/frameworks/solid.test.ts b/src/adapters/frameworks/solid.test.ts index fe56a82..9ad246b 100644 --- a/src/adapters/frameworks/solid.test.ts +++ b/src/adapters/frameworks/solid.test.ts @@ -1,5 +1,7 @@ import { beforeAll, describe, expect, it, mock } from "bun:test"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; + // Bun resolves `solid-js` to the SSR build (`createEffect` is a no-op). Point // both the test and `solid` at the client build for reactive coverage. mock.module( @@ -90,15 +92,5 @@ describe("useHydrated", () => { }); describe("solid dependency isolation", () => { - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file( - new URL("./solid.ts", import.meta.url), - ).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((match) => match[1]); - for (const importPath of relativeImports) { - expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./solid.ts", import.meta.url)); }); diff --git a/src/adapters/frameworks/svelte-store.test.ts b/src/adapters/frameworks/svelte-store.test.ts index e7d95d8..de05063 100644 --- a/src/adapters/frameworks/svelte-store.test.ts +++ b/src/adapters/frameworks/svelte-store.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import { get } from "svelte/store"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; import { hydratedStore } from "./svelte-store"; function createFakeSignal() { @@ -59,15 +60,5 @@ describe("hydratedStore (svelte 3+ stores)", () => { }); describe("svelte-store dependency isolation", () => { - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file( - new URL("./svelte-store.ts", import.meta.url), - ).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((match) => match[1]); - for (const importPath of relativeImports) { - expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./svelte-store.ts", import.meta.url)); }); diff --git a/src/adapters/frameworks/svelte.test.ts b/src/adapters/frameworks/svelte.test.ts index 88f238f..777b72f 100644 --- a/src/adapters/frameworks/svelte.test.ts +++ b/src/adapters/frameworks/svelte.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; import { hydratedRune } from "./svelte"; function createFakeSignal() { @@ -43,15 +44,5 @@ describe("hydratedRune (svelte 5 runes)", () => { }); describe("svelte dependency isolation", () => { - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file( - new URL("./svelte.ts", import.meta.url), - ).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((match) => match[1]); - for (const importPath of relativeImports) { - expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./svelte.ts", import.meta.url)); }); diff --git a/src/adapters/frameworks/vue.test.ts b/src/adapters/frameworks/vue.test.ts index df454a2..4c8a971 100644 --- a/src/adapters/frameworks/vue.test.ts +++ b/src/adapters/frameworks/vue.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import { effect, effectScope } from "vue"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; import { useHydrated } from "./vue"; function createFakeSignal() { @@ -50,7 +51,7 @@ describe("useHydrated (vue)", () => { scope.run(() => { hydratedRef = useHydrated(signal); effect(() => { - hydratedRef!.value; + void hydratedRef!.value; }); }); expect(signal.listenerCount()).toBe(1); @@ -61,13 +62,5 @@ describe("useHydrated (vue)", () => { expect(hydratedRef!.value).toBe(lastValue); }); - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file(new URL("./vue.ts", import.meta.url)).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((match) => match[1]); - for (const importPath of relativeImports) { - expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./vue.ts", import.meta.url)); }); diff --git a/src/adapters/sources/jotai.test.ts b/src/adapters/sources/jotai.test.ts index f3c3dbe..0ea002e 100644 --- a/src/adapters/sources/jotai.test.ts +++ b/src/adapters/sources/jotai.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from "bun:test"; import type { WritableAtom } from "jotai"; import { createJSONStorage } from "../../core/persist-core"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; import { MemoryStorage } from "../../testing/memory-storage"; import { waitForHydration } from "../../testing/wait-for-hydration"; import { persistAtom } from "./jotai"; @@ -95,15 +96,5 @@ describe("persistAtom", () => { }); describe("jotai dependency isolation", () => { - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file( - new URL("./jotai.ts", import.meta.url), - ).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((match) => match[1]); - for (const importPath of relativeImports) { - expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./jotai.ts", import.meta.url)); }); diff --git a/src/adapters/sources/mobx.test.ts b/src/adapters/sources/mobx.test.ts index 3b35976..b8bb9be 100644 --- a/src/adapters/sources/mobx.test.ts +++ b/src/adapters/sources/mobx.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; import { MemoryStorage } from "../../testing/memory-storage"; import { waitForHydration } from "../../testing/wait-for-hydration"; @@ -100,13 +101,5 @@ describe("persistObservable", () => { }); describe("mobx dependency isolation", () => { - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file(new URL("./mobx.ts", import.meta.url)).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((match) => match[1]); - for (const importPath of relativeImports) { - expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./mobx.ts", import.meta.url)); }); diff --git a/src/adapters/sources/tanstack-store.test.ts b/src/adapters/sources/tanstack-store.test.ts index 2c19f64..0707606 100644 --- a/src/adapters/sources/tanstack-store.test.ts +++ b/src/adapters/sources/tanstack-store.test.ts @@ -4,6 +4,7 @@ import { createAtom, Store } from "@tanstack/store"; import type { Atom } from "@tanstack/store"; import { createJSONStorage } from "../../core/persist-core"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; import { MemoryStorage } from "../../testing/memory-storage"; import { waitForHydration } from "../../testing/wait-for-hydration"; import { persistAtom, persistStore } from "./tanstack-store"; @@ -79,15 +80,5 @@ describe("persistAtom", () => { }); describe("tanstack-store dependency isolation", () => { - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file( - new URL("./tanstack-store.ts", import.meta.url), - ).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((match) => match[1]); - for (const importPath of relativeImports) { - expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./tanstack-store.ts", import.meta.url)); }); diff --git a/src/adapters/sources/valtio.test.ts b/src/adapters/sources/valtio.test.ts index 60808e9..4dd283f 100644 --- a/src/adapters/sources/valtio.test.ts +++ b/src/adapters/sources/valtio.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; import { MemoryStorage } from "../../testing/memory-storage"; import { waitForHydration } from "../../testing/wait-for-hydration"; @@ -103,15 +104,5 @@ describe("persistProxy", () => { }); describe("valtio dependency isolation", () => { - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file( - new URL("./valtio.ts", import.meta.url), - ).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((match) => match[1]); - for (const importPath of relativeImports) { - expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./valtio.ts", import.meta.url)); }); diff --git a/src/adapters/sources/zustand.test.ts b/src/adapters/sources/zustand.test.ts index 1d28e1b..84a62fc 100644 --- a/src/adapters/sources/zustand.test.ts +++ b/src/adapters/sources/zustand.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from "bun:test"; import type { StoreApi } from "zustand"; import { createJSONStorage } from "../../core/persist-core"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; import { MemoryStorage } from "../../testing/memory-storage"; import { waitForHydration } from "../../testing/wait-for-hydration"; import { persistStore } from "./zustand"; @@ -87,15 +88,5 @@ describe("persistStore", () => { }); describe("zustand dependency isolation", () => { - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file( - new URL("./zustand.ts", import.meta.url), - ).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((match) => match[1]); - for (const importPath of relativeImports) { - expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./zustand.ts", import.meta.url)); }); diff --git a/src/adapters/transport/crosstab.test.ts b/src/adapters/transport/crosstab.test.ts index f16d5e3..0230d34 100644 --- a/src/adapters/transport/crosstab.test.ts +++ b/src/adapters/transport/crosstab.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import { persistSource } from "../../core/persist-core"; import type { PersistStorage, StorageValue } from "../../core/persist-core"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; import { createMockSource } from "../../testing/mock-source"; import { waitForHydration } from "../../testing/wait-for-hydration"; import { createBroadcastCrossTab } from "./crosstab"; @@ -197,15 +198,5 @@ describe("createBroadcastCrossTab", () => { expect(fired).toBe(false); }); - it("imports only from core (no cross-adapter coupling)", async () => { - const source = await Bun.file( - new URL("./crosstab.ts", import.meta.url), - ).text(); - const relativeImports = [ - ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), - ].map((match) => match[1]); - for (const importPath of relativeImports) { - expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); - } - }); + itImportsOnlyFromCore(new URL("./crosstab.ts", import.meta.url)); }); diff --git a/src/testing/assert-core-only-imports.ts b/src/testing/assert-core-only-imports.ts new file mode 100644 index 0000000..e118f42 --- /dev/null +++ b/src/testing/assert-core-only-imports.ts @@ -0,0 +1,19 @@ +import { expect, it } from "bun:test"; + +/** + * The per-entry self-check: every relative import in the adapter source + * resolves into `core/` — pins no cross-adapter coupling (the isolation + * invariant documented in `docs/architecture.md`). Call inside a + * `describe(...)`. Test-only; not shipped in `dist/`. + */ +export function itImportsOnlyFromCore(sourceUrl: URL): void { + it("imports only from core (no cross-adapter coupling)", async () => { + const source = await Bun.file(sourceUrl).text(); + const relativeImports = [ + ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ].map((match) => match[1]); + for (const importPath of relativeImports) { + expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); + } + }); +} From a882d082a786b8b2b80ae550b6a977e169f15476 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Mon, 6 Jul 2026 09:43:11 +0300 Subject: [PATCH 64/77] =?UTF-8?q?harden:=20CodeRabbit=20nitpick=20triage?= =?UTF-8?q?=20=E2=80=94=20apply=209,=20push=20back=203,=20rest=20outdated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/ci.yml | 20 ++++++++++++ .size-limit.json | 18 +++++++++++ docs/architecture.md | 2 +- docs/glossary.md | 18 +++++------ src/adapters/backends/encrypted.test.ts | 41 +++++++++++++++++++++++++ src/adapters/backends/node-fs.ts | 15 ++++++--- src/adapters/sources/valtio.test.ts | 2 +- src/adapters/sources/valtio.ts | 2 +- src/adapters/transport/crosstab.ts | 2 +- src/testing/assert-core-only-imports.ts | 8 +++++ 10 files changed, 111 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 302841b..cd2cd46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - name: Setup uses: ./.github/actions/setup @@ -61,6 +63,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - name: Setup uses: ./.github/actions/setup @@ -76,6 +80,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - name: Setup uses: ./.github/actions/setup @@ -91,6 +97,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - name: Setup uses: ./.github/actions/setup @@ -106,6 +114,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - name: Setup uses: ./.github/actions/setup @@ -121,6 +131,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - name: Setup uses: ./.github/actions/setup @@ -136,6 +148,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - name: Setup uses: ./.github/actions/setup @@ -154,6 +168,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - name: Setup uses: ./.github/actions/setup @@ -169,10 +185,14 @@ jobs: # An advisory-API outage doesn't block; malware is out of scope # (provenance + frozen lockfile cover that). name: 🛡 Audit + needs: skip-ci + if: needs['skip-ci'].outputs.skip != 'true' runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - name: Setup uses: ./.github/actions/setup diff --git a/.size-limit.json b/.size-limit.json index 554690e..b237d57 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -16,5 +16,23 @@ "path": "dist/codecs/seroval.mjs", "limit": "2 KB", "gzip": true + }, + { + "name": "encrypted", + "path": "dist/backends/encrypted.mjs", + "limit": "2 KB", + "gzip": true + }, + { + "name": "compressed", + "path": "dist/backends/compressed.mjs", + "limit": "2 KB", + "gzip": true + }, + { + "name": "zod", + "path": "dist/codecs/zod.mjs", + "limit": "2 KB", + "gzip": true } ] diff --git a/docs/architecture.md b/docs/architecture.md index 4e01fe6..9d01b17 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,7 +68,7 @@ One API. Sync backends (localStorage) settle hydration before first paint; async ## Beyond Query-persister parity -`buster` / `maxAge` / `throttleMs` / `retryWrite` ship alongside versioned `migrate`, cross-tab sync (`crossTab` + `onCrossTabRemove`), the hydration signal, and the codec seam — beyond what TanStack Query's `persistQuery_client` offers. **Deliberate `maxAge` default divergence:** prefs shouldn't silently expire, so `maxAge` is opt-in rather than default-on. +`buster` / `maxAge` / `throttleMs` / `retryWrite` ship alongside versioned `migrate`, cross-tab sync (`crossTab` + `onCrossTabRemove`), the hydration signal, and the codec seam — beyond what TanStack Query's `persistQueryClient` offers. **Deliberate `maxAge` default divergence:** prefs shouldn't silently expire, so `maxAge` is opt-in rather than default-on. ## Publishing & API docs diff --git a/docs/glossary.md b/docs/glossary.md index 7109b7b..3f95d3d 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -39,15 +39,15 @@ ## Options (non-exhaustive — see JSDoc) -| Term | Definition | -| ----------------- | ----------------------------------------------------------------------- | -| **skipHydration** | Skip the on-create hydration read (consumer rehydrates manually). | -| **throttleMs** | Trailing write throttle window. | -| **maxAge** | Opt-in expiry (deliberate divergence: prefs shouldn't silently expire). | -| **buster** | Cache-busting key mixed into the storage key. | -| **migrate** | Versioned migration of a persisted envelope on read. | -| **crossTab** | Cross-tab sync via storage events; rehydrate on remote write. | -| **retryWrite** | Retry loop for failed writes, guarded by the generation guard. | +| Term | Definition | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **skipHydration** | Skip the on-create hydration read (consumer rehydrates manually). | +| **throttleMs** | Trailing write throttle window. | +| **maxAge** | Opt-in expiry (deliberate divergence: prefs shouldn't silently expire). | +| **buster** | Cache-busting key mixed into the storage key. | +| **migrate** | Versioned migration of a persisted envelope on read. | +| **crossTab** | Cross-tab sync via `storage` events, or via a `BroadcastChannel` bridge (`createBroadcastCrossTab`) for backends that fire none (IndexedDB); rehydrate on remote write. | +| **retryWrite** | Retry loop for failed writes, guarded by the generation guard. | ## Flagged ambiguities diff --git a/src/adapters/backends/encrypted.test.ts b/src/adapters/backends/encrypted.test.ts index 603087d..d0c8b6a 100644 --- a/src/adapters/backends/encrypted.test.ts +++ b/src/adapters/backends/encrypted.test.ts @@ -92,6 +92,47 @@ describe("createEncryptedStorage", () => { persist2.destroy(); }); + it("composed: a wrong-key hydrate routes to onError phase 'hydrate' and does NOT clearCorrupt (backend reject, not codec throw)", async () => { + const key1 = await makeKey(); + const key2 = await makeKey(); + const name = "encrypted-wrong-key"; + + // Seed an encrypted blob with key1 (await the async encrypt directly). + const seedStorage = createStorage<{ count: number }>( + () => createEncryptedStorage(() => memory, { key: key1 })!, + serovalCodec(), + )!; + await seedStorage.setItem(name, { state: { count: 9 }, version: 0 }); + expect(memory.getItem(name)).not.toBeNull(); + + // Hydrate the same key with key2 — AES-GCM decrypt throws in the backend's + // async getItem, which persist-core reports as phase "hydrate". This is NOT + // the codec's clearCorruptOnFailure path (that fires only when the *codec* + // throws parsing a raw value), so the encrypted blob stays in storage. + // `skipHydration` + `await rehydrate()` (not `waitForHydration`) because the + // decrypt reject settles on a macrotask (crypto.subtle) — microtask polling + // never yields to it. + const errors: Array<{ phase: string }> = []; + const wrongStorage = createStorage<{ count: number }>( + () => createEncryptedStorage(() => memory, { key: key2 })!, + serovalCodec(), + { clearCorruptOnFailure: true }, + )!; + const source = createMockSource({ count: 0 }); + const persist = persistSource(source, { + name, + storage: wrongStorage, + skipHydration: true, + onError: (_e, ctx) => errors.push({ phase: ctx.phase }), + }); + await persist.rehydrate(); + + expect(errors).toContainEqual({ phase: "hydrate" }); + expect(memory.getItem(name)).not.toBeNull(); + expect(source.state.count).toBe(0); + persist.destroy(); + }); + it("returns undefined when crypto.subtle is unavailable", () => { const originalCrypto = globalThis.crypto; try { diff --git a/src/adapters/backends/node-fs.ts b/src/adapters/backends/node-fs.ts index 1c20207..3972a64 100644 --- a/src/adapters/backends/node-fs.ts +++ b/src/adapters/backends/node-fs.ts @@ -54,8 +54,11 @@ export function nodeFsStateStorage( getItem: async (name) => { try { return await readFile(pathFor(name), "utf8"); - } catch { - return null; + } catch (err) { + // Missing key → null. Other failures (EACCES, EISDIR, disk) must + // surface — swallowing them would mask real I/O problems as "no data". + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw err; } }, setItem: async (name, value) => { @@ -65,8 +68,12 @@ export function nodeFsStateStorage( removeItem: async (name) => { try { await unlink(pathFor(name)); - } catch { - /* missing file — nothing to remove */ + } catch (err) { + // Idempotent: a missing file is nothing to remove. Other failures + // (permissions, etc.) rethrow — the caller's write/remove contract + // shouldn't silently swallow them. + if ((err as NodeJS.ErrnoException).code === "ENOENT") return; + throw err; } }, }; diff --git a/src/adapters/sources/valtio.test.ts b/src/adapters/sources/valtio.test.ts index 4dd283f..4179c2c 100644 --- a/src/adapters/sources/valtio.test.ts +++ b/src/adapters/sources/valtio.test.ts @@ -9,7 +9,7 @@ type MockProxy<T extends object> = T & { __state: T; }; -mock.module("valtio", () => ({ +mock.module("valtio/vanilla", () => ({ snapshot: <T extends object>(proxyObj: T): T => { const state = (proxyObj as MockProxy<T>).__state ?? proxyObj; return { ...state }; diff --git a/src/adapters/sources/valtio.ts b/src/adapters/sources/valtio.ts index d8c6b01..6912467 100644 --- a/src/adapters/sources/valtio.ts +++ b/src/adapters/sources/valtio.ts @@ -1,5 +1,5 @@ // valtio source adapter — peer `valtio` >=1.0.0. -import { snapshot, subscribe } from "valtio"; +import { snapshot, subscribe } from "valtio/vanilla"; import type { PersistApi, PersistOptions } from "../../core/persist-core"; import { persistSource } from "../../core/persist-core"; diff --git a/src/adapters/transport/crosstab.ts b/src/adapters/transport/crosstab.ts index 5bc48f6..8016ea8 100644 --- a/src/adapters/transport/crosstab.ts +++ b/src/adapters/transport/crosstab.ts @@ -93,7 +93,7 @@ export function createBroadcastCrossTab<S>( .then(() => { postMessage({ key: name, - newValue: 1 as unknown as string, + newValue: "1", storageArea: null, }); }) diff --git a/src/testing/assert-core-only-imports.ts b/src/testing/assert-core-only-imports.ts index e118f42..88a68b2 100644 --- a/src/testing/assert-core-only-imports.ts +++ b/src/testing/assert-core-only-imports.ts @@ -5,12 +5,20 @@ import { expect, it } from "bun:test"; * resolves into `core/` — pins no cross-adapter coupling (the isolation * invariant documented in `docs/architecture.md`). Call inside a * `describe(...)`. Test-only; not shipped in `dist/`. + * + * Covers the three relative-import shapes a TS module can use: `from "..."`, + * bare side-effect `import "..."`, and dynamic `import("...")`. The + * `../../core/` prefix assumes the flat adapter layout `src/adapters/<seam>/<name>.ts` + * (two levels deep) — enforced by the architecture-priming rule, so a deeper + * adapter would need this prefix adjusted. */ export function itImportsOnlyFromCore(sourceUrl: URL): void { it("imports only from core (no cross-adapter coupling)", async () => { const source = await Bun.file(sourceUrl).text(); const relativeImports = [ ...source.matchAll(/from\s+["'](\.\.?\/[^"']+)["']/g), + ...source.matchAll(/import\s+["'](\.\.?\/[^"']+)["']/g), + ...source.matchAll(/import\s*\(\s*["'](\.\.?\/[^"']+)["']\s*\)/g), ].map((match) => match[1]); for (const importPath of relativeImports) { expect(importPath).toMatch(/^\.\.\/\.\.\/core\//); From 2e516b1fdd44f4016501cc22aabcabe5e76ee006 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Mon, 6 Jul 2026 11:13:20 +0300 Subject: [PATCH 65/77] harden: Tier 1 fixes from triangulated harden-pr audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/encrypted-compressed.md | 2 +- README.md | 6 +- docs/audits/2026-07-04-docs-adapters-roi.md | 6 +- ...6-07-06-triangulated-harden-pr-findings.md | 146 ++++++++++++++++++ .../GPT-5.5-medium-harden-pr-full-findings.md | 100 ++++++++++++ .../Opus-4.8-high-harden-pr-full-findings.md | 95 ++++++++++++ .../composer-2.5-fast-harden-pr-full.md | 143 +++++++++++++++++ docs/plans/remaining-roi.md | 12 +- src/adapters/backends/encrypted.ts | 3 +- src/adapters/backends/secure-store.test.ts | 29 ++++ src/adapters/backends/secure-store.ts | 13 +- src/adapters/frameworks/svelte-store.ts | 2 +- src/adapters/frameworks/svelte.ts | 2 +- src/adapters/sources/zustand.ts | 1 + src/core/hydration.ts | 2 +- 15 files changed, 542 insertions(+), 20 deletions(-) create mode 100644 docs/audits/2026-07-06-triangulated-harden-pr-findings.md create mode 100644 docs/audits/GPT-5.5-medium-harden-pr-full-findings.md create mode 100644 docs/audits/Opus-4.8-high-harden-pr-full-findings.md create mode 100644 docs/audits/composer-2.5-fast-harden-pr-full.md diff --git a/.changeset/encrypted-compressed.md b/.changeset/encrypted-compressed.md index 8be27d2..9270f19 100644 --- a/.changeset/encrypted-compressed.md +++ b/.changeset/encrypted-compressed.md @@ -4,7 +4,7 @@ Add two zero-dep storage wrappers over the `StateStorage` seam — both async web-global adapters (no peer dep), composing with `createStorage(backend, codec)`: -- `./backends/encrypted` — `createEncryptedStorage(getStorage, { key })`: AES-GCM via WebCrypto (`crypto.subtle`). Each stored value is `base64(iv).base64(ciphertext)`; the AES-GCM auth tag means a wrong key or tampered ciphertext throws on decrypt → persist-core's corrupt-payload path returns `null` (or `clearCorruptOnFailure` removes the key). Returns `undefined` when `crypto.subtle` is unavailable so `createStorage` collapses to the no-op `PersistApi`. +- `./backends/encrypted` — `createEncryptedStorage(getStorage, { key })`: AES-GCM via WebCrypto (`crypto.subtle`). Each stored value is `base64(iv).base64(ciphertext)`; the AES-GCM auth tag means a wrong key or tampered ciphertext throws on decrypt → the backend's async `getItem` rejects → persist-core reports it via `onError` phase `"hydrate"` (NOT the codec's `clearCorruptOnFailure` path, which only fires when the codec throws parsing a raw value). Returns `undefined` when `crypto.subtle` is unavailable so `createStorage` collapses to the no-op `PersistApi`. - `./backends/compressed` — `createCompressedStorage(getStorage, { format? })`: native `CompressionStream`/`DecompressionStream` (`gzip` | `deflate` | `deflate-raw`, default `gzip`); output is base64 so it stays string-wire. Returns `undefined` when the stream APIs are unavailable. Stacks with `createEncryptedStorage` (compress-then-encrypt is the standard order). **Design note:** encryption + compression are backend **wrappers**, not sync `StorageCodec`s, because `crypto.subtle` and the stream APIs are async and the `StorageCodec` seam is sync. The codec serializes the envelope (sync); the wrapper encrypts/compresses the serialized string (async). diff --git a/README.md b/README.md index caf9201..21c5f33 100644 --- a/README.md +++ b/README.md @@ -499,12 +499,12 @@ Pick by sync-vs-async (does it gate UI?), cross-tab needs, and whether you want | MMKV (RN) | ✓ | ✗ | ✗ | large | ✗ | `./backends/mmkv` | | Secure Store (Expo) | ✗ | ✗ | ✗ | ~2KB/key | ✓ | `./backends/secure-store` | | Node fs | ✗ | ✗ | ✗ | disk | ✓ | `./backends/node-fs` | -| Encrypted (wrapper) | ✗ | inherits | ✗ | inherits | ✓ | `./backends/encrypted` | -| Compressed (wrapper) | ✗ | inherits | ✗ | inherits (smaller) | ✓ | `./backends/compressed` | +| Encrypted (wrapper) | ✗ | ✗ | ✗ | inherits | ✓ | `./backends/encrypted` | +| Compressed (wrapper) | ✗ | ✗ | ✗ | inherits (smaller) | ✓ | `./backends/compressed` | | localStorage | ✓ | ✓ | ✗ | ~5MB | ✗ | core `createJSONStorage` | | sessionStorage | ✓ | ✗ | ✗ | ~5MB | ✗ | core `createJSONStorage` | -IDB has no storage events — pair `./transport/crosstab` for cross-tab sync. +IDB has no storage events — pair `./transport/crosstab` for cross-tab sync. Encrypted/compressed wrappers over `localStorage` also don't receive native `storage` events (`raw` is the wrapper, not `localStorage`) — use `./transport/crosstab` for cross-tab there too. ## Choosing a codec diff --git a/docs/audits/2026-07-04-docs-adapters-roi.md b/docs/audits/2026-07-04-docs-adapters-roi.md index 41a02cf..8e2326e 100644 --- a/docs/audits/2026-07-04-docs-adapters-roi.md +++ b/docs/audits/2026-07-04-docs-adapters-roi.md @@ -111,7 +111,7 @@ Ordered by ROI = impact ÷ effort. **Effort:** S / M / L. **Status as of 2026-07 | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | | ✅ 1 | Define "hydration-aware" in the README — one paragraph + a "what flashes without it" diagram. Fixes the single biggest tone gap. | S | | ✅ 2 | Add a complete IDB + React + `useHydrated` + `destroy()` walkthrough to the README. Closes the gap that justifies the library's existence. | S | -| ✅ 3 | Link the generated `docs/api/` site from the README + publish to GitHub Pages (`.nojekyll` already present). | S | +| 3 | Link the generated `docs/api/` site from the README + publish to GitHub Pages (`.nojekyll` already present). | S | | ✅ 4 | Document `createPersistRegistry` + clear-all-on-logout with a recipe. | S | | ✅ 5 | Add recipes for `partialize`, `merge`, `retryWrite`, `throttleMs`, `maxAge`, `buster` — six hidden powers, one short block each. | S | | ✅ 6 | BroadcastChannel → `CrossTabEventTarget` bridge adapter for IDB cross-tab. Seam exists (`src/core/persist-core.ts:113`); IDB fires no `storage` events so `crossTab` is silently broken on IDB without it (`src/adapters/backends/idb.ts:62`, `skill:116`). | S | @@ -140,7 +140,7 @@ Ordered by ROI = impact ÷ effort. **Effort:** S / M / L. **Status as of 2026-07 | # | Action | Effort | | ----- | ------------------------------------------------------------------------------------------------------- | ------ | -| 20 | npm provenance + signing in `release.yml` (`id-token: write` + `--provenance`). | S | +| ✅ 20 | npm provenance + signing in `release.yml` (`id-token: write` + `--provenance`). | S | | 21 | Test matrix — real browser (Playwright) + Safari + SSR-framework (Next.js hydration). Today jsdom only. | M | | ✅ 22 | Coverage gate. | S | | ✅ 23 | Bundle-size badge + `size-limit` gate. | S | @@ -155,7 +155,7 @@ Ordered by ROI = impact ÷ effort. **Effort:** S / M / L. **Status as of 2026-07 | 28 | React ergonomics layer — `<PersistProvider>` + context + `usePersisted(store)` selector binding + auto-`destroy()`. `src/adapters/frameworks/react.ts:22` signals this is intentionally deferred. | M-L | | 29 | StackBlitz / CodeSandbox playground embedded in docs site. | M | | 30 | OPFS + SQLite-WASM + Cloudflare KV/Durable Objects storage adapters. | M-L | -| 31 | Migration-chain helper — `createMigrationChain({...})`; today `migrate` is a single callback, v0→v1→v2 chaining is user-written. | M | +| ✅ 31 | Migration-chain helper — `createMigrationChain({...})`; today `migrate` is a single callback, v0→v1→v2 chaining is user-written. | M | | ✅ 32 | Continue the TanStack upstream pitch (`docs/plans/upstream-tanstack-pitch.md`) — adapter breadth (#13, #9) + docs polish (#1, #2, #12) are the leverage. Ongoing. | ongoing | --- diff --git a/docs/audits/2026-07-06-triangulated-harden-pr-findings.md b/docs/audits/2026-07-06-triangulated-harden-pr-findings.md new file mode 100644 index 0000000..2a14c08 --- /dev/null +++ b/docs/audits/2026-07-06-triangulated-harden-pr-findings.md @@ -0,0 +1,146 @@ +# Triangulated harden-pr findings — final summary + +**Date:** 2026-07-06 +**HEAD:** `a882d082a786b8b2b80ae550b6a977e169f15476` · **Branch:** `audit/docs-adapters-roi` · [PR #7](https://github.com/stainless-code/persist/pull/7) +**Sources triangulated:** + +- [composer-2.5-fast](./composer-2.5-fast-harden-pr-full.md) (C) +- [GPT-5.5-medium](./GPT-5.5-medium-harden-pr-full-findings.md) (G) +- [Opus-4.8-high](./Opus-4.8-high-harden-pr-full-findings.md) (O) + +**Method:** each finding re-verified against current code (file:line) before inclusion. Cross-report refs use C/G/O prefixes. Verdicts: ✅ confirmed · ⚠️ partial · ❌ rejected · 🕒 outdated · 💭 defer. + +**Production bar:** **not met** — 3 confirmed runtime bugs in shipped code (2 @example/adapter, 1 cross-tab echo), 1 docs-governance blocker (false ✅), plus a cluster of S-effort doc/JSDoc nits. Core engine hot paths verified clean by all three reviewers. + +--- + +## TL;DR + +Three reviewers converge on a small set of real, S-effort, in-bounds fixes — most shipped `@example` blocks are broken (encrypted `clearCorruptOnFailure` misplacement, secure-store colon-key throw, svelte inverted gate, zustand missing import) and the audit/plan ✅ marks drifted from reality (API docs hosting, migration-chain, npm provenance). Two deeper core-engine bugs (clearStorage doesn't cancel pending writes; writes during async hydration are permanently skipped) and one crosstab echo loop are **confirmed real but narrow-trigger / core-behavior-changing** — flagged for a decision, not blind application. The mock-fidelity + size-gate items overlap the already-triaged CodeRabbit nitpicks. + +--- + +## Confirmed actionable items + +### Tier 1 — fix before ship (S, in-bounds, confirmed bugs) + +| # | File:line | Finding | Raised | Fix | +| --- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `src/adapters/backends/encrypted.ts:35` | `@example` passes `clearCorruptOnFailure: true` to `persistStore(...)` — it's a `createStorage` option, not `PersistOptions`; copied snippet won't typecheck and the flag is inert there | O:M1, G:#5 | move into `createStorage<Prefs>(..., ..., { clearCorruptOnFailure: true })` | +| 2 | `src/adapters/backends/secure-store.ts:24,42` | forwards `name` verbatim to `expo-secure-store` (keys must match `/^[\w.-]+$/`); the adapter's own `@example` `name: "auth:token:v1"` throws at runtime. Mock accepts any key so the test never catches it | O:M2, G:#4 | sanitize `:` → `_` in the adapter (new adapter only; no cross-cutting change) **or** fix the example + document the charset constraint | +| 3 | `src/adapters/frameworks/svelte.ts:24`, `svelte-store.ts:16` | `@example` renders `<Skeleton/>` when `hydrated` is **true** — inverted; ships wrong into published hovers | G:#7 | swap to `{#if !hydrated}<Skeleton/>{:else}<Prefs/>{/if}` | +| 4 | `src/adapters/sources/zustand.ts:16` | `@example` uses `createJSONStorage` but never imports it | O:n3 | add `import { createJSONStorage } from "@stainless-code/persist";` | +| 5 | `.changeset/encrypted-compressed.md:7` | claims wrong-key/tampered decrypt → "corrupt-payload path returns null (or `clearCorruptOnFailure` removes the key)" — wrong: decrypt rejects in backend `getItem` → phase `"hydrate"`; `clearCorruptOnFailure` is inert (codec-only) | G:#6 | correct the changeset to match the `encrypted.ts` JSDoc (fixed in `131b213`) | +| 6 | `README.md:502–503` | "Choosing a storage" marks encrypted/compressed cross-tab as `inherits`, but `createStorage` sets `raw` to the **wrapper** while browser `StorageEvent.storageArea` is the real `localStorage` → the identity guard rejects every native event. IDB + `createBroadcastCrossTab` path is unaffected | C:M1, G:#3 | change `inherits` → `✗ (native storage events; use ./transport/crosstab bridge)` + add a regression test (m1) | +| 7 | audit `#3` (`docs/audits/2026-07-04…:114`), `README.md:882`, `docs/plans/remaining-roi.md:48` | `#3` is ✅ ("Link docs/api + publish to GitHub Pages, `.nojekyll` already present") but README says "not hosted yet", no Pages workflow exists, **`.nojekyll` is not tracked** | C:B2/M2, G:#8/#12 | un-✅ `#3` (or split: ✅ the link, leave publish open); remove the `.nojekyll already present` claim from `remaining-roi.md` + audit | +| 8 | audit `#31` (`…:158`) and `#20` (`…:143`) | `#31` migration-chain + `#20` npm provenance are shipped/implemented (commits `2e9247f`, `0c4ea17`) but remain un-✅ in the audit | C:M4/M5, G:#9/#13 | ✅ both rows (provenance note "implemented — pending first-release verify") | +| 9 | `src/core/hydration.ts:2` | comment "see `./frameworks/react`" stale after refold — module is now in `src/core/` | O:n5 | `./frameworks/react` → `../adapters/frameworks/react` | +| 10 | `docs/plans/remaining-roi.md:66` | numbering skips `### 6` (`### 5` → `### 7`) | O:n6 | renumber | + +### Tier 2 — confirmed core-engine bugs (need a decision, not blind apply) + +These are real but change core runtime behavior; GPT-5.5 raised them, Composer + Opus did not. Narrow triggers, but correct bugs. + +| # | File:line | Finding | Raised | Notes | +| --- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 11 | `src/core/persist-core.ts:1207` | `clearStorage()` only calls `removeItem` — does **not** bump `writeGeneration` or cancel the pending throttled write / in-flight `retryWrite`. A pending timer or retry loop re-writes the key after explicit clear/logout; affects `PersistRegistry.clearAll()` too | G:#1 | Fix: `clearStorage` → `cancelPendingWrite()` + `writeGeneration++` before `removeItem`. In-bounds bug fix, but it's a core-engine behavior change — decide before applying | +| 12 | `src/core/persist-core.ts:954` | source subscription returns early while `!hasHydratedFlag`; a `setState` during an async hydrate with no usable payload is dropped and never re-scheduled (`hadPendingWrite` tracks only cancelled throttle timers, not gate-skipped events) | G:#2 | Fix: track a "skipped during hydrate" flag and `scheduleWrite()` after settle. Core-engine change — decide before applying | +| 13 | `src/adapters/transport/crosstab.ts:106` | `wrap().removeItem` broadcasts removal after every successful `removeItem`, even when the key was already absent. With `skipPersist` + `onCrossTabRemove` reset, two tabs can bounce remove notifications / reset writes indefinitely | G:#10 | Fix options: (a) `getItem` before `removeItem` and skip the post if absent; (b) suppress re-broadcast of a just-received removal. Design decision — crosstab-bridge-specific | + +### Tier 3 — should fix (test gaps, structure, perf — S–M, in-bounds) + +| # | File:line | Finding | Raised | Effort | +| --- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ----------- | +| 14 | `src/core/persist-core.test.ts:22` | zero-dep gate scans `persist-core.ts` only; `hydration.ts` (other half of the `.` core) unguarded (zero imports today → no active violation) | C:m2, O:M3 | S | +| 15 | `src/adapters/backends/node-fs.ts:45` | `..`/`.`/empty-key refusal guard (security-relevant) has no test | G:#16, O:m1 | S | +| 16 | `src/adapters/transport/crosstab.ts:100,112` | failed-write/remove broadcast suppression (`.catch` branches, added `131b213`) uncovered | C:m9, G:#17 | S | +| 17 | `src/adapters/transport/crosstab.ts:67` | `removeEventListener` teardown path untested | C:m10 | S | +| 18 | `src/adapters/backends/encrypted.ts:53` | invalid-backend shape guard uncovered | C:m11 | S | +| 19 | `src/adapters/backends/encrypted.ts:89` | malformed ciphertext (missing `.`) branch untested (wrong-key is covered) | C:m12, O:n7 | S | +| 20 | `src/adapters/backends/compressed.ts:51` | invalid-backend shape guard uncovered | C:m13 | S | +| 21 | `src/adapters/backends/compressed.test.ts` | no corrupt/non-base64 decompress failure test | C:m14 | S | +| 22 | `encrypted`+`compressed` | documented compress-then-encrypt stack has no integration test | C:m15 | M | +| 23 | `src/adapters/frameworks/preact.test.ts:5` | `useSyncExternalStore` mock ignores `subscribe` → no rerender-on-flip / unsubscribe-cleanup coverage; no `tests-dom/` parity for preact | C:M7, G:#14, O:m3 | M | +| 24 | `src/adapters/frameworks/svelte.test.ts:39` | runes `createSubscriber` reactive ownership untested (needs a Svelte runtime — out of bun scope) | C:M8, G:#15, O:n10 | L · defer | +| 25 | `src/adapters/frameworks/angular.test.ts:25` | mock runs `effect()` synchronously → hides the async gap `angular.ts:30` guards | O:n8 | S–M · defer | +| 26 | `src/core/persist-core.test.ts` (migration chain) | `onOlder: "throw"` has no `persistSource` e2e (unlike discard / throwing-step) | C:n5 | S | +| 27 | `encrypted.ts:102`, `compressed.ts:100` | `toBase64` O(n²) per-byte string concat — latency on large payloads | C:m17 | S | +| 28 | `.size-limit.json` | no `transport/crosstab` gate (encrypted/compressed/zod added in `a882d08`) | O:m6 | S | +| 29 | `.cursor/skills/codemap/` | real directory containing a `SKILL.md` symlink, not a symlink to `.agents/skills/codemap` (violates agents-first convention; future siblings won't mirror) | G:#11 | S | +| 30 | `src/adapters/sources/mobx.ts:31` | `Object.assign(observable, next)` writes without `runInAction` → throws under `configure({ enforceActions: "always" })`; mock never enforces actions | O:m4, (CodeRabbit) | S–M | + +### Tier 4 — doc / JSDoc / public-API nits (S, fix when touching the file) + +| # | File:line | Finding | Raised | +| --- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | --- | +| 31 | `README.md:57–58` | install table lists both svelte subpaths as peer `svelte` with no version split (entry table + architecture require >=5.7 vs >=3) | C:M6 | +| 32 | `README.md:403` | entry table omits `toHydrationSignal` / `alwaysHydratedSignal`; labels `HydrationSignal` as `(`hydration`)` | C:m7 | +| 33 | `README.md:3` | headline undersells the shipped surface (7 framework + 5 source + 6 backend + zod + crosstab) | O:m5 | +| 34 | `README.md` | `alwaysHydratedSignal` exported from core, no README mention | C:m3 | +| 35 | `src/adapters/codecs/zod.ts:24` | `zodCodec` lacks `@example` | C:m5 | +| 36 | `src/adapters/codecs/seroval.ts:20` | `serovalCodec` lacks `@example` | C:n3 | +| 37 | `src/core/persist-core.ts:422` | `CreateMigrationChainOptions.onNewer`/`onOlder` use prose defaults, not `@default` tags | C:m6 | +| 38 | `src/adapters/backends/encrypted.ts:15` | `clearCorruptOnFailure` JSDoc wording self-contradictory ("non-corrupt raw" — should be "corrupt/unparseable") | O:n1 | +| 39 | `react.ts:34`, `preact.ts` | `@example` fenced ` ```ts ` but contains JSX → should be ` ```tsx ` | O:n2 | +| 40 | `src/adapters/frameworks/preact.ts:7` | `UseHydratedResult.hydrated` has no JSDoc (react.ts parallel does) | O:n4 | +| 41 | `src/adapters/codecs/zod.ts:20` | JSDoc cites "persist-core's corrupt-payload path" — prefer consumer-facing language | C:n4 | +| 42 | `.changeset/node-fs-pack-gate.md:7` | mixes consumer `./backends/node-fs` API with maintainer CI internals (attw/knip/publint) | C:m8 | +| 43 | `.changeset/subpath-mirror-folders.md:14` | maintainer internals (tsdown keys, `persist-` prefix) in consumer changeset | C:n2 | +| 44 | `docs/glossary.md:5` | no `transport/` seam term (`./transport/crosstab`) | C:n1 | +| 45 | `docs/audits/2026-07-04…:11,635` | TL;DR + appendix C describe pre-ship gaps without a "historical snapshot" banner; appendix C.2 documents flat legacy subpaths without annotation | C:M3/m4 | M | + +--- + +## Rejected / by-design / dropped (with reason) + +| Claim | Verdict | Reason | +| ----------------------------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `.changeset/subpath-mirror-folders.md` `minor` vs `Breaking` body = semver blocker (C:B1) | ⚠️ judgment call, **not a blocker** | Package is 0.x; a `major` changeset would jump to 1.0.0 (changesets default). `minor` to stay pre-1.0 is defensible; the "Breaking" body text communicates the import-path migration to consumers. Keep `minor`; ensure the Breaking callout stays prominent | +| Extract `toBase64`/`fromBase64` to `core/` (CodeRabbit) | ❌ reject | Opus i2 confirms: 2 consumers < the 3+ bar in `architecture-priming`; a core export spends the zero-dep-core budget; a shared adapter util violates per-entry `assert-core-only-imports`. **Keep the duplicate** | +| `zustand.test.ts` mock `setState` full-replace (O:n9) | 💭 info | Safe today — `PersistableSource.setState` contract returns the full state; zustand function-setState with full-state return ≡ replace. Distinction invisible | +| `createMigrationChain` return type `undefined as unknown as S` cast (O:i1) | ❌ out of bounds | Correct when plugged into `PersistOptions.migrate`; fixing = semantic API change | +| `svelte >=3.0.0` package peer vs `>=5.7` for runes (all) | 🕒 LEDGER § Deferred | npm can't express per-subpath peer ranges; documented per-subpath in README/JSDoc/changeset. Needs a package split to fix | +| Audit historical counts ("5 subpath entries", flat subpaths) | 🕒 LEDGER § Rejections | Dated audit record; counts accurate to 2026-07-04 | +| `retryWrite` uncapped | by-design | JSDoc states the callback IS the termination policy | +| `mobx` `toJS` cost before `partialize` (G:#18) | by-design | `PersistableSource.getState` must return full state; `partialize` projects after. Inherent to the contract; document if needed | +| `crosstab` per-write post no coalescing (C:m18) | 💭 defer | Mitigated by `throttleMs`; design note, not a bug | + +--- + +## Recommended fix order + +1. **Tier 1 (1–10)** — one pass: `@example`/JSDoc corrections (1,3,4,9), changeset text (5), secure-store key handling (2), README matrix (6), audit ✅ reconciliation + `.nojekyll` prose (7,8,10). All S, in-bounds, no runtime behavior change. +2. **Tier 2 (11–13)** — decision required: confirm the three core-engine/crosstab bugs and the intended fix shape before applying (they change observable runtime behavior — outside the "no behavior change" harden guardrail, but legitimate bug fixes once approved). +3. **Tier 3 (14–30)** — test-gap + structure + perf pass; highest-value first: 14 (hydration gate), 15 (node-fs traversal), 27 (toBase64), 28 (crosstab size gate), 29 (codemap symlink), 30 (mobx runInAction). +4. **Tier 4 (31–45)** — doc/JSDoc nits, batch when touching each file. + +--- + +## Passing (no action — confirmed by all three reviewers) + +- Core zero-dep gate on `persist-core.ts`; no cross-adapter imports; 22/22 adapter `itImportsOnlyFromCore` checks; 202 unit + 4 DOM tests pass. +- `exports` ↔ `tsdown.config.ts` ↔ `typedoc.json` ↔ `architecture.md` ↔ README subpath tables aligned (23 subpaths + core). +- CI: coverage (90% gate, ~98% aggregate), size-limit, pack validation (attw+publint+knip), audit high/critical gate, trusted publishing. +- Hot paths verified clean: `createMigrationChain` O(k)+O(version); trailing throttle single-timer + generation supersede; crosstab `close()` clears handler map; `persist-core.ts` zero value imports. + +--- + +## Cross-reviewer agreement matrix + +| Finding | C | G | O | Verdict | +| ----------------------------------------------------------- | ------------ | --- | --- | ---------------------------------------- | +| encrypted `@example` `clearCorruptOnFailure` misplacement | – | ✅ | ✅ | confirmed | +| secure-store colon-key throw | – | ✅ | ✅ | confirmed | +| encrypted/compressed wrapper breaks native cross-tab events | ✅ | ✅ | – | confirmed | +| svelte `@example` inverted gate | – | ✅ | – | confirmed | +| audit `#3` false ✅ + `.nojekyll` missing | ✅ | ✅ | – | confirmed | +| audit `#31`/`#20` un-✅ despite shipped | ✅ | ✅ | – | confirmed | +| preact mock ignores `subscribe` | ✅ | ✅ | ✅ | confirmed (test gap) | +| svelte runes reactive ownership untested | ✅ | ✅ | ✅ | confirmed (defer — needs Svelte runtime) | +| `clearStorage` doesn't cancel pending writes | – | ✅ | – | confirmed real (narrow) | +| writes during async hydration skipped | – | ✅ | – | confirmed real (narrow) | +| crosstab remove-echo between tabs | – | ✅ | – | confirmed real (narrow) | +| `hydration.ts` zero-dep gate gap | ✅ | – | ✅ | confirmed (low risk) | +| node-fs traversal guard untested | – | ✅ | ✅ | confirmed | +| changeset `subpath-mirror` `minor` vs Breaking | ✅ | – | – | judgment call — not a blocker | +| base64 dedup extract | (CodeRabbit) | – | ❌ | reject (keep duplicate) | diff --git a/docs/audits/GPT-5.5-medium-harden-pr-full-findings.md b/docs/audits/GPT-5.5-medium-harden-pr-full-findings.md new file mode 100644 index 0000000..48ed972 --- /dev/null +++ b/docs/audits/GPT-5.5-medium-harden-pr-full-findings.md @@ -0,0 +1,100 @@ +# GPT-5.5-medium harden-pr full findings + +Mode: `/harden-pr full`, read-only/report-only. +Scope: `origin/main...HEAD` at `a882d082a786b8b2b80ae550b6a977e169f15476`. +PR: <https://github.com/stainless-code/persist/pull/7> + +User constraints: no code changes, no commit. This file is the requested findings artifact. + +## Status + +- Branch working tree was clean before writing this report. +- PR checks were green (`CI complete`, format, lint, typecheck, unit, DOM, build, audit, pack, size). +- PR is mergeable but review-required. +- Reviewer pass ran read-only across correctness, ship-readiness, structure, public API, tests, and performance. + +## Vetted Findings + +### Blocker + +1. `src/core/persist-core.ts` - `clearStorage()` can be undone by older writes. + + `clearStorage()` only calls `storage.removeItem(options.name)` and does not cancel a pending throttled write or bump `writeGeneration`. A pending timer or in-flight `retryWrite` loop can re-write the key after explicit clear/logout. This also affects `PersistRegistry.clearAll()`, because registered callbacks call `api.clearStorage()`. + +### Major + +2. `src/core/persist-core.ts` - writes during async hydration can be permanently skipped. + + The source subscription returns early while `hasHydratedFlag` is false. If state changes during an async hydrate and storage has no usable payload, the change is not written after hydration settles because `hadPendingWrite` only tracks cancelled throttle timers, not subscription events skipped by the hydration gate. + +3. `src/adapters/backends/encrypted.ts`, `src/adapters/backends/compressed.ts`, `src/core/persist-core.ts` - wrapped `localStorage` does not inherit native `storage` events. + + `createStorage` exposes the object returned by `getStorage()` as `PersistStorage.raw`. For encrypted/compressed wrappers over `localStorage`, `raw` becomes the wrapper object, while browser `StorageEvent.storageArea` is the real `localStorage`. The cross-tab identity guard rejects those events, so the README matrix claim that these wrappers inherit cross-tab behavior is not true for native storage events. + +4. `src/adapters/backends/secure-store.ts` - common documented names are invalid SecureStore keys. + + The adapter passes persist names directly to `SecureStore.getItemAsync` / `setItemAsync` / `deleteItemAsync`. Expo SecureStore keys may only contain alphanumeric characters, `.`, `-`, and `_`; documented/example keys like `auth:token:v1` will reject. + +5. `src/adapters/backends/encrypted.ts` - published JSDoc example passes an invalid option to `persistStore`. + + The hover example calls `persistStore(store, { name, storage, clearCorruptOnFailure: true })`, but `clearCorruptOnFailure` is a `createStorage` option, not a `PersistOptions` option. Copying the example should fail typecheck and teaches the wrong API surface. + +6. `.changeset/encrypted-compressed.md` - release note states the wrong encrypted corrupt-payload behavior. + + The changeset says wrong-key/tampered ciphertext decrypt failures go through persist-core's corrupt-payload path and can be removed by `clearCorruptOnFailure`. Decrypt happens inside backend `getItem`, so the failure rejects during hydrate instead of codec decode cleanup. + +7. `src/adapters/frameworks/svelte.ts`, `src/adapters/frameworks/svelte-store.ts` - Svelte examples reverse the hydration gate. + + Both examples render `<Skeleton />` when `hydrated` is true and `<Prefs />` when false. That is inverted from the documented gate behavior and will ship into published hovers. + +8. `docs/audits/2026-07-04-docs-adapters-roi.md`, `README.md` - API docs hosting is marked shipped but still not hosted. + + ROI item #3 is checked as shipped for linking/publishing the generated TypeDoc site and says `.nojekyll` is already present. Current README still says the API reference is not hosted, no GitHub Pages/docs site workflow exists, and no `.nojekyll` file is tracked. + +9. `docs/audits/2026-07-04-docs-adapters-roi.md` - shipped migration-chain item remains listed as unshipped. + + `createMigrationChain` is implemented, exported, tested, documented, and has a changeset, but audit item #31 still appears unmarked in Tier 4. This conflicts with the audit/plan convention that shipped ROI items are marked and remaining work is tracked in `docs/plans/remaining-roi.md`. + +10. `src/core/persist-core.ts`, `src/adapters/transport/crosstab.ts` - BroadcastChannel remove events can echo between tabs. + + `onCrossTabRemove` commonly resets the local source. With `skipPersist`, that reset calls `removeItem`; the BroadcastChannel wrapper broadcasts removals after successful `removeItem` even if the key was already absent. Two tabs using the bridge can bounce remove notifications/reset writes. + +### Minor + +11. `.cursor/skills/codemap/SKILL.md` - codemap skill symlink shape violates the agents-first convention. + + Other Cursor skills are tracked as `.cursor/skills/<name>` symlinks to `.agents/skills/<name>`. The codemap entry is a real directory containing only a `SKILL.md` symlink, so future sibling files under `.agents/skills/codemap/` would not be mirrored. + +12. `docs/plans/remaining-roi.md` - docs-site acceptance references missing `.nojekyll`. + + The docs-site plan says `.nojekyll` is already present, but the repo does not track it. This will mislead the next docs-site implementation. + +13. `docs/audits/2026-07-04-docs-adapters-roi.md` - npm provenance item is stale. + + ROI item #20 still describes `id-token: write` plus `--provenance` as unshipped. The branch implements trusted publishing via OIDC and `publishConfig.provenance`; the remaining work is first-release verification and old token cleanup, already captured in `docs/plans/remaining-roi.md`. + +14. `src/adapters/frameworks/preact.test.ts` - Preact subscription cleanup is not tested. + + The `preact/compat` mock returns `getSnapshot()` only and ignores `subscribe`, so tests do not exercise `signal.subscribeHydrated` wiring or unsubscribe cleanup. + +15. `src/adapters/frameworks/svelte.test.ts` - Svelte runes reactive ownership is not tested. + + Tests cover direct `current` reads and explicitly skip the `createSubscriber` path inside a Svelte owner. Reactive auto-update/cleanup remains unverified. + +16. `src/adapters/backends/node-fs.test.ts` - traversal guard lacks a regression test. + + `nodeFsStateStorage` refuses keys that sanitize to `.`, `..`, or empty, but tests only cover unsafe-character sanitization and collision resistance. A regression reopening traversal-by-key would pass. + +17. `src/adapters/transport/crosstab.test.ts` - failed-write broadcast suppression lacks tests. + + The wrapper now catches rejected `setItem`/`removeItem` follow-up promises and avoids broadcasting failed writes/removes, but no test covers those branches. + +18. `src/adapters/sources/mobx.ts` - large MobX sources pay full `toJS()` cost before `partialize`. + + The adapter reads `toJS(observable)` on every source change before persist-core can run `partialize`, so large observables pay O(store) clone/allocation cost even when the persisted slice is small. + +## Dropped During Vetting + +- `retryWrite` being uncapped was dropped as by-design for this report: the JSDoc explicitly states the callback is the termination policy and that a callback always returning state can spin forever. +- The package-level `svelte >=3.0.0` peer range was dropped per `harden-pr` ledger: it is shared by `./frameworks/svelte` and `./frameworks/svelte-store`, and cannot be fixed per subpath in this package. +- Dated audit historical counts were dropped per `harden-pr` ledger. diff --git a/docs/audits/Opus-4.8-high-harden-pr-full-findings.md b/docs/audits/Opus-4.8-high-harden-pr-full-findings.md new file mode 100644 index 0000000..dc9d2f0 --- /dev/null +++ b/docs/audits/Opus-4.8-high-harden-pr-full-findings.md @@ -0,0 +1,95 @@ +# Opus-4.8-high — harden-pr (full) findings + +**Mode:** full · **Scope:** `origin/main...HEAD` (52 files) · **HEAD:** `a882d082a786b8b2b80ae550b6a977e169f15476` +**Branch:** `audit/docs-adapters-roi` (PR #7) · **Run:** review-only — **no code changed, no commit**. +**Reviewers (parallel, readonly):** Correctness · Ship-readiness · Structure · Public API · Tests · Performance. +**Suite:** `bun test ./src` = 202 pass / 0 fail (confirmed by two reviewers). + +## Intent anchor (contract used by every reviewer) + +- **Goal:** execute the 2026-07-04 ROI audit — ship adapters + `createMigrationChain`, refold `src/`→`src/core/`+`src/adapters/<seam>/` (breaking subpath rename mirroring folders), harden CI/supply-chain, docs rewrite. +- **Non-goals (must not change):** core runtime behavior; core stays zero-dep; subpaths own their optional peer, import only from `core/`, no barrel; source adapters shape-named; `maxAge` opt-in; sync-first read path; `.` core entry unchanged. + +## Verdict + +Branch is close to pristine. **3 in-bounds items worth fixing before ship** (2 real bugs in shipped `@example`/adapter code + 1 enforcement gap), all **S effort**. The remainder are test-coverage gaps, mock-fidelity notes (mostly overlapping CodeRabbit's already-triaged nitpicks), and doc nits. No blocker; no correctness bug in the core engine hot paths (verified clean). + +--- + +## Fix-before-ship (Major — new, not previously triaged) + +### M1 · Public API · `src/adapters/backends/encrypted.ts:35` · S + +The shipped `@example` passes `clearCorruptOnFailure: true` **to `persistStore(store, { ... })`**, but that flag is a `CreateStorageOptions` field consumed by `createStorage`, **not** a `PersistOptions` field. `persistSource`/`persistStore` never read it. + +- **Impact:** published example won't typecheck (TS excess-property error) and the flag is inert where placed. The README recipe (`README.md` ~L542) places it correctly on `createStorage`'s 3rd arg. +- **Vetted:** confirmed `PersistOptions` (persist-core.ts) has no `clearCorruptOnFailure`; it lives on `CreateStorageOptions`. +- **Fix (in-bounds):** move `clearCorruptOnFailure: true` into the `createStorage<Prefs>(..., ..., { clearCorruptOnFailure: true })` options arg in the example. + +### M2 · Correctness · `src/adapters/backends/secure-store.ts:42` (+ test:9) · S + +The adapter forwards the store `name` verbatim to `expo-secure-store`, whose keys **must match `/^[\w.-]+$/`** (alphanumeric, `.`, `-`, `_`). The repo-wide colon naming convention — including **this adapter's own JSDoc example `name: "auth:token:v1"`** — throws `Invalid key provided to SecureStore` at runtime on first get/set. + +- **Vetted:** real library constraint; the documented example would throw. +- **Mock-tautology:** `secure-store.test.ts` mocks with a plain `Map` that accepts any key (and the test happens to use colon-free `"secure-json"`), so the charset contract is never exercised. +- **Fix (in-bounds):** sanitize keys (e.g. `:` → `_`) in the adapter **or** change the JSDoc example + document that secure-store keys can't contain colons. (Sanitizing changes only this new adapter's on-storage key mapping — no cross-cutting behavior change.) + +### M3 · Structure · `src/core/persist-core.test.ts:22` · S + +The zero-dep gate test reads only `./persist-core.ts`. `hydration.ts` — the other half of the zero-dep core per the intent anchor **and its own file header** — is **not** enforced. + +- **Vetted:** confirmed the gate `describe` only `Bun.file("./persist-core.ts")`. `hydration.ts` currently has zero imports, so **no active violation** — but the invariant is regression-unguarded for half the core. +- **Severity note:** reviewer rated `major`; my vet leans **minor↔major** (tiny file, zero imports today → low regression risk). Either way, an S-effort add-a-second-assertion. +- **Fix (in-bounds):** extend the gate to also assert `hydration.ts` has no value imports. + +--- + +## Should-fix (Minor) + +| # | Bar | Location | Finding | Effort | +| --- | ----------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| m1 | Tests | `src/adapters/backends/node-fs.ts:45` | Path-traversal/`..`/`.`/empty-key refusal guard (security-relevant) has **zero test coverage** — a regression reintroducing traversal passes CI. Add a test asserting `setItem("..", …)` / `"."` throws. | S | +| m2 | Tests | `src/adapters/sources/jotai.test.ts:49` | Atom **replace-merge** contract only implicitly covered (primitive round-trip). No object-atom test proving replace ≠ core's shallow-spread; the user-supplied-`merge` branch (left of `??`) untested. | S | +| m3 | Tests | `src/adapters/frameworks/preact.test.ts:5` | Mock ignores the `subscribe` arg → no test verifies the hydration flip rerenders or that unsubscribe fires. Unlike React (which has `tests-dom/`), preact has no DOM coverage → reactive wiring effectively untested. | M | +| m4 | Correctness/Tests | `src/adapters/sources/mobx.ts:31` (+ `mobx.test.ts:9`) | `Object.assign(observable, next)` writes **without `runInAction`** → under `configure({ enforceActions: "always" })` MobX warns on every hydrate/write; the hand-rolled mock never enforces actions so it's uncatchable. (Merged: impl gap + mock-fidelity.) | S–M | +| m5 | Docs | `README.md:3` (+ L397 intro) | Headline tagline — "storage × codec seams, **TanStack Store adapters, and a React hydration hook**" — undersells the shipped surface (7 framework adapters, 5 source adapters, 6 new backends, zod codec, crosstab). Tables below are correct → headline drift, not a broken contract. | S | +| m6 | Perf/Ship shape | `.size-limit.json` | Gates 6 of 23 bundles (core/react/seroval/encrypted/compressed/zod — the heaviest own-code). Net-new **`transport/crosstab`** (shipped surface) has **no** size gate; the thin framework/source/RN-backend wrappers also unbudgeted. Adding a `transport/crosstab` line is the highest-value single addition. (Rest is a defensible intentional subset per audit #23.) | S | + +--- + +## Nits (fix when touching the file) + +| # | Bar | Location | Finding | +| --- | ---------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| n1 | Public API | `src/adapters/backends/encrypted.ts:15` | `clearCorruptOnFailure` JSDoc wording is self-contradictory ("only fires when the _codec_ throws parsing a **non-corrupt** raw" — it fires precisely when the raw **is** corrupt/unparseable). Intent (backend-read reject → phase `hydrate`, vs codec parse-fail → self-heal) is correct; the phrase reads wrong in hovers. | +| n2 | Docs | `src/adapters/frameworks/react.ts:34` (+ `preact.ts`) | `@example` fenced ` ```ts ` but contains JSX (`return <Skeleton />`) → should be ` ```tsx ` for published-typing fidelity. | +| n3 | Docs | `src/adapters/sources/zustand.ts:16` | `@example` uses `createJSONStorage(() => localStorage)` but never imports it (a real core export) → snippet won't compile as-is. **Vetted.** | +| n4 | Public API | `src/adapters/frameworks/preact.ts:7` | `UseHydratedResult.hydrated` has no JSDoc, while the parallel `react.ts` result type carries "gates UI flash only, never the state read". Inconsistent hovers across near-identical public types. | +| n5 | Structure | `src/core/hydration.ts:2` | Doc-comment "see `./frameworks/react`" is stale after the refold — hydration.ts now lives in `src/core/`, so the module is at `../adapters/frameworks/react` (comment only, no import/boundary impact). | +| n6 | Docs | `docs/plans/remaining-roi.md:66` | Item numbering skips #6 (`### 5` → `### 7`) — internal renumbering slip in a live plan. | +| n7 | Tests | `src/adapters/backends/encrypted.ts:89` (test) | `decryptAesGcm` "invalid ciphertext payload" branch (missing `.` separator) untested; wrong-key auth-tag reject is covered. | +| n8 | Tests | `src/adapters/frameworks/angular.test.ts:25` | Mock runs `effect()` synchronously → hides the async gap `angular.ts:30` guards (re-read `isHydrated()` at attach). Deleting that guard line would still pass. | +| n9 | Tests | `src/adapters/sources/zustand.test.ts:17` | Mock does full-replace `setState`; real zustand shallow-merges a function-updater return. Functionally safe today (persist-core always returns full merged state), so the distinction is invisible. | +| n10 | Tests | `src/adapters/frameworks/svelte.test.ts:39` | Runes `createSubscriber`→`subscribeHydrated` path never invoked in bun (no Svelte owner). Value contract covered; `HydrationSignal` contract pinned in `core/hydration.test.ts`. `fixable_in_bounds: false` (needs a Svelte runtime). | + +--- + +## Info (log only — no action) + +- **i1 · `src/core/persist-core.ts:463`** — `createMigrationChain` return type `Promise<S>` doesn't reflect the `undefined` a discard path resolves (deliberate `undefined as unknown as S` cast). Correct when plugged into `PersistOptions.migrate`; the type slightly overstates for a caller invoking the returned fn directly. Fixing = semantic API change → **out of bounds**. +- **i2 · base64 duplication** (`encrypted.ts:100` ≡ `compressed.ts:98`) — Performance reviewer confirms keeping the duplication is the **right** call: extracting to `core/` spends bytes against the 3 KB zero-dep-core budget for a 2-consumer helper, and a shared adapter util would violate the per-entry `assert-core-only-imports` isolation. **Do not extract** (counters CodeRabbit's extract suggestion). +- **i3 · Coverage** — `bun test ./src` 202/0; no new file has an untested surface likely to drag the 0.90 line gate (only the small guarded branches in m1/n7/n10). +- **i4 · Hot paths verified clean** — `createMigrationChain` construction is O(k)+O(k log k)+O(version) with a bounded runtime walk (no O(n²)); the trailing throttle is a single coalescing timer with generation-guard supersede; crosstab `wrap` allocates one deferred microtask per write and `close()` clears the handler map + swallows rejections (no leak); `persist-core.ts` has zero value imports so no peer is dragged into the core bundle. + +--- + +## Dropped at vet step (not re-raised) + +- **svelte peer range** — `package.json` declares `svelte >=3.0.0` while `./frameworks/svelte` (runes) needs `>=5.7.0`. Already in **LEDGER § Deferred** (npm can't express per-subpath peer ranges; documented per-subpath in README/JSDoc/changeset). Out of bounds without splitting the subpath into its own package. +- **`docs/audits/2026-07-04-docs-adapters-roi.md` historical counts** ("5 subpath entries", etc.) — **LEDGER § Rejections**: dated audit record, counts accurate to the audit date. + +--- + +## Note on overlap with CodeRabbit + +The mock-fidelity items (m4 mobx, m3 preact, n8 angular, n9 zustand) and the size-gate breadth (m6) restate CodeRabbit nitpicks the author already triaged (applied the core-only-imports dedup; deferred/declined the mock-fidelity ones). **New this pass:** M1 (encrypted `@example` `clearCorruptOnFailure` misplacement), M2 (secure-store colon-key runtime throw), M3 (hydration.ts gate gap), n3 (zustand missing import), n1 (encrypted wording), n5 (hydration.ts stale path), n6 (roi numbering). M1 and M2 are the highest-value catches — both ship broken/misleading `@example` or runtime behavior in new public adapters. diff --git a/docs/audits/composer-2.5-fast-harden-pr-full.md b/docs/audits/composer-2.5-fast-harden-pr-full.md new file mode 100644 index 0000000..5404ada --- /dev/null +++ b/docs/audits/composer-2.5-fast-harden-pr-full.md @@ -0,0 +1,143 @@ +# Harden PR — full pass (read-only) + +**Model:** composer-2.5-fast +**Mode:** full (`origin/main...HEAD`) +**HEAD:** `a882d082a786b8b2b80ae550b6a977e169f15476` +**Branch:** `audit/docs-adapters-roi` · [PR #7](https://github.com/stainless-code/persist/pull/7) +**Date:** 2026-07-06 +**Constraints:** no code changes · no commit · findings only + +--- + +## Intent anchor + +Execute the [2026-07-04 docs-adapters ROI audit](2026-07-04-docs-adapters-roi.md): ship adapters, refold `src/` → `core/` + `adapters/<seam>/`, breaking subpath rename, `createMigrationChain`, README/docs rewrite, CI/supply-chain hardening. In-bounds fixes must not redesign features or change observable runtime behavior beyond bug fixes. + +--- + +## Baseline checks (at review time) + +| Check | Status | +| --------------- | ------------------------------------------------------- | +| `bun run check` | pass (build, format, lint, 202 unit + 4 DOM, typecheck) | +| CI on PR #7 | all jobs green | +| Coverage gate | 90% threshold met (~98% aggregate per reviewers) | + +**Production bar:** **not met** — 2 blockers, 8 major (incl. 1 already in LEDGER § Deferred), several minor/nit gaps below. + +--- + +## Vetted findings + +Sorted: severity → confidence desc → effort asc. Deduped across 6 reviewers (Correctness, Ship-readiness, Structure, Public API, Tests, Performance). Vet step applied: LEDGER § Rejections consulted; each survivor re-read at cited location. + +### Blocker + +| # | Severity | File | Line | Finding | Conf | Effort | Bar | In bounds | +| --- | -------- | --------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | ------ | ---- | --------- | +| B1 | blocker | `.changeset/subpath-mirror-folders.md` | 2 | Frontmatter declares `@stainless-code/persist: **minor**` while the body labels four import-path renames as **Breaking**. Semver/changelog will under-bump the release. | high | S | Docs | yes | +| B2 | blocker | `docs/audits/2026-07-04-docs-adapters-roi.md` | 114 | ROI item **#3** is ✅ (“Link `docs/api/` + publish to GitHub Pages”) but README § API reference still says “not hosted yet” (`README.md:882`), no GitHub Pages workflow exists, and `.nojekyll` is absent from the tracked tree. False shipped mark vs consumer docs. | high | S | Docs | yes | + +### Major + +| # | Severity | File | Line | Finding | Conf | Effort | Bar | In bounds | +| --- | -------- | --------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | ------ | ----------- | --------- | +| M1 | major | `src/core/persist-core.ts` | 976 | **`crossTab: true` + encrypted/compressed wrapper over `localStorage` silently ignores cross-tab updates.** `createStorage` sets `PersistStorage.raw` to the wrapper `StateStorage`; browser `StorageEvent.storageArea` is the underlying `localStorage`. Guard `event.storageArea !== raw` rejects every event. README decision matrix marks encrypted/compressed cross-tab as “inherits” (`README.md:502–503`) — misleading; users expect localStorage cross-tab to work. Workaround: `createBroadcastCrossTab` (`storageArea: null` → key-only). IDB + bridge path unaffected. | high | M | Correctness | yes | +| M2 | major | `docs/plans/remaining-roi.md` | 48 | Prose claims “`.nojekyll` already present” for GitHub Pages; file does not exist anywhere in repo (same root cause as B2). | high | S | Docs | yes | +| M3 | major | `docs/audits/2026-07-04-docs-adapters-roi.md` | 11 | Audit **TL;DR + appendix C** still describe pre-ship consumer-doc gaps (no hydration explainer, no IDB walkthrough, registry invisible, API site unlinked) that contradict the ROI table ✅ marks and the current README. Readers cannot trust audit status columns without a “historical snapshot vs post-branch” banner. | high | M | Docs | yes | +| M4 | major | `docs/audits/2026-07-04-docs-adapters-roi.md` | 158 | **`createMigrationChain` shipped** (core export, README recipe, `.changeset/migration-chain.md`) but audit ROI **#31** remains un-✅. Plan lifecycle drift vs `remaining-roi.md` contract. | high | S | Docs | yes | +| M5 | major | `docs/audits/2026-07-04-docs-adapters-roi.md` | 143 | **npm trusted publishing implemented** (`release.yml`, `publishConfig.provenance`) but audit ROI **#20** remains un-✅. | high | S | Docs | yes | +| M6 | major | `README.md` | 57 | Install optional-peer table lists `./frameworks/svelte` and `./frameworks/svelte-store` both as peer `svelte` with no version split; Entry points table + `architecture.md` require `>=5.7` for runes vs `>=3` for svelte-store. Intra-README drift. | high | S | Docs | yes | +| M7 | major | `src/adapters/frameworks/preact.test.ts` | 5 | `useSyncExternalStore` mock returns `getSnapshot()` only — **`subscribeHydrated` wiring and rerender-on-flip never exercised**; tests re-invoke the hook per assertion instead of a mounted component lifecycle. React has `tests-dom/` parity; Preact does not. | high | M | Tests | yes | +| M8 | major | `src/adapters/frameworks/svelte.test.ts` | 39 | Svelte 5 `hydratedRune` reactive auto-update / `createSubscriber` cleanup untested outside a Svelte owner (suite documents gap). Public subscribe path unverified at production bar. | high | L | Tests | yes | + +### Minor + +| # | Severity | File | Line | Finding | Conf | Effort | Bar | In bounds | +| --- | -------- | ------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------ | ---------- | --------- | +| m1 | minor | `src/adapters/backends/encrypted.test.ts` | — | No test for wrapper-backend + `crossTab: true` on localStorage (M1 combo). | high | S | Tests | yes | +| m2 | minor | `src/core/persist-core.test.ts` | 22 | Zero-dep gate scans **`persist-core.ts` only**; `hydration.ts` ships via `.` entry but has no matching value-import gate. | high | S | Structure | yes | +| m3 | minor | `README.md` | — | `alwaysHydratedSignal` exported from core; no README/skill mention (audit appendix still flags hidden). | high | S | Docs | yes | +| m4 | minor | `docs/audits/2026-07-04-docs-adapters-roi.md` | 635 | Appendix C.2 export inventory still documents flat legacy subpaths (`/seroval`, `/idb`, …) without “pre-refold” annotation. | high | S | Docs | yes | +| m5 | minor | `src/adapters/codecs/zod.ts` | 24 | `zodCodec` lacks `@example` (factory has one). | high | S | Public API | yes | +| m6 | minor | `src/core/persist-core.ts` | 422 | `CreateMigrationChainOptions.onNewer` / `onOlder` use prose defaults, not `@default` tags (unlike `PersistOptions`). | high | S | Public API | yes | +| m7 | minor | `README.md` | 403 | Entry table omits `toHydrationSignal` / `alwaysHydratedSignal`; mislabels `HydrationSignal` as ``(`hydration`)`` (no such export). | high | S | Public API | yes | +| m8 | minor | `.changeset/node-fs-pack-gate.md` | 7 | Changeset mixes consumer `./backends/node-fs` API with maintainer CI internals (attw, knip, publint). | high | S | Public API | yes | +| m9 | minor | `src/adapters/transport/crosstab.ts` | 100 | `wrap().setItem`/`removeItem` `.catch` branches (suppress broadcast on backend failure) uncovered. | high | S | Tests | yes | +| m10 | minor | `src/adapters/transport/crosstab.ts` | 67 | `crossTabEventTarget.removeEventListener` teardown path untested. | high | S | Tests | yes | +| m11 | minor | `src/adapters/backends/encrypted.ts` | 53 | Invalid-backend shape guard (lines 53–58) uncovered. | high | S | Tests | yes | +| m12 | minor | `src/adapters/backends/encrypted.ts` | 89 | Malformed ciphertext wire format (not wrong-key) untested. | high | S | Tests | yes | +| m13 | minor | `src/adapters/backends/compressed.ts` | 51 | Invalid-backend shape guard uncovered. | high | S | Tests | yes | +| m14 | minor | `src/adapters/backends/compressed.test.ts` | — | No corrupt/non-base64 decompress failure test. | high | S | Tests | yes | +| m15 | minor | `src/adapters/backends/compressed.ts` | 18 | Documented compress-then-encrypt stack has no integration test. | medium | M | Tests | yes | +| m16 | minor | `tests-dom/react.test.tsx` | — | DOM suite React-only; Preact shares `useSyncExternalStore` but has no jsdom parity suite. | high | M | Tests | yes | +| m17 | minor | `src/adapters/backends/{encrypted,compressed}.ts` | 100 | `toBase64` uses O(n²) per-byte string concat — latency on large payloads. | high | S | Ship shape | yes | +| m18 | minor | `src/adapters/transport/crosstab.ts` | 92 | Per-write BroadcastChannel post with no coalescing; peer tabs run overlapping full rehydrates under burst writes (mitigate with `throttleMs`). | high | M | Ship shape | yes | + +### Nit + +| # | Severity | File | Line | Finding | Conf | Effort | Bar | In bounds | +| --- | -------- | -------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------ | ------ | ------ | ---------- | --------- | +| n1 | nit | `docs/glossary.md` | 5 | No `transport/` seam term (`./transport/crosstab`) vs `architecture.md`. | high | S | Docs | yes | +| n2 | nit | `.changeset/subpath-mirror-folders.md` | 14 | Maintainer internals (tsdown keys, `persist-` prefix) in consumer changeset. | high | S | Public API | yes | +| n3 | nit | `src/adapters/codecs/seroval.ts` | 20 | `serovalCodec` lacks `@example` (factory has one). | high | S | Public API | yes | +| n4 | nit | `src/adapters/codecs/zod.ts` | 20 | JSDoc cites “persist-core's corrupt-payload path” — prefer consumer-facing language. | medium | S | Public API | yes | +| n5 | nit | `src/core/persist-core.test.ts` | 2094 | `createMigrationChain` `onOlder: 'throw'` unit-tested but no persistSource e2e (unlike discard/throwing-step e2e). | medium | S | Tests | yes | + +--- + +## Already deferred (LEDGER § Deferred — do not re-fix without architecture decision) + +| File | Finding | Reason | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `package.json` peerDependencies | `./frameworks/svelte` needs `>=5.7` per README/JSDoc but package declares `svelte >=3.0.0` shared with `./frameworks/svelte-store` | Package-level peers cannot split per subpath without a separate package or peer rename policy | + +Related vetted items: M6 above (README tables); duplicate Public API entries merged here. + +--- + +## Dropped at vet (false / by-design / duplicate) + +| Claim | Verdict | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| Audit “5 subpath entries” historical counts | **Drop** — LEDGER § Rejections: dated audit record accurate to 2026-07-04 | +| “Missing changeset for new export” | **Drop** — all 11 feature changesets present; only B1 semver type wrong | +| “Exports / tsdown / typedoc misaligned” | **Drop** — verified aligned (23 subpaths + core) | +| “No co-located tests / isolation checks” | **Drop** — 22/22 adapter entries have `itImportsOnlyFromCore`; 202 tests pass | +| persist-core hot-path regression | **Drop** — diff adds `createMigrationChain` only; hydrate/write/cross-tab core unchanged vs main | + +--- + +## Passing (no action) + +- Core zero-dep gate on `persist-core.ts`; no cross-adapter imports detected. +- Architecture: `exports` ↔ `tsdown.config.ts` ↔ `typedoc.json` ↔ `architecture.md` ↔ README subpath tables aligned. +- CI: coverage, size-limit, pack validation (`attw`+`publint`+`knip`), audit gate, trusted publishing workflow in place. +- Co-located test + isolation pattern consistent across adapters. + +--- + +## Recommended fix order (if acting on this report) + +1. **B1** — bump `.changeset/subpath-mirror-folders.md` to `major`. +2. **B2 + M2 + M3** — reconcile audit ✅ marks with reality (either ship GitHub Pages + link, or un-✅ #3 and fix prose); add TL;DR banner for historical appendix. +3. **M1 + m1 + README matrix** — fix or document wrapper + `crossTab` limitation; add regression test. +4. **M4 + M5** — ✅ audit rows #31 and #20. +5. **M6** — README install table version split (peer range M7 stays deferred unless package split). +6. **M7 + m16** — Preact DOM tests or improve bun mock to exercise subscribe. +7. Remaining minor/nit doc + JSDoc + test gaps in pass 2. + +--- + +## Reviewers spawned + +| Role | Agent | +| -------------- | ---------------------------------------------------------------------------- | +| Correctness | [2397b09f-d885-4c63-99de-40fc178f3ff8](2397b09f-d885-4c63-99de-40fc178f3ff8) | +| Ship-readiness | [3ef32333-594e-48d4-b58c-fffba1b70eeb](3ef32333-594e-48d4-b58c-fffba1b70eeb) | +| Structure | [17732c03-4dc0-44b4-833b-a3693321bad3](17732c03-4dc0-44b4-833b-a3693321bad3) | +| Public API | [bb0db2ca-ba31-470d-a3e8-292f361ba339](bb0db2ca-ba31-470d-a3e8-292f361ba339) | +| Tests | [71a1a07b-1896-4217-a695-c2eedf6bef0c](71a1a07b-1896-4217-a695-c2eedf6bef0c) | +| Performance | [988590a5-2895-463a-83c3-1c0447edd7f5](988590a5-2895-463a-83c3-1c0447edd7f5) | + +**Passes run:** 1 of 3 (read-only stop — no fix loop per user request). diff --git a/docs/plans/remaining-roi.md b/docs/plans/remaining-roi.md index 328f218..223a38a 100644 --- a/docs/plans/remaining-roi.md +++ b/docs/plans/remaining-roi.md @@ -45,7 +45,7 @@ Framework adapters mount `HydrationSignal` into each framework's external-store - **What:** a VitePress or Astro Starlight site splitting the wall-of-text README into Getting Started → Adapters → Recipes → Adapter authoring → Reference; host the generated `docs/api/` under it. The README stays as the landing-page digest. - **Why:** the README is one document serving "give me 5 minutes", "I want the seam theory", and "I'm writing a Svelte adapter" — all three audiences get one scroll. The generated `docs/api/` site is built but git-ignored and unlinked. -- **Acceptance:** site builds (`bun run docs:site` or equivalent), deployed to GitHub Pages (`.nojekyll` already present); README links to it; `docs/api/` reachable from the site nav. No content duplication — the site pulls from the same source prose where possible. +- **Acceptance:** site builds (`bun run docs:site` or equivalent), deployed to GitHub Pages (add `.nojekyll` at publish time); README links to it; `docs/api/` reachable from the site nav. No content duplication — the site pulls from the same source prose where possible. - **Lands:** `docs/site/` (or a `docs/` restructure); README trimmed to landing digest. Changeset: `minor`. ### 4. npm provenance + signing — Tier 3, S ✅ implemented (verify on next release) @@ -63,21 +63,21 @@ Framework adapters mount `HydrationSignal` into each framework's external-store - **Acceptance:** CI `Test (Browser)` job runs Playwright green; `Test (SSR)` job runs a Next.js app green; both gated by `CI complete`. Co-locate fixtures under `tests-browser/` and `tests-ssr/` (outside `bun test ./src`'s scan, like `tests-dom/`). - **Lands:** `.github/workflows/ci.yml` + new test dirs; `docs/architecture.md` § Test matrix updated. No changeset (test-only). -### 7. React ergonomics layer — Tier 4, M-L +### 6. React ergonomics layer — Tier 4, M-L - **What:** a `./frameworks/react` ergonomics companion (or a new `./frameworks/react-context` subpath) — `<PersistProvider>` + React context + `usePersisted(store, selector)` selector binding + auto-`destroy()` on unmount. The existing `useHydrated` stays the reference primitive. - **Why:** `useHydrated` is the entire React surface today — no provider, no auto store binding, no auto-teardown. Each consumer manually threads a `HydrationSignal` + `useEffect` cleanup (`src/adapters/frameworks/react.ts:22` signals this is intentionally deferred). - **Acceptance:** subpath ships + `tests-dom` coverage for mount/unmount teardown + selector rerender + provider scoping; README "React ergonomics" section. Keep it optional — the bare `useHydrated` path must remain valid. - **Lands:** `src/adapters/frameworks/react-context.ts` (new subpath) + README section. Changeset: `minor`. **Decision needed:** ship in-repo or as a separate package (the JSDoc deferral hints at a higher-layer package). -### 8. OPFS + SQLite-WASM + Cloudflare KV/Durable Objects adapters — Tier 4, M-L +### 7. OPFS + SQLite-WASM + Cloudflare KV/Durable Objects adapters — Tier 4, M-L - **What:** three new `./backends/` subpaths: `opfs` (Origin Private File System, async, file-backed, high-volume structured state), `sqlite-wasm` (wa-sqlite / sqlite-wasm, structured-clone mode like IDB), `cloudflare-kv` + `cloudflare-do` (edge runtime, async `StateStorage`). - **Why:** extends the backend surface to high-volume browser state, structured-query WASM storage, and edge runtimes. All fit `StateStorage<TRaw>` cleanly; no core rework. - **Acceptance:** each ships as its own subpath with optional peer + co-located test (mock the runtime, like the MMKV/AsyncStorage tests) + README backend decision-matrix row. `sqlite-wasm` may be better as a community recipe than a shipped peer (heavy) — decide per-adapter. - **Lands:** `src/adapters/backends/<name>.ts` + README "Choosing a storage" row + changeset (one per adapter). -### 9. StackBlitz / CodeSandbox playground — Tier 4, M +### 8. StackBlitz / CodeSandbox playground — Tier 4, M - **What:** an embedded live-editable example (StackBlitz or CodeSandbox) linked from the docs site (item 3) and README — the fastest on-ramp for a new user. - **Why:** no playground today; a new user can't try a wiring without cloning. Pairs with the docs site (item 3) and `examples/` (item 2). @@ -101,8 +101,8 @@ From audit Appendix B.3. Each is a one-line composition over an existing seam; s 1. **#1 (Query bridge)** — M, pure code, high adoption payoff, no deps. Best next pick. 2. **#5 (real-browser + SSR matrix)** — M, de-risks the hydration-critical paths before more surface lands. -3. **#2 (examples/) → #3 (docs site) → #9 (playground)** — the docs/demo arc; sequence so each builds on the prior. -4. **#7 (React ergonomics) + #8 (OPFS/SQLite/Cloudflare)** — strategic; decide ship-vs-recipe per item. +3. **#2 (examples/) → #3 (docs site) → #8 (playground)** — the docs/demo arc; sequence so each builds on the prior. +4. **#6 (React ergonomics) + #7 (OPFS/SQLite/Cloudflare)** — strategic; decide ship-vs-recipe per item. ## Reference diff --git a/src/adapters/backends/encrypted.ts b/src/adapters/backends/encrypted.ts index da49fe9..65bd804 100644 --- a/src/adapters/backends/encrypted.ts +++ b/src/adapters/backends/encrypted.ts @@ -31,8 +31,9 @@ export interface CreateEncryptedStorageOptions { * const storage = createStorage<Prefs>( * () => createEncryptedStorage(() => localStorage, { key })!, * serovalCodec(), + * { clearCorruptOnFailure: true }, * ); - * persistStore(store, { name: "app:prefs:v1", storage, clearCorruptOnFailure: true }); + * persistStore(store, { name: "app:prefs:v1", storage }); * ``` */ export function createEncryptedStorage( diff --git a/src/adapters/backends/secure-store.test.ts b/src/adapters/backends/secure-store.test.ts index 8dd7651..bf3c08a 100644 --- a/src/adapters/backends/secure-store.test.ts +++ b/src/adapters/backends/secure-store.test.ts @@ -60,5 +60,34 @@ describe("createSecureStoreStorage", () => { persist2.destroy(); }); + it("sanitizes colon-style persist names for SecureStore key charset", async () => { + const storage = createSecureStoreStorage<{ token: string }>()!; + const name = "auth:token:v1"; + const sanitizedKey = "auth_token_v1"; + + const source = createMockSource({ token: "" }); + const persist = persistSource(source, { name, storage }); + await waitForHydration(persist.hasHydrated); + + source.setState(() => ({ token: "secret" })); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(store.has(sanitizedKey)).toBe(true); + expect(store.has(name)).toBe(false); + expect(JSON.parse(store.get(sanitizedKey)!).state.token).toBe("secret"); + + const source2 = createMockSource({ token: "" }); + const persist2 = persistSource(source2, { + name, + storage, + skipHydration: true, + }); + await persist2.rehydrate(); + expect(source2.state.token).toBe("secret"); + + persist.destroy(); + persist2.destroy(); + }); + itImportsOnlyFromCore(new URL("./secure-store.ts", import.meta.url)); }); diff --git a/src/adapters/backends/secure-store.ts b/src/adapters/backends/secure-store.ts index 1c7855f..f6aa769 100644 --- a/src/adapters/backends/secure-store.ts +++ b/src/adapters/backends/secure-store.ts @@ -4,6 +4,8 @@ import * as SecureStore from "expo-secure-store"; import type { PersistStorage, StateStorage } from "../../core/persist-core"; import { createJSONStorage } from "../../core/persist-core"; +const sanitizeKey = (name: string): string => name.replace(/[^\w.-]/g, "_"); + /** * `StateStorage` over `expo-secure-store` — fully async, string-wire, backed * by the platform keychain/keystore. Values are encrypted at rest by the OS. @@ -12,6 +14,10 @@ import { createJSONStorage } from "../../core/persist-core"; * (auth tokens, short prefs), not large state. Oversized writes reject; pair * `partialize` to persist only a small slice. * + * SecureStore keys must match `/^[\w.-]+$/`. Persist names using colons (e.g. + * `auth:token:v1`) are sanitized — characters outside that set become `_` — so + * colon-style names round-trip consistently across get/set/remove. + * * @example * ```ts * import { secureStoreStateStorage } from "@stainless-code/persist/backends/secure-store"; @@ -21,9 +27,10 @@ import { createJSONStorage } from "../../core/persist-core"; */ export function secureStoreStateStorage(): StateStorage { return { - getItem: (name) => SecureStore.getItemAsync(name), - setItem: (name, value) => SecureStore.setItemAsync(name, value), - removeItem: (name) => SecureStore.deleteItemAsync(name), + getItem: (name) => SecureStore.getItemAsync(sanitizeKey(name)), + setItem: (name, value) => + SecureStore.setItemAsync(sanitizeKey(name), value), + removeItem: (name) => SecureStore.deleteItemAsync(sanitizeKey(name)), }; } diff --git a/src/adapters/frameworks/svelte-store.ts b/src/adapters/frameworks/svelte-store.ts index e91a7e4..0563b1b 100644 --- a/src/adapters/frameworks/svelte-store.ts +++ b/src/adapters/frameworks/svelte-store.ts @@ -13,7 +13,7 @@ import type { HydrationSignal } from "../../core/hydration"; * @example * ```ts * const hydrated = hydratedStore(prefsHydration); - * // {#if $hydrated}<Skeleton />{:else}<Prefs />{/if} + * // {#if !$hydrated}<Skeleton />{:else}<Prefs />{/if} * ``` */ export function hydratedStore( diff --git a/src/adapters/frameworks/svelte.ts b/src/adapters/frameworks/svelte.ts index 321f4d4..037aa18 100644 --- a/src/adapters/frameworks/svelte.ts +++ b/src/adapters/frameworks/svelte.ts @@ -21,7 +21,7 @@ const alwaysTrue: { readonly current: boolean } = { * @example * ```ts * const hydrated = hydratedRune(prefsHydration); - * // {#if hydrated.current}<Skeleton />{:else}<Prefs />{/if} + * // {#if !hydrated.current}<Skeleton />{:else}<Prefs />{/if} * ``` */ export function hydratedRune(signal: HydrationSignal | null | undefined): { diff --git a/src/adapters/sources/zustand.ts b/src/adapters/sources/zustand.ts index cf598f8..d20d594 100644 --- a/src/adapters/sources/zustand.ts +++ b/src/adapters/sources/zustand.ts @@ -11,6 +11,7 @@ import { persistSource } from "../../core/persist-core"; * @example * ```ts * import { create } from "zustand"; + * import { createJSONStorage } from "@stainless-code/persist"; * import { persistStore } from "@stainless-code/persist/sources/zustand"; * const store = create(() => ({ count: 0 })); * const persist = persistStore(store, { name: "count", storage: createJSONStorage(() => localStorage) }); diff --git a/src/core/hydration.ts b/src/core/hydration.ts index 73f9708..bdc369c 100644 --- a/src/core/hydration.ts +++ b/src/core/hydration.ts @@ -1,5 +1,5 @@ // Hydration is OBSERVED from outside the store via `useSyncExternalStore` -// (see `./frameworks/react` — the reference `useHydrated`), never by mutating `store.state` with a +// (see `../adapters/frameworks/react` — the reference `useHydrated`), never by mutating `store.state` with a // `__hydrated` flag — store creation and `useSelector` reads stay identical // with or without a hydration sidekick. From ad6f7c420e4efc3afe5fce765c3d0f2b978d739f Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Mon, 6 Jul 2026 11:33:18 +0300 Subject: [PATCH 66/77] =?UTF-8?q?harden:=20Tier=202=20core-engine=20bug=20?= =?UTF-8?q?fixes=20=E2=80=94=20clearStorage,=20hydrate-skipped=20writes,?= =?UTF-8?q?=20crosstab=20echo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/adapters/transport/crosstab.test.ts | 60 ++++++++++++++++++ src/adapters/transport/crosstab.ts | 24 +++++--- src/core/persist-core.test.ts | 82 +++++++++++++++++++++++++ src/core/persist-core.ts | 42 ++++++++++--- 4 files changed, 194 insertions(+), 14 deletions(-) diff --git a/src/adapters/transport/crosstab.test.ts b/src/adapters/transport/crosstab.test.ts index 0230d34..6e4a779 100644 --- a/src/adapters/transport/crosstab.test.ts +++ b/src/adapters/transport/crosstab.test.ts @@ -198,5 +198,65 @@ describe("createBroadcastCrossTab", () => { expect(fired).toBe(false); }); + it("skipPersist reset + onCrossTabRemove: no remove-echo loop across tabs", async () => { + const shared = new Map<string, StorageValue<{ count: number }>>(); + shared.set("echo", { state: { count: 5 }, version: 0 }); + const channelName = `echo-${Math.random()}`; + const bridgeA = createBroadcastCrossTab<{ count: number }>({ + channelName, + })!; + const bridgeB = createBroadcastCrossTab<{ count: number }>({ + channelName, + })!; + + let removeCallsA = 0; + let removeCallsB = 0; + const sourceA = createMockSource({ count: 0 }); + const sourceB = createMockSource({ count: 0 }); + + const persistA = persistSource(sourceA, { + name: "echo", + storage: bridgeA.wrap(createSharedAsyncStorage(shared)), + crossTab: true, + crossTabEventTarget: bridgeA.crossTabEventTarget, + skipPersist: (s) => s.count === 0, + onCrossTabRemove: () => { + removeCallsA++; + sourceA.setState(() => ({ count: 0 })); + }, + }); + const persistB = persistSource(sourceB, { + name: "echo", + storage: bridgeB.wrap(createSharedAsyncStorage(shared)), + crossTab: true, + crossTabEventTarget: bridgeB.crossTabEventTarget, + skipPersist: (s) => s.count === 0, + onCrossTabRemove: () => { + removeCallsB++; + sourceB.setState(() => ({ count: 0 })); + }, + }); + + await waitForHydration(persistA.hasHydrated); + await waitForHydration(persistB.hasHydrated); + expect(sourceA.state.count).toBe(5); + expect(sourceB.state.count).toBe(5); + + // Tab A resets to default → skipPersist removes + broadcasts. Tab B's + // onCrossTabRemove resets → its skipPersist removeItem finds the key already + // absent in the shared backend → must NOT re-broadcast (would loop). + sourceA.setState(() => ({ count: 0 })); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(removeCallsB).toBe(1); // tab A's removal reached tab B once + expect(removeCallsA).toBe(0); // tab B never echoed back + expect(shared.get("echo")).toBeUndefined(); + + persistA.destroy(); + persistB.destroy(); + bridgeA.close(); + bridgeB.close(); + }); + itImportsOnlyFromCore(new URL("./crosstab.ts", import.meta.url)); }); diff --git a/src/adapters/transport/crosstab.ts b/src/adapters/transport/crosstab.ts index 8016ea8..b07930a 100644 --- a/src/adapters/transport/crosstab.ts +++ b/src/adapters/transport/crosstab.ts @@ -104,14 +104,24 @@ export function createBroadcastCrossTab<S>( return result; }, removeItem(name) { + // Probe presence before removing so a no-op remove (key already + // absent in a shared backend — IDB, localStorage) doesn't broadcast + // and echo across tabs (skipPersist reset → onCrossTabRemove → …). + const present = Promise.resolve(storage.getItem(name)).then( + (v) => v != null, + () => true, // probe failed — assume present so a real remove still broadcasts + ); const result = storage.removeItem(name); - Promise.resolve(result) - .then(() => { - postMessage({ key: name, newValue: null, storageArea: null }); - }) - .catch(() => { - // remove failed — don't broadcast a removal that didn't land. - }); + present.then((wasPresent) => { + if (!wasPresent) return; + Promise.resolve(result) + .then(() => { + postMessage({ key: name, newValue: null, storageArea: null }); + }) + .catch(() => { + // remove failed — don't broadcast a removal that didn't land. + }); + }); return result; }, }; diff --git a/src/core/persist-core.test.ts b/src/core/persist-core.test.ts index a5cf4ab..9b20b84 100644 --- a/src/core/persist-core.test.ts +++ b/src/core/persist-core.test.ts @@ -2291,3 +2291,85 @@ describe("createMigrationChain", () => { ); }); }); + +describe("persistApi.clearStorage pending-write cancellation", () => { + let memory: MemoryStorage; + + beforeEach(() => { + memory = new MemoryStorage(); + }); + + it("clearStorage cancels a pending throttled write — the key stays cleared (no resurrection)", async () => { + const jsonStorage = createJSONStorage<{ count: number }>(() => memory)!; + const source = createMockSource({ count: 0 }); + const persist = persistSource(source, { + name: "clear-pending", + storage: jsonStorage, + throttleMs: 20, + }); + await waitForHydration(persist.hasHydrated); + source.setState(() => ({ count: 5 })); // pending in the 20ms window + await persist.clearStorage(); // cancels pending + removes + await new Promise((resolve) => setTimeout(resolve, 40)); + expect(memory.getItem("clear-pending")).toBeNull(); + persist.destroy(); + }); + + it("registry.clearAll cancels a pending throttled write (logout wipe survives a pending write)", async () => { + const jsonStorage = createJSONStorage<{ count: number }>(() => memory)!; + const registry = createPersistRegistry(); + const source = createMockSource({ count: 0 }); + const persist = persistSource(source, { + name: "clear-all-pending", + storage: jsonStorage, + throttleMs: 20, + registry, + }); + await waitForHydration(persist.hasHydrated); + source.setState(() => ({ count: 5 })); + await registry.clearAll(); + await new Promise((resolve) => setTimeout(resolve, 40)); + expect(memory.getItem("clear-all-pending")).toBeNull(); + persist.destroy(); + }); +}); + +describe("persistSource write-during-async-hydrate", () => { + let memory: MemoryStorage; + + beforeEach(() => { + memory = new MemoryStorage(); + }); + + it("a setState during an async hydrate is re-scheduled after settle (not silently lost on reload)", async () => { + let resolveGet: ((value: string | null) => void) | undefined; + const delayedStorage: StateStorage = { + getItem: () => + new Promise((resolve) => { + resolveGet = resolve; + }), + setItem: (name, value) => memory.setItem(name, value), + removeItem: (name) => memory.removeItem(name), + }; + const jsonStorage = createJSONStorage<{ count: number }>( + () => delayedStorage, + )!; + const source = createMockSource({ count: 0 }); + const persist = persistSource(source, { + name: "skip-during-hydrate", + storage: jsonStorage, + }); + // Hydrate getItem is unresolved; a user setState lands during the window. + source.setState(() => ({ count: 9 })); + // Resolve the hydrate with NO usable payload → no merge → state stays 9. + resolveGet?.(null); + await waitForHydration(persist.hasHydrated); + expect(source.state.count).toBe(9); + // The skipped write was re-scheduled after settle → storage has count:9. + await new Promise((resolve) => queueMicrotask(resolve)); + const stored = memory.getItem("skip-during-hydrate"); + expect(stored).not.toBeNull(); + expect(JSON.parse(stored!).state.count).toBe(9); + persist.destroy(); + }); +}); diff --git a/src/core/persist-core.ts b/src/core/persist-core.ts index 068f514..6d210f7 100644 --- a/src/core/persist-core.ts +++ b/src/core/persist-core.ts @@ -753,6 +753,14 @@ export function persistSource<TState, TPersistedState = TState>( let hasHydratedFlag = false; let hydrationVersion = 0; + // True while persist-core is driving `source.setState` itself (the hydrate + // merge) so the subscribe gate can distinguish that notification from a user + // `setState` during the hydrate window (see `writeSkippedDuringHydrate`). + let internalSetState = false; + // A user `setState` landed during an async hydrate — the subscribe gate drops + // it (a stale flush would race the storage read). Re-scheduled after settle so + // the change isn't silently lost on reload with no follow-up write. + let writeSkippedDuringHydrate = false; const hydrationListeners = new Set<PersistListener<TState>>(); const finishHydrationListeners = new Set<PersistListener<TState>>(); @@ -951,7 +959,13 @@ export function persistSource<TState, TPersistedState = TState>( // mount/scope) leaks a subscription + registry entry per mount and keeps // writing after unmount. const sourceSubscription = source.subscribe(() => { - if (!hasHydratedFlag) return; + if (!hasHydratedFlag) { + // A user setState during an async hydrate — the gate drops it (a stale + // flush would race the storage read); re-scheduled after settle. Skip the + // persist-core-driven merge (`internalSetState`) — not a user write to recover. + if (!internalSetState) writeSkippedDuringHydrate = true; + return; + } scheduleWrite(); }); @@ -1068,7 +1082,14 @@ export function persistSource<TState, TPersistedState = TState>( const applyResolvedState = (migratedState: TPersistedState | undefined) => { if (migratedState === undefined) return; const merge = options.merge ?? shallowSpreadMerge; - source.setState(() => merge(migratedState, source.getState())); + // Mark this as persist-core's own setState so the subscribe gate doesn't + // record it as a user write to re-schedule (the merge is not a write event). + internalSetState = true; + try { + source.setState(() => merge(migratedState, source.getState())); + } finally { + internalSetState = false; + } }; // Shared finish/fail epilogue: settle the post-rehydration callback, flip @@ -1104,6 +1125,7 @@ export function persistSource<TState, TPersistedState = TState>( const currentVersion = ++hydrationVersion; hasHydratedFlag = false; + writeSkippedDuringHydrate = false; // A pending throttled write predates this hydrate — cancel it (a stale // flush must not race the storage read), but REMEMBER it: if this // hydrate wins, the state it captured is still current post-merge and @@ -1162,10 +1184,11 @@ export function persistSource<TState, TPersistedState = TState>( if (currentVersion !== hydrationVersion) return; settleHydration(postRehydrationCallback); - // Re-schedule the write this hydrate cancelled on entry: the state it - // captured is still the current state (post-merge), and dropping it - // would leave storage stale until the next setState. - if (hadPendingWrite) scheduleWrite(); + // Re-schedule the write this hydrate cancelled on entry, OR a user + // setState that the subscribe gate dropped during the async window: the + // state it captured is still the current state (post-merge), and dropping + // it would leave storage stale until the next setState. + if (hadPendingWrite || writeSkippedDuringHydrate) scheduleWrite(); } catch (error: unknown) { if (currentVersion !== hydrationVersion) return; reportError(error, errorPhase); @@ -1180,7 +1203,7 @@ export function persistSource<TState, TPersistedState = TState>( } catch (settleError) { reportError(settleError, "hydrate"); } finally { - if (hadPendingWrite) scheduleWrite(); + if (hadPendingWrite || writeSkippedDuringHydrate) scheduleWrite(); } } }; @@ -1205,6 +1228,11 @@ export function persistSource<TState, TPersistedState = TState>( } }, clearStorage() { + // Cancel any pending throttled write + supersede in-flight retryWrite + // before removing the key — otherwise a pending timer or retry loop + // resurrects the key after the clear (also affects registry.clearAll()). + cancelPendingWrite(); + writeGeneration++; return storage?.removeItem(options.name); }, rehydrate: hydrate, From 1cec3d33b49f23151ef9458d31514fc731b54079 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Mon, 6 Jul 2026 11:53:08 +0300 Subject: [PATCH 67/77] =?UTF-8?q?harden:=20Tier=203=20Cluster=201=20?= =?UTF-8?q?=E2=80=94=20pin=20untested=20guards=20&=20defensive=20branches?= =?UTF-8?q?=20(9=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/adapters/backends/compressed.test.ts | 15 +++++++ src/adapters/backends/encrypted.test.ts | 20 ++++++++++ src/adapters/backends/node-fs.test.ts | 20 ++++++++++ src/adapters/transport/crosstab.test.ts | 50 ++++++++++++++++++++++++ src/core/persist-core.test.ts | 38 ++++++++++++++++++ 5 files changed, 143 insertions(+) diff --git a/src/adapters/backends/compressed.test.ts b/src/adapters/backends/compressed.test.ts index 33f92a0..082b3db 100644 --- a/src/adapters/backends/compressed.test.ts +++ b/src/adapters/backends/compressed.test.ts @@ -104,5 +104,20 @@ describe("createCompressedStorage", () => { } }); + it("returns undefined when the backend is missing a required method", () => { + const broken = { + getItem: () => null, + setItem: () => {}, + // removeItem missing + } as unknown as import("../../core/persist-core").StateStorage<string>; + expect(createCompressedStorage(() => broken)).toBeUndefined(); + }); + + it("decompress rejects a corrupt (non-base64) payload", async () => { + const storage = createCompressedStorage(() => memory)!; + memory.setItem("corrupt", "!!!not-base64!!!"); + await expect(storage.getItem("corrupt")).rejects.toThrow(); + }); + itImportsOnlyFromCore(new URL("./compressed.ts", import.meta.url)); }); diff --git a/src/adapters/backends/encrypted.test.ts b/src/adapters/backends/encrypted.test.ts index d0c8b6a..a74a43a 100644 --- a/src/adapters/backends/encrypted.test.ts +++ b/src/adapters/backends/encrypted.test.ts @@ -158,5 +158,25 @@ describe("createEncryptedStorage", () => { } }); + it("returns undefined when the backend is missing a required method", () => { + const broken = { + getItem: () => null, + setItem: () => {}, + // removeItem missing + } as unknown as import("../../core/persist-core").StateStorage<string>; + expect( + createEncryptedStorage(() => broken, { key: {} as CryptoKey }), + ).toBeUndefined(); + }); + + it("decrypt rejects a malformed ciphertext payload (missing separator)", async () => { + const key = await makeKey(); + const storage = createEncryptedStorage(() => memory, { key })!; + memory.setItem("malformed", "not-a-valid-ciphertext-no-separator"); + await expect(storage.getItem("malformed")).rejects.toThrow( + /invalid ciphertext payload/, + ); + }); + itImportsOnlyFromCore(new URL("./encrypted.ts", import.meta.url)); }); diff --git a/src/adapters/backends/node-fs.test.ts b/src/adapters/backends/node-fs.test.ts index 8713faf..be585f9 100644 --- a/src/adapters/backends/node-fs.test.ts +++ b/src/adapters/backends/node-fs.test.ts @@ -103,6 +103,26 @@ describe("nodeFsStateStorage", () => { } }); + it("refuses keys that sanitize to traversal / self / empty segments", async () => { + const dir = await tempDir(); + try { + const storage = nodeFsStateStorage({ dir }); + for (const bad of ["..", ".", ""]) { + await expect(storage.setItem(bad, "x")).rejects.toThrow( + /refusing to resolve outside dir/, + ); + await expect(storage.getItem(bad)).rejects.toThrow( + /refusing to resolve outside dir/, + ); + await expect(storage.removeItem(bad)).rejects.toThrow( + /refusing to resolve outside dir/, + ); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + it("composes with createStorage + persistSource end-to-end", async () => { const dir = await tempDir(); try { diff --git a/src/adapters/transport/crosstab.test.ts b/src/adapters/transport/crosstab.test.ts index 6e4a779..d2f3a3d 100644 --- a/src/adapters/transport/crosstab.test.ts +++ b/src/adapters/transport/crosstab.test.ts @@ -258,5 +258,55 @@ describe("createBroadcastCrossTab", () => { bridgeB.close(); }); + it("a failed setItem/removeItem does not broadcast", async () => { + const channelName = `fail-${Math.random()}`; + const bridgeA = createBroadcastCrossTab<{ x: number }>({ channelName })!; + const bridgeB = createBroadcastCrossTab<{ x: number }>({ channelName })!; + + let posts = 0; + bridgeB.crossTabEventTarget.addEventListener("storage", () => posts++); + + const failing: PersistStorage<{ x: number }> = { + raw: undefined, + getItem: () => ({ state: { x: 1 }, version: 0 }), // present → removeItem probe proceeds + setItem: () => Promise.reject(new Error("write fail")), + removeItem: () => Promise.reject(new Error("remove fail")), + }; + const wrapped = bridgeA.wrap(failing); + + await expect( + wrapped.setItem("k", { state: { x: 1 }, version: 0 }), + ).rejects.toThrow(); + await expect(wrapped.removeItem("k")).rejects.toThrow(); + + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(posts).toBe(0); + + bridgeA.close(); + bridgeB.close(); + }); + + it("removeEventListener stops delivery to that listener", async () => { + const channelName = `rm-${Math.random()}`; + const bridge = createBroadcastCrossTab({ channelName })!; + const poster = new BroadcastChannel(channelName); + + let fired = 0; + const listener = () => fired++; + bridge.crossTabEventTarget.addEventListener("storage", listener); + + poster.postMessage({ key: "k", newValue: "1", storageArea: null }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(fired).toBe(1); + + bridge.crossTabEventTarget.removeEventListener("storage", listener); + poster.postMessage({ key: "k", newValue: "1", storageArea: null }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(fired).toBe(1); // no second delivery after removeEventListener + + poster.close(); + bridge.close(); + }); + itImportsOnlyFromCore(new URL("./crosstab.ts", import.meta.url)); }); diff --git a/src/core/persist-core.test.ts b/src/core/persist-core.test.ts index 9b20b84..2bc8f6c 100644 --- a/src/core/persist-core.test.ts +++ b/src/core/persist-core.test.ts @@ -45,6 +45,16 @@ describe("persist-core zero-dep gate", () => { ); expect(storeValueImports).toEqual([]); }); + + it("hydration.ts has no value imports (zero-dep core contract)", async () => { + const source = await Bun.file( + new URL("./hydration.ts", import.meta.url), + ).text(); + const valueImports = source + .split("\n") + .filter((line) => /^import\s/.test(line) && !/^import type\s/.test(line)); + expect(valueImports).toEqual([]); + }); }); describe("createJSONStorage codec seam", () => { @@ -2290,6 +2300,34 @@ describe("createMigrationChain", () => { /stored version must be an integer/, ); }); + + it("end-to-end: onOlder 'throw' routes to onError phase 'migrate' and keeps initial state", async () => { + const memory = new MemoryStorage(); + const jsonStorage = createJSONStorage<{ x?: number }>(() => memory)!; + await jsonStorage.setItem("older-throw", { state: { x: 7 }, version: 0 }); + + const errors: Array<{ phase: string; message: string }> = []; + const source = createMockSource({ x: 99 }); + const persist = persistSource(source, { + name: "older-throw", + version: 3, + storage: jsonStorage, + migrate: createMigrationChain<{ x?: number }>({ + version: 3, + steps: { 2: (s) => s }, // minKey=2 → v0 unsupported + onOlder: "throw", + }), + onError: (e, ctx) => + errors.push({ phase: ctx.phase, message: (e as Error).message }), + }); + await waitForHydration(persist.hasHydrated); + + const migrateError = errors.find((e) => e.phase === "migrate"); + expect(migrateError).toBeTruthy(); + expect(migrateError!.message).toMatch(/older than the chain's earliest/); + expect(source.state).toEqual({ x: 99 }); // kept initial — nothing usable + persist.destroy(); + }); }); describe("persistApi.clearStorage pending-write cancellation", () => { From 9d5ff60ca9472c61e82e84fcdce47424a1e40034 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Mon, 6 Jul 2026 11:59:33 +0300 Subject: [PATCH 68/77] =?UTF-8?q?harden:=20Tier=203=20Cluster=203=20?= =?UTF-8?q?=E2=80=94=20toBase64=20O(n),=20mobx=20runInAction,=20crosstab?= =?UTF-8?q?=20size=20gate,=20compress+encrypt=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies Cluster 3 of the triangulated harden-pr audit (docs/audits/2026-07-06-triangulated-harden-pr-findings.md), minus the codemap symlink (committed separately to avoid a git-stash symlink-transition conflict). - encrypted.ts + compressed.ts: toBase64 now `Array.from + join` (O(n)) instead of per-byte string concat (O(n²) on large payloads). Identical behavior. - mobx.ts: wrap `Object.assign(observable, next)` in `runInAction` — writes threw under `configure({ enforceActions: "always" })` (strict-mode MobX). Mock updated with a passthrough `runInAction`. - .size-limit.json: add `transport/crosstab` size gate (396 B gzip vs 2 KB) — the shipped bridge bundle was unbudgeted. - encrypted.test.ts: compress-then-encrypt stack integration test (the documented README recipe — compress wraps encrypt wraps memory; round-trip + not-plaintext). Verified: `bun run check` + `size` + `check:pack` green; 217 pass / 0 fail. --- .size-limit.json | 6 ++++++ src/adapters/backends/compressed.ts | 5 ++--- src/adapters/backends/encrypted.test.ts | 17 +++++++++++++++++ src/adapters/backends/encrypted.ts | 5 ++--- src/adapters/sources/mobx.test.ts | 1 + src/adapters/sources/mobx.ts | 10 +++++++--- 6 files changed, 35 insertions(+), 9 deletions(-) diff --git a/.size-limit.json b/.size-limit.json index b237d57..0d8d9c2 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -34,5 +34,11 @@ "path": "dist/codecs/zod.mjs", "limit": "2 KB", "gzip": true + }, + { + "name": "crosstab", + "path": "dist/transport/crosstab.mjs", + "limit": "2 KB", + "gzip": true } ] diff --git a/src/adapters/backends/compressed.ts b/src/adapters/backends/compressed.ts index 2f607cc..1073d43 100644 --- a/src/adapters/backends/compressed.ts +++ b/src/adapters/backends/compressed.ts @@ -96,9 +96,8 @@ async function decompress( } function toBase64(bytes: Uint8Array): string { - let bin = ""; - for (const b of bytes) bin += String.fromCharCode(b); - return btoa(bin); + // Array.from + join — O(n), not the O(n²) of per-byte string concat. + return btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join("")); } function fromBase64(s: string): Uint8Array<ArrayBuffer> { diff --git a/src/adapters/backends/encrypted.test.ts b/src/adapters/backends/encrypted.test.ts index a74a43a..105ec95 100644 --- a/src/adapters/backends/encrypted.test.ts +++ b/src/adapters/backends/encrypted.test.ts @@ -6,6 +6,7 @@ import { MemoryStorage } from "../../testing/memory-storage"; import { createMockSource } from "../../testing/mock-source"; import { waitForHydration } from "../../testing/wait-for-hydration"; import { serovalCodec } from "../codecs/seroval"; +import { createCompressedStorage } from "./compressed"; import { createEncryptedStorage } from "./encrypted"; async function makeKey(): Promise<CryptoKey> { @@ -178,5 +179,21 @@ describe("createEncryptedStorage", () => { ); }); + it("compress-then-encrypt stack round-trips (the documented recipe)", async () => { + const key = await makeKey(); + const memory = new MemoryStorage(); + const encrypted = createEncryptedStorage(() => memory, { key })!; + const compressed = createCompressedStorage(() => encrypted)!; + + const plaintext = "hello world hello world hello world"; + await compressed.setItem("stack", plaintext); + + const raw = memory.getItem("stack")!; + expect(raw).toContain("."); + expect(raw).not.toContain("hello world"); + + expect(await compressed.getItem("stack")).toBe(plaintext); + }); + itImportsOnlyFromCore(new URL("./encrypted.ts", import.meta.url)); }); diff --git a/src/adapters/backends/encrypted.ts b/src/adapters/backends/encrypted.ts index 65bd804..45e2744 100644 --- a/src/adapters/backends/encrypted.ts +++ b/src/adapters/backends/encrypted.ts @@ -99,9 +99,8 @@ async function decryptAesGcm(payload: string, key: CryptoKey): Promise<string> { } function toBase64(bytes: Uint8Array): string { - let bin = ""; - for (const b of bytes) bin += String.fromCharCode(b); - return btoa(bin); + // Array.from + join — O(n), not the O(n²) of per-byte string concat. + return btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join("")); } function fromBase64(s: string): Uint8Array<ArrayBuffer> { diff --git a/src/adapters/sources/mobx.test.ts b/src/adapters/sources/mobx.test.ts index b8bb9be..0fe26f1 100644 --- a/src/adapters/sources/mobx.test.ts +++ b/src/adapters/sources/mobx.test.ts @@ -16,6 +16,7 @@ mock.module("mobx", () => ({ if (obj && typeof obj === "object") return { ...obj } as T; return obj; }, + runInAction: (fn: () => void) => fn(), })); const { createJSONStorage } = await import("../../core/persist-core"); diff --git a/src/adapters/sources/mobx.ts b/src/adapters/sources/mobx.ts index fe87198..babaadf 100644 --- a/src/adapters/sources/mobx.ts +++ b/src/adapters/sources/mobx.ts @@ -1,5 +1,5 @@ // mobx source adapter — peer `mobx` >=6.0.0. -import { observe, toJS } from "mobx"; +import { observe, runInAction, toJS } from "mobx"; import type { PersistApi, PersistOptions } from "../../core/persist-core"; import { persistSource } from "../../core/persist-core"; @@ -27,8 +27,12 @@ export function persistObservable< { getState: () => toJS(observable) as TState, setState: (updater) => { - const next = updater(toJS(observable) as TState); - Object.assign(observable, next); + // runInAction — Object.assign on an observable throws under + // `configure({ enforceActions: "always" })` outside an action. + runInAction(() => { + const next = updater(toJS(observable) as TState); + Object.assign(observable, next); + }); }, subscribe: (listener) => { const unsub = observe(observable, () => listener()); From ff8bcd98ddee9ee7879f597f61b9b7d90358b347 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Mon, 6 Jul 2026 11:59:49 +0300 Subject: [PATCH 69/77] chore: remove old .cursor/skills/codemap/SKILL.md symlink (replaced by dir symlink next) --- .cursor/skills/codemap/SKILL.md | 1 - 1 file changed, 1 deletion(-) delete mode 120000 .cursor/skills/codemap/SKILL.md diff --git a/.cursor/skills/codemap/SKILL.md b/.cursor/skills/codemap/SKILL.md deleted file mode 120000 index 9e39e2d..0000000 --- a/.cursor/skills/codemap/SKILL.md +++ /dev/null @@ -1 +0,0 @@ -../../../.agents/skills/codemap/SKILL.md \ No newline at end of file From 96d514a1f448db952348c3210496eb1ff86670b3 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Mon, 6 Jul 2026 12:00:03 +0300 Subject: [PATCH 70/77] chore: codemap skill as dir symlink to .agents (agents-first convention) --- .cursor/skills/codemap | 1 + 1 file changed, 1 insertion(+) create mode 120000 .cursor/skills/codemap diff --git a/.cursor/skills/codemap b/.cursor/skills/codemap new file mode 120000 index 0000000..d827dc9 --- /dev/null +++ b/.cursor/skills/codemap @@ -0,0 +1 @@ +../../.agents/skills/codemap \ No newline at end of file From f2ec0f926b6a0e94c500b67b474e1a39a9af4513 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Mon, 6 Jul 2026 12:09:18 +0300 Subject: [PATCH 71/77] docs(plans): lift framework-runtime test gaps into remaining-roi #5; drop harden-pr audit docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The triangulated harden-pr audit + its three source finding docs (composer-2.5-fast / GPT-5.5 / Opus-4.8) have served their purpose — Tier 1, 2, and 3 (Clusters 1 + 3) shipped across commits 2e516b1, ad6f7c4, 1cec3d3, 9d5ff60, ff8bcd9, 96d514a. The only unshipped items are the Cluster 2 framework-mock- fidelity gaps (Preact subscribe wiring, Svelte runes reactive ownership, Angular async effect timing) — all need real framework runtimes the bun mocks can't provide. Lifted into remaining-roi #5 (now "Real-browser + SSR + framework- runtime test matrix") and the four audit docs removed (no external refs; the dated 2026-07-04 ROI audit stays as the historical record). --- ...6-07-06-triangulated-harden-pr-findings.md | 146 ------------------ .../GPT-5.5-medium-harden-pr-full-findings.md | 100 ------------ .../Opus-4.8-high-harden-pr-full-findings.md | 95 ------------ .../composer-2.5-fast-harden-pr-full.md | 143 ----------------- docs/plans/remaining-roi.md | 8 +- 5 files changed, 4 insertions(+), 488 deletions(-) delete mode 100644 docs/audits/2026-07-06-triangulated-harden-pr-findings.md delete mode 100644 docs/audits/GPT-5.5-medium-harden-pr-full-findings.md delete mode 100644 docs/audits/Opus-4.8-high-harden-pr-full-findings.md delete mode 100644 docs/audits/composer-2.5-fast-harden-pr-full.md diff --git a/docs/audits/2026-07-06-triangulated-harden-pr-findings.md b/docs/audits/2026-07-06-triangulated-harden-pr-findings.md deleted file mode 100644 index 2a14c08..0000000 --- a/docs/audits/2026-07-06-triangulated-harden-pr-findings.md +++ /dev/null @@ -1,146 +0,0 @@ -# Triangulated harden-pr findings — final summary - -**Date:** 2026-07-06 -**HEAD:** `a882d082a786b8b2b80ae550b6a977e169f15476` · **Branch:** `audit/docs-adapters-roi` · [PR #7](https://github.com/stainless-code/persist/pull/7) -**Sources triangulated:** - -- [composer-2.5-fast](./composer-2.5-fast-harden-pr-full.md) (C) -- [GPT-5.5-medium](./GPT-5.5-medium-harden-pr-full-findings.md) (G) -- [Opus-4.8-high](./Opus-4.8-high-harden-pr-full-findings.md) (O) - -**Method:** each finding re-verified against current code (file:line) before inclusion. Cross-report refs use C/G/O prefixes. Verdicts: ✅ confirmed · ⚠️ partial · ❌ rejected · 🕒 outdated · 💭 defer. - -**Production bar:** **not met** — 3 confirmed runtime bugs in shipped code (2 @example/adapter, 1 cross-tab echo), 1 docs-governance blocker (false ✅), plus a cluster of S-effort doc/JSDoc nits. Core engine hot paths verified clean by all three reviewers. - ---- - -## TL;DR - -Three reviewers converge on a small set of real, S-effort, in-bounds fixes — most shipped `@example` blocks are broken (encrypted `clearCorruptOnFailure` misplacement, secure-store colon-key throw, svelte inverted gate, zustand missing import) and the audit/plan ✅ marks drifted from reality (API docs hosting, migration-chain, npm provenance). Two deeper core-engine bugs (clearStorage doesn't cancel pending writes; writes during async hydration are permanently skipped) and one crosstab echo loop are **confirmed real but narrow-trigger / core-behavior-changing** — flagged for a decision, not blind application. The mock-fidelity + size-gate items overlap the already-triaged CodeRabbit nitpicks. - ---- - -## Confirmed actionable items - -### Tier 1 — fix before ship (S, in-bounds, confirmed bugs) - -| # | File:line | Finding | Raised | Fix | -| --- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | `src/adapters/backends/encrypted.ts:35` | `@example` passes `clearCorruptOnFailure: true` to `persistStore(...)` — it's a `createStorage` option, not `PersistOptions`; copied snippet won't typecheck and the flag is inert there | O:M1, G:#5 | move into `createStorage<Prefs>(..., ..., { clearCorruptOnFailure: true })` | -| 2 | `src/adapters/backends/secure-store.ts:24,42` | forwards `name` verbatim to `expo-secure-store` (keys must match `/^[\w.-]+$/`); the adapter's own `@example` `name: "auth:token:v1"` throws at runtime. Mock accepts any key so the test never catches it | O:M2, G:#4 | sanitize `:` → `_` in the adapter (new adapter only; no cross-cutting change) **or** fix the example + document the charset constraint | -| 3 | `src/adapters/frameworks/svelte.ts:24`, `svelte-store.ts:16` | `@example` renders `<Skeleton/>` when `hydrated` is **true** — inverted; ships wrong into published hovers | G:#7 | swap to `{#if !hydrated}<Skeleton/>{:else}<Prefs/>{/if}` | -| 4 | `src/adapters/sources/zustand.ts:16` | `@example` uses `createJSONStorage` but never imports it | O:n3 | add `import { createJSONStorage } from "@stainless-code/persist";` | -| 5 | `.changeset/encrypted-compressed.md:7` | claims wrong-key/tampered decrypt → "corrupt-payload path returns null (or `clearCorruptOnFailure` removes the key)" — wrong: decrypt rejects in backend `getItem` → phase `"hydrate"`; `clearCorruptOnFailure` is inert (codec-only) | G:#6 | correct the changeset to match the `encrypted.ts` JSDoc (fixed in `131b213`) | -| 6 | `README.md:502–503` | "Choosing a storage" marks encrypted/compressed cross-tab as `inherits`, but `createStorage` sets `raw` to the **wrapper** while browser `StorageEvent.storageArea` is the real `localStorage` → the identity guard rejects every native event. IDB + `createBroadcastCrossTab` path is unaffected | C:M1, G:#3 | change `inherits` → `✗ (native storage events; use ./transport/crosstab bridge)` + add a regression test (m1) | -| 7 | audit `#3` (`docs/audits/2026-07-04…:114`), `README.md:882`, `docs/plans/remaining-roi.md:48` | `#3` is ✅ ("Link docs/api + publish to GitHub Pages, `.nojekyll` already present") but README says "not hosted yet", no Pages workflow exists, **`.nojekyll` is not tracked** | C:B2/M2, G:#8/#12 | un-✅ `#3` (or split: ✅ the link, leave publish open); remove the `.nojekyll already present` claim from `remaining-roi.md` + audit | -| 8 | audit `#31` (`…:158`) and `#20` (`…:143`) | `#31` migration-chain + `#20` npm provenance are shipped/implemented (commits `2e9247f`, `0c4ea17`) but remain un-✅ in the audit | C:M4/M5, G:#9/#13 | ✅ both rows (provenance note "implemented — pending first-release verify") | -| 9 | `src/core/hydration.ts:2` | comment "see `./frameworks/react`" stale after refold — module is now in `src/core/` | O:n5 | `./frameworks/react` → `../adapters/frameworks/react` | -| 10 | `docs/plans/remaining-roi.md:66` | numbering skips `### 6` (`### 5` → `### 7`) | O:n6 | renumber | - -### Tier 2 — confirmed core-engine bugs (need a decision, not blind apply) - -These are real but change core runtime behavior; GPT-5.5 raised them, Composer + Opus did not. Narrow triggers, but correct bugs. - -| # | File:line | Finding | Raised | Notes | -| --- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 11 | `src/core/persist-core.ts:1207` | `clearStorage()` only calls `removeItem` — does **not** bump `writeGeneration` or cancel the pending throttled write / in-flight `retryWrite`. A pending timer or retry loop re-writes the key after explicit clear/logout; affects `PersistRegistry.clearAll()` too | G:#1 | Fix: `clearStorage` → `cancelPendingWrite()` + `writeGeneration++` before `removeItem`. In-bounds bug fix, but it's a core-engine behavior change — decide before applying | -| 12 | `src/core/persist-core.ts:954` | source subscription returns early while `!hasHydratedFlag`; a `setState` during an async hydrate with no usable payload is dropped and never re-scheduled (`hadPendingWrite` tracks only cancelled throttle timers, not gate-skipped events) | G:#2 | Fix: track a "skipped during hydrate" flag and `scheduleWrite()` after settle. Core-engine change — decide before applying | -| 13 | `src/adapters/transport/crosstab.ts:106` | `wrap().removeItem` broadcasts removal after every successful `removeItem`, even when the key was already absent. With `skipPersist` + `onCrossTabRemove` reset, two tabs can bounce remove notifications / reset writes indefinitely | G:#10 | Fix options: (a) `getItem` before `removeItem` and skip the post if absent; (b) suppress re-broadcast of a just-received removal. Design decision — crosstab-bridge-specific | - -### Tier 3 — should fix (test gaps, structure, perf — S–M, in-bounds) - -| # | File:line | Finding | Raised | Effort | -| --- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ----------- | -| 14 | `src/core/persist-core.test.ts:22` | zero-dep gate scans `persist-core.ts` only; `hydration.ts` (other half of the `.` core) unguarded (zero imports today → no active violation) | C:m2, O:M3 | S | -| 15 | `src/adapters/backends/node-fs.ts:45` | `..`/`.`/empty-key refusal guard (security-relevant) has no test | G:#16, O:m1 | S | -| 16 | `src/adapters/transport/crosstab.ts:100,112` | failed-write/remove broadcast suppression (`.catch` branches, added `131b213`) uncovered | C:m9, G:#17 | S | -| 17 | `src/adapters/transport/crosstab.ts:67` | `removeEventListener` teardown path untested | C:m10 | S | -| 18 | `src/adapters/backends/encrypted.ts:53` | invalid-backend shape guard uncovered | C:m11 | S | -| 19 | `src/adapters/backends/encrypted.ts:89` | malformed ciphertext (missing `.`) branch untested (wrong-key is covered) | C:m12, O:n7 | S | -| 20 | `src/adapters/backends/compressed.ts:51` | invalid-backend shape guard uncovered | C:m13 | S | -| 21 | `src/adapters/backends/compressed.test.ts` | no corrupt/non-base64 decompress failure test | C:m14 | S | -| 22 | `encrypted`+`compressed` | documented compress-then-encrypt stack has no integration test | C:m15 | M | -| 23 | `src/adapters/frameworks/preact.test.ts:5` | `useSyncExternalStore` mock ignores `subscribe` → no rerender-on-flip / unsubscribe-cleanup coverage; no `tests-dom/` parity for preact | C:M7, G:#14, O:m3 | M | -| 24 | `src/adapters/frameworks/svelte.test.ts:39` | runes `createSubscriber` reactive ownership untested (needs a Svelte runtime — out of bun scope) | C:M8, G:#15, O:n10 | L · defer | -| 25 | `src/adapters/frameworks/angular.test.ts:25` | mock runs `effect()` synchronously → hides the async gap `angular.ts:30` guards | O:n8 | S–M · defer | -| 26 | `src/core/persist-core.test.ts` (migration chain) | `onOlder: "throw"` has no `persistSource` e2e (unlike discard / throwing-step) | C:n5 | S | -| 27 | `encrypted.ts:102`, `compressed.ts:100` | `toBase64` O(n²) per-byte string concat — latency on large payloads | C:m17 | S | -| 28 | `.size-limit.json` | no `transport/crosstab` gate (encrypted/compressed/zod added in `a882d08`) | O:m6 | S | -| 29 | `.cursor/skills/codemap/` | real directory containing a `SKILL.md` symlink, not a symlink to `.agents/skills/codemap` (violates agents-first convention; future siblings won't mirror) | G:#11 | S | -| 30 | `src/adapters/sources/mobx.ts:31` | `Object.assign(observable, next)` writes without `runInAction` → throws under `configure({ enforceActions: "always" })`; mock never enforces actions | O:m4, (CodeRabbit) | S–M | - -### Tier 4 — doc / JSDoc / public-API nits (S, fix when touching the file) - -| # | File:line | Finding | Raised | -| --- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | --- | -| 31 | `README.md:57–58` | install table lists both svelte subpaths as peer `svelte` with no version split (entry table + architecture require >=5.7 vs >=3) | C:M6 | -| 32 | `README.md:403` | entry table omits `toHydrationSignal` / `alwaysHydratedSignal`; labels `HydrationSignal` as `(`hydration`)` | C:m7 | -| 33 | `README.md:3` | headline undersells the shipped surface (7 framework + 5 source + 6 backend + zod + crosstab) | O:m5 | -| 34 | `README.md` | `alwaysHydratedSignal` exported from core, no README mention | C:m3 | -| 35 | `src/adapters/codecs/zod.ts:24` | `zodCodec` lacks `@example` | C:m5 | -| 36 | `src/adapters/codecs/seroval.ts:20` | `serovalCodec` lacks `@example` | C:n3 | -| 37 | `src/core/persist-core.ts:422` | `CreateMigrationChainOptions.onNewer`/`onOlder` use prose defaults, not `@default` tags | C:m6 | -| 38 | `src/adapters/backends/encrypted.ts:15` | `clearCorruptOnFailure` JSDoc wording self-contradictory ("non-corrupt raw" — should be "corrupt/unparseable") | O:n1 | -| 39 | `react.ts:34`, `preact.ts` | `@example` fenced ` ```ts ` but contains JSX → should be ` ```tsx ` | O:n2 | -| 40 | `src/adapters/frameworks/preact.ts:7` | `UseHydratedResult.hydrated` has no JSDoc (react.ts parallel does) | O:n4 | -| 41 | `src/adapters/codecs/zod.ts:20` | JSDoc cites "persist-core's corrupt-payload path" — prefer consumer-facing language | C:n4 | -| 42 | `.changeset/node-fs-pack-gate.md:7` | mixes consumer `./backends/node-fs` API with maintainer CI internals (attw/knip/publint) | C:m8 | -| 43 | `.changeset/subpath-mirror-folders.md:14` | maintainer internals (tsdown keys, `persist-` prefix) in consumer changeset | C:n2 | -| 44 | `docs/glossary.md:5` | no `transport/` seam term (`./transport/crosstab`) | C:n1 | -| 45 | `docs/audits/2026-07-04…:11,635` | TL;DR + appendix C describe pre-ship gaps without a "historical snapshot" banner; appendix C.2 documents flat legacy subpaths without annotation | C:M3/m4 | M | - ---- - -## Rejected / by-design / dropped (with reason) - -| Claim | Verdict | Reason | -| ----------------------------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `.changeset/subpath-mirror-folders.md` `minor` vs `Breaking` body = semver blocker (C:B1) | ⚠️ judgment call, **not a blocker** | Package is 0.x; a `major` changeset would jump to 1.0.0 (changesets default). `minor` to stay pre-1.0 is defensible; the "Breaking" body text communicates the import-path migration to consumers. Keep `minor`; ensure the Breaking callout stays prominent | -| Extract `toBase64`/`fromBase64` to `core/` (CodeRabbit) | ❌ reject | Opus i2 confirms: 2 consumers < the 3+ bar in `architecture-priming`; a core export spends the zero-dep-core budget; a shared adapter util violates per-entry `assert-core-only-imports`. **Keep the duplicate** | -| `zustand.test.ts` mock `setState` full-replace (O:n9) | 💭 info | Safe today — `PersistableSource.setState` contract returns the full state; zustand function-setState with full-state return ≡ replace. Distinction invisible | -| `createMigrationChain` return type `undefined as unknown as S` cast (O:i1) | ❌ out of bounds | Correct when plugged into `PersistOptions.migrate`; fixing = semantic API change | -| `svelte >=3.0.0` package peer vs `>=5.7` for runes (all) | 🕒 LEDGER § Deferred | npm can't express per-subpath peer ranges; documented per-subpath in README/JSDoc/changeset. Needs a package split to fix | -| Audit historical counts ("5 subpath entries", flat subpaths) | 🕒 LEDGER § Rejections | Dated audit record; counts accurate to 2026-07-04 | -| `retryWrite` uncapped | by-design | JSDoc states the callback IS the termination policy | -| `mobx` `toJS` cost before `partialize` (G:#18) | by-design | `PersistableSource.getState` must return full state; `partialize` projects after. Inherent to the contract; document if needed | -| `crosstab` per-write post no coalescing (C:m18) | 💭 defer | Mitigated by `throttleMs`; design note, not a bug | - ---- - -## Recommended fix order - -1. **Tier 1 (1–10)** — one pass: `@example`/JSDoc corrections (1,3,4,9), changeset text (5), secure-store key handling (2), README matrix (6), audit ✅ reconciliation + `.nojekyll` prose (7,8,10). All S, in-bounds, no runtime behavior change. -2. **Tier 2 (11–13)** — decision required: confirm the three core-engine/crosstab bugs and the intended fix shape before applying (they change observable runtime behavior — outside the "no behavior change" harden guardrail, but legitimate bug fixes once approved). -3. **Tier 3 (14–30)** — test-gap + structure + perf pass; highest-value first: 14 (hydration gate), 15 (node-fs traversal), 27 (toBase64), 28 (crosstab size gate), 29 (codemap symlink), 30 (mobx runInAction). -4. **Tier 4 (31–45)** — doc/JSDoc nits, batch when touching each file. - ---- - -## Passing (no action — confirmed by all three reviewers) - -- Core zero-dep gate on `persist-core.ts`; no cross-adapter imports; 22/22 adapter `itImportsOnlyFromCore` checks; 202 unit + 4 DOM tests pass. -- `exports` ↔ `tsdown.config.ts` ↔ `typedoc.json` ↔ `architecture.md` ↔ README subpath tables aligned (23 subpaths + core). -- CI: coverage (90% gate, ~98% aggregate), size-limit, pack validation (attw+publint+knip), audit high/critical gate, trusted publishing. -- Hot paths verified clean: `createMigrationChain` O(k)+O(version); trailing throttle single-timer + generation supersede; crosstab `close()` clears handler map; `persist-core.ts` zero value imports. - ---- - -## Cross-reviewer agreement matrix - -| Finding | C | G | O | Verdict | -| ----------------------------------------------------------- | ------------ | --- | --- | ---------------------------------------- | -| encrypted `@example` `clearCorruptOnFailure` misplacement | – | ✅ | ✅ | confirmed | -| secure-store colon-key throw | – | ✅ | ✅ | confirmed | -| encrypted/compressed wrapper breaks native cross-tab events | ✅ | ✅ | – | confirmed | -| svelte `@example` inverted gate | – | ✅ | – | confirmed | -| audit `#3` false ✅ + `.nojekyll` missing | ✅ | ✅ | – | confirmed | -| audit `#31`/`#20` un-✅ despite shipped | ✅ | ✅ | – | confirmed | -| preact mock ignores `subscribe` | ✅ | ✅ | ✅ | confirmed (test gap) | -| svelte runes reactive ownership untested | ✅ | ✅ | ✅ | confirmed (defer — needs Svelte runtime) | -| `clearStorage` doesn't cancel pending writes | – | ✅ | – | confirmed real (narrow) | -| writes during async hydration skipped | – | ✅ | – | confirmed real (narrow) | -| crosstab remove-echo between tabs | – | ✅ | – | confirmed real (narrow) | -| `hydration.ts` zero-dep gate gap | ✅ | – | ✅ | confirmed (low risk) | -| node-fs traversal guard untested | – | ✅ | ✅ | confirmed | -| changeset `subpath-mirror` `minor` vs Breaking | ✅ | – | – | judgment call — not a blocker | -| base64 dedup extract | (CodeRabbit) | – | ❌ | reject (keep duplicate) | diff --git a/docs/audits/GPT-5.5-medium-harden-pr-full-findings.md b/docs/audits/GPT-5.5-medium-harden-pr-full-findings.md deleted file mode 100644 index 48ed972..0000000 --- a/docs/audits/GPT-5.5-medium-harden-pr-full-findings.md +++ /dev/null @@ -1,100 +0,0 @@ -# GPT-5.5-medium harden-pr full findings - -Mode: `/harden-pr full`, read-only/report-only. -Scope: `origin/main...HEAD` at `a882d082a786b8b2b80ae550b6a977e169f15476`. -PR: <https://github.com/stainless-code/persist/pull/7> - -User constraints: no code changes, no commit. This file is the requested findings artifact. - -## Status - -- Branch working tree was clean before writing this report. -- PR checks were green (`CI complete`, format, lint, typecheck, unit, DOM, build, audit, pack, size). -- PR is mergeable but review-required. -- Reviewer pass ran read-only across correctness, ship-readiness, structure, public API, tests, and performance. - -## Vetted Findings - -### Blocker - -1. `src/core/persist-core.ts` - `clearStorage()` can be undone by older writes. - - `clearStorage()` only calls `storage.removeItem(options.name)` and does not cancel a pending throttled write or bump `writeGeneration`. A pending timer or in-flight `retryWrite` loop can re-write the key after explicit clear/logout. This also affects `PersistRegistry.clearAll()`, because registered callbacks call `api.clearStorage()`. - -### Major - -2. `src/core/persist-core.ts` - writes during async hydration can be permanently skipped. - - The source subscription returns early while `hasHydratedFlag` is false. If state changes during an async hydrate and storage has no usable payload, the change is not written after hydration settles because `hadPendingWrite` only tracks cancelled throttle timers, not subscription events skipped by the hydration gate. - -3. `src/adapters/backends/encrypted.ts`, `src/adapters/backends/compressed.ts`, `src/core/persist-core.ts` - wrapped `localStorage` does not inherit native `storage` events. - - `createStorage` exposes the object returned by `getStorage()` as `PersistStorage.raw`. For encrypted/compressed wrappers over `localStorage`, `raw` becomes the wrapper object, while browser `StorageEvent.storageArea` is the real `localStorage`. The cross-tab identity guard rejects those events, so the README matrix claim that these wrappers inherit cross-tab behavior is not true for native storage events. - -4. `src/adapters/backends/secure-store.ts` - common documented names are invalid SecureStore keys. - - The adapter passes persist names directly to `SecureStore.getItemAsync` / `setItemAsync` / `deleteItemAsync`. Expo SecureStore keys may only contain alphanumeric characters, `.`, `-`, and `_`; documented/example keys like `auth:token:v1` will reject. - -5. `src/adapters/backends/encrypted.ts` - published JSDoc example passes an invalid option to `persistStore`. - - The hover example calls `persistStore(store, { name, storage, clearCorruptOnFailure: true })`, but `clearCorruptOnFailure` is a `createStorage` option, not a `PersistOptions` option. Copying the example should fail typecheck and teaches the wrong API surface. - -6. `.changeset/encrypted-compressed.md` - release note states the wrong encrypted corrupt-payload behavior. - - The changeset says wrong-key/tampered ciphertext decrypt failures go through persist-core's corrupt-payload path and can be removed by `clearCorruptOnFailure`. Decrypt happens inside backend `getItem`, so the failure rejects during hydrate instead of codec decode cleanup. - -7. `src/adapters/frameworks/svelte.ts`, `src/adapters/frameworks/svelte-store.ts` - Svelte examples reverse the hydration gate. - - Both examples render `<Skeleton />` when `hydrated` is true and `<Prefs />` when false. That is inverted from the documented gate behavior and will ship into published hovers. - -8. `docs/audits/2026-07-04-docs-adapters-roi.md`, `README.md` - API docs hosting is marked shipped but still not hosted. - - ROI item #3 is checked as shipped for linking/publishing the generated TypeDoc site and says `.nojekyll` is already present. Current README still says the API reference is not hosted, no GitHub Pages/docs site workflow exists, and no `.nojekyll` file is tracked. - -9. `docs/audits/2026-07-04-docs-adapters-roi.md` - shipped migration-chain item remains listed as unshipped. - - `createMigrationChain` is implemented, exported, tested, documented, and has a changeset, but audit item #31 still appears unmarked in Tier 4. This conflicts with the audit/plan convention that shipped ROI items are marked and remaining work is tracked in `docs/plans/remaining-roi.md`. - -10. `src/core/persist-core.ts`, `src/adapters/transport/crosstab.ts` - BroadcastChannel remove events can echo between tabs. - - `onCrossTabRemove` commonly resets the local source. With `skipPersist`, that reset calls `removeItem`; the BroadcastChannel wrapper broadcasts removals after successful `removeItem` even if the key was already absent. Two tabs using the bridge can bounce remove notifications/reset writes. - -### Minor - -11. `.cursor/skills/codemap/SKILL.md` - codemap skill symlink shape violates the agents-first convention. - - Other Cursor skills are tracked as `.cursor/skills/<name>` symlinks to `.agents/skills/<name>`. The codemap entry is a real directory containing only a `SKILL.md` symlink, so future sibling files under `.agents/skills/codemap/` would not be mirrored. - -12. `docs/plans/remaining-roi.md` - docs-site acceptance references missing `.nojekyll`. - - The docs-site plan says `.nojekyll` is already present, but the repo does not track it. This will mislead the next docs-site implementation. - -13. `docs/audits/2026-07-04-docs-adapters-roi.md` - npm provenance item is stale. - - ROI item #20 still describes `id-token: write` plus `--provenance` as unshipped. The branch implements trusted publishing via OIDC and `publishConfig.provenance`; the remaining work is first-release verification and old token cleanup, already captured in `docs/plans/remaining-roi.md`. - -14. `src/adapters/frameworks/preact.test.ts` - Preact subscription cleanup is not tested. - - The `preact/compat` mock returns `getSnapshot()` only and ignores `subscribe`, so tests do not exercise `signal.subscribeHydrated` wiring or unsubscribe cleanup. - -15. `src/adapters/frameworks/svelte.test.ts` - Svelte runes reactive ownership is not tested. - - Tests cover direct `current` reads and explicitly skip the `createSubscriber` path inside a Svelte owner. Reactive auto-update/cleanup remains unverified. - -16. `src/adapters/backends/node-fs.test.ts` - traversal guard lacks a regression test. - - `nodeFsStateStorage` refuses keys that sanitize to `.`, `..`, or empty, but tests only cover unsafe-character sanitization and collision resistance. A regression reopening traversal-by-key would pass. - -17. `src/adapters/transport/crosstab.test.ts` - failed-write broadcast suppression lacks tests. - - The wrapper now catches rejected `setItem`/`removeItem` follow-up promises and avoids broadcasting failed writes/removes, but no test covers those branches. - -18. `src/adapters/sources/mobx.ts` - large MobX sources pay full `toJS()` cost before `partialize`. - - The adapter reads `toJS(observable)` on every source change before persist-core can run `partialize`, so large observables pay O(store) clone/allocation cost even when the persisted slice is small. - -## Dropped During Vetting - -- `retryWrite` being uncapped was dropped as by-design for this report: the JSDoc explicitly states the callback is the termination policy and that a callback always returning state can spin forever. -- The package-level `svelte >=3.0.0` peer range was dropped per `harden-pr` ledger: it is shared by `./frameworks/svelte` and `./frameworks/svelte-store`, and cannot be fixed per subpath in this package. -- Dated audit historical counts were dropped per `harden-pr` ledger. diff --git a/docs/audits/Opus-4.8-high-harden-pr-full-findings.md b/docs/audits/Opus-4.8-high-harden-pr-full-findings.md deleted file mode 100644 index dc9d2f0..0000000 --- a/docs/audits/Opus-4.8-high-harden-pr-full-findings.md +++ /dev/null @@ -1,95 +0,0 @@ -# Opus-4.8-high — harden-pr (full) findings - -**Mode:** full · **Scope:** `origin/main...HEAD` (52 files) · **HEAD:** `a882d082a786b8b2b80ae550b6a977e169f15476` -**Branch:** `audit/docs-adapters-roi` (PR #7) · **Run:** review-only — **no code changed, no commit**. -**Reviewers (parallel, readonly):** Correctness · Ship-readiness · Structure · Public API · Tests · Performance. -**Suite:** `bun test ./src` = 202 pass / 0 fail (confirmed by two reviewers). - -## Intent anchor (contract used by every reviewer) - -- **Goal:** execute the 2026-07-04 ROI audit — ship adapters + `createMigrationChain`, refold `src/`→`src/core/`+`src/adapters/<seam>/` (breaking subpath rename mirroring folders), harden CI/supply-chain, docs rewrite. -- **Non-goals (must not change):** core runtime behavior; core stays zero-dep; subpaths own their optional peer, import only from `core/`, no barrel; source adapters shape-named; `maxAge` opt-in; sync-first read path; `.` core entry unchanged. - -## Verdict - -Branch is close to pristine. **3 in-bounds items worth fixing before ship** (2 real bugs in shipped `@example`/adapter code + 1 enforcement gap), all **S effort**. The remainder are test-coverage gaps, mock-fidelity notes (mostly overlapping CodeRabbit's already-triaged nitpicks), and doc nits. No blocker; no correctness bug in the core engine hot paths (verified clean). - ---- - -## Fix-before-ship (Major — new, not previously triaged) - -### M1 · Public API · `src/adapters/backends/encrypted.ts:35` · S - -The shipped `@example` passes `clearCorruptOnFailure: true` **to `persistStore(store, { ... })`**, but that flag is a `CreateStorageOptions` field consumed by `createStorage`, **not** a `PersistOptions` field. `persistSource`/`persistStore` never read it. - -- **Impact:** published example won't typecheck (TS excess-property error) and the flag is inert where placed. The README recipe (`README.md` ~L542) places it correctly on `createStorage`'s 3rd arg. -- **Vetted:** confirmed `PersistOptions` (persist-core.ts) has no `clearCorruptOnFailure`; it lives on `CreateStorageOptions`. -- **Fix (in-bounds):** move `clearCorruptOnFailure: true` into the `createStorage<Prefs>(..., ..., { clearCorruptOnFailure: true })` options arg in the example. - -### M2 · Correctness · `src/adapters/backends/secure-store.ts:42` (+ test:9) · S - -The adapter forwards the store `name` verbatim to `expo-secure-store`, whose keys **must match `/^[\w.-]+$/`** (alphanumeric, `.`, `-`, `_`). The repo-wide colon naming convention — including **this adapter's own JSDoc example `name: "auth:token:v1"`** — throws `Invalid key provided to SecureStore` at runtime on first get/set. - -- **Vetted:** real library constraint; the documented example would throw. -- **Mock-tautology:** `secure-store.test.ts` mocks with a plain `Map` that accepts any key (and the test happens to use colon-free `"secure-json"`), so the charset contract is never exercised. -- **Fix (in-bounds):** sanitize keys (e.g. `:` → `_`) in the adapter **or** change the JSDoc example + document that secure-store keys can't contain colons. (Sanitizing changes only this new adapter's on-storage key mapping — no cross-cutting behavior change.) - -### M3 · Structure · `src/core/persist-core.test.ts:22` · S - -The zero-dep gate test reads only `./persist-core.ts`. `hydration.ts` — the other half of the zero-dep core per the intent anchor **and its own file header** — is **not** enforced. - -- **Vetted:** confirmed the gate `describe` only `Bun.file("./persist-core.ts")`. `hydration.ts` currently has zero imports, so **no active violation** — but the invariant is regression-unguarded for half the core. -- **Severity note:** reviewer rated `major`; my vet leans **minor↔major** (tiny file, zero imports today → low regression risk). Either way, an S-effort add-a-second-assertion. -- **Fix (in-bounds):** extend the gate to also assert `hydration.ts` has no value imports. - ---- - -## Should-fix (Minor) - -| # | Bar | Location | Finding | Effort | -| --- | ----------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -| m1 | Tests | `src/adapters/backends/node-fs.ts:45` | Path-traversal/`..`/`.`/empty-key refusal guard (security-relevant) has **zero test coverage** — a regression reintroducing traversal passes CI. Add a test asserting `setItem("..", …)` / `"."` throws. | S | -| m2 | Tests | `src/adapters/sources/jotai.test.ts:49` | Atom **replace-merge** contract only implicitly covered (primitive round-trip). No object-atom test proving replace ≠ core's shallow-spread; the user-supplied-`merge` branch (left of `??`) untested. | S | -| m3 | Tests | `src/adapters/frameworks/preact.test.ts:5` | Mock ignores the `subscribe` arg → no test verifies the hydration flip rerenders or that unsubscribe fires. Unlike React (which has `tests-dom/`), preact has no DOM coverage → reactive wiring effectively untested. | M | -| m4 | Correctness/Tests | `src/adapters/sources/mobx.ts:31` (+ `mobx.test.ts:9`) | `Object.assign(observable, next)` writes **without `runInAction`** → under `configure({ enforceActions: "always" })` MobX warns on every hydrate/write; the hand-rolled mock never enforces actions so it's uncatchable. (Merged: impl gap + mock-fidelity.) | S–M | -| m5 | Docs | `README.md:3` (+ L397 intro) | Headline tagline — "storage × codec seams, **TanStack Store adapters, and a React hydration hook**" — undersells the shipped surface (7 framework adapters, 5 source adapters, 6 new backends, zod codec, crosstab). Tables below are correct → headline drift, not a broken contract. | S | -| m6 | Perf/Ship shape | `.size-limit.json` | Gates 6 of 23 bundles (core/react/seroval/encrypted/compressed/zod — the heaviest own-code). Net-new **`transport/crosstab`** (shipped surface) has **no** size gate; the thin framework/source/RN-backend wrappers also unbudgeted. Adding a `transport/crosstab` line is the highest-value single addition. (Rest is a defensible intentional subset per audit #23.) | S | - ---- - -## Nits (fix when touching the file) - -| # | Bar | Location | Finding | -| --- | ---------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| n1 | Public API | `src/adapters/backends/encrypted.ts:15` | `clearCorruptOnFailure` JSDoc wording is self-contradictory ("only fires when the _codec_ throws parsing a **non-corrupt** raw" — it fires precisely when the raw **is** corrupt/unparseable). Intent (backend-read reject → phase `hydrate`, vs codec parse-fail → self-heal) is correct; the phrase reads wrong in hovers. | -| n2 | Docs | `src/adapters/frameworks/react.ts:34` (+ `preact.ts`) | `@example` fenced ` ```ts ` but contains JSX (`return <Skeleton />`) → should be ` ```tsx ` for published-typing fidelity. | -| n3 | Docs | `src/adapters/sources/zustand.ts:16` | `@example` uses `createJSONStorage(() => localStorage)` but never imports it (a real core export) → snippet won't compile as-is. **Vetted.** | -| n4 | Public API | `src/adapters/frameworks/preact.ts:7` | `UseHydratedResult.hydrated` has no JSDoc, while the parallel `react.ts` result type carries "gates UI flash only, never the state read". Inconsistent hovers across near-identical public types. | -| n5 | Structure | `src/core/hydration.ts:2` | Doc-comment "see `./frameworks/react`" is stale after the refold — hydration.ts now lives in `src/core/`, so the module is at `../adapters/frameworks/react` (comment only, no import/boundary impact). | -| n6 | Docs | `docs/plans/remaining-roi.md:66` | Item numbering skips #6 (`### 5` → `### 7`) — internal renumbering slip in a live plan. | -| n7 | Tests | `src/adapters/backends/encrypted.ts:89` (test) | `decryptAesGcm` "invalid ciphertext payload" branch (missing `.` separator) untested; wrong-key auth-tag reject is covered. | -| n8 | Tests | `src/adapters/frameworks/angular.test.ts:25` | Mock runs `effect()` synchronously → hides the async gap `angular.ts:30` guards (re-read `isHydrated()` at attach). Deleting that guard line would still pass. | -| n9 | Tests | `src/adapters/sources/zustand.test.ts:17` | Mock does full-replace `setState`; real zustand shallow-merges a function-updater return. Functionally safe today (persist-core always returns full merged state), so the distinction is invisible. | -| n10 | Tests | `src/adapters/frameworks/svelte.test.ts:39` | Runes `createSubscriber`→`subscribeHydrated` path never invoked in bun (no Svelte owner). Value contract covered; `HydrationSignal` contract pinned in `core/hydration.test.ts`. `fixable_in_bounds: false` (needs a Svelte runtime). | - ---- - -## Info (log only — no action) - -- **i1 · `src/core/persist-core.ts:463`** — `createMigrationChain` return type `Promise<S>` doesn't reflect the `undefined` a discard path resolves (deliberate `undefined as unknown as S` cast). Correct when plugged into `PersistOptions.migrate`; the type slightly overstates for a caller invoking the returned fn directly. Fixing = semantic API change → **out of bounds**. -- **i2 · base64 duplication** (`encrypted.ts:100` ≡ `compressed.ts:98`) — Performance reviewer confirms keeping the duplication is the **right** call: extracting to `core/` spends bytes against the 3 KB zero-dep-core budget for a 2-consumer helper, and a shared adapter util would violate the per-entry `assert-core-only-imports` isolation. **Do not extract** (counters CodeRabbit's extract suggestion). -- **i3 · Coverage** — `bun test ./src` 202/0; no new file has an untested surface likely to drag the 0.90 line gate (only the small guarded branches in m1/n7/n10). -- **i4 · Hot paths verified clean** — `createMigrationChain` construction is O(k)+O(k log k)+O(version) with a bounded runtime walk (no O(n²)); the trailing throttle is a single coalescing timer with generation-guard supersede; crosstab `wrap` allocates one deferred microtask per write and `close()` clears the handler map + swallows rejections (no leak); `persist-core.ts` has zero value imports so no peer is dragged into the core bundle. - ---- - -## Dropped at vet step (not re-raised) - -- **svelte peer range** — `package.json` declares `svelte >=3.0.0` while `./frameworks/svelte` (runes) needs `>=5.7.0`. Already in **LEDGER § Deferred** (npm can't express per-subpath peer ranges; documented per-subpath in README/JSDoc/changeset). Out of bounds without splitting the subpath into its own package. -- **`docs/audits/2026-07-04-docs-adapters-roi.md` historical counts** ("5 subpath entries", etc.) — **LEDGER § Rejections**: dated audit record, counts accurate to the audit date. - ---- - -## Note on overlap with CodeRabbit - -The mock-fidelity items (m4 mobx, m3 preact, n8 angular, n9 zustand) and the size-gate breadth (m6) restate CodeRabbit nitpicks the author already triaged (applied the core-only-imports dedup; deferred/declined the mock-fidelity ones). **New this pass:** M1 (encrypted `@example` `clearCorruptOnFailure` misplacement), M2 (secure-store colon-key runtime throw), M3 (hydration.ts gate gap), n3 (zustand missing import), n1 (encrypted wording), n5 (hydration.ts stale path), n6 (roi numbering). M1 and M2 are the highest-value catches — both ship broken/misleading `@example` or runtime behavior in new public adapters. diff --git a/docs/audits/composer-2.5-fast-harden-pr-full.md b/docs/audits/composer-2.5-fast-harden-pr-full.md deleted file mode 100644 index 5404ada..0000000 --- a/docs/audits/composer-2.5-fast-harden-pr-full.md +++ /dev/null @@ -1,143 +0,0 @@ -# Harden PR — full pass (read-only) - -**Model:** composer-2.5-fast -**Mode:** full (`origin/main...HEAD`) -**HEAD:** `a882d082a786b8b2b80ae550b6a977e169f15476` -**Branch:** `audit/docs-adapters-roi` · [PR #7](https://github.com/stainless-code/persist/pull/7) -**Date:** 2026-07-06 -**Constraints:** no code changes · no commit · findings only - ---- - -## Intent anchor - -Execute the [2026-07-04 docs-adapters ROI audit](2026-07-04-docs-adapters-roi.md): ship adapters, refold `src/` → `core/` + `adapters/<seam>/`, breaking subpath rename, `createMigrationChain`, README/docs rewrite, CI/supply-chain hardening. In-bounds fixes must not redesign features or change observable runtime behavior beyond bug fixes. - ---- - -## Baseline checks (at review time) - -| Check | Status | -| --------------- | ------------------------------------------------------- | -| `bun run check` | pass (build, format, lint, 202 unit + 4 DOM, typecheck) | -| CI on PR #7 | all jobs green | -| Coverage gate | 90% threshold met (~98% aggregate per reviewers) | - -**Production bar:** **not met** — 2 blockers, 8 major (incl. 1 already in LEDGER § Deferred), several minor/nit gaps below. - ---- - -## Vetted findings - -Sorted: severity → confidence desc → effort asc. Deduped across 6 reviewers (Correctness, Ship-readiness, Structure, Public API, Tests, Performance). Vet step applied: LEDGER § Rejections consulted; each survivor re-read at cited location. - -### Blocker - -| # | Severity | File | Line | Finding | Conf | Effort | Bar | In bounds | -| --- | -------- | --------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | ------ | ---- | --------- | -| B1 | blocker | `.changeset/subpath-mirror-folders.md` | 2 | Frontmatter declares `@stainless-code/persist: **minor**` while the body labels four import-path renames as **Breaking**. Semver/changelog will under-bump the release. | high | S | Docs | yes | -| B2 | blocker | `docs/audits/2026-07-04-docs-adapters-roi.md` | 114 | ROI item **#3** is ✅ (“Link `docs/api/` + publish to GitHub Pages”) but README § API reference still says “not hosted yet” (`README.md:882`), no GitHub Pages workflow exists, and `.nojekyll` is absent from the tracked tree. False shipped mark vs consumer docs. | high | S | Docs | yes | - -### Major - -| # | Severity | File | Line | Finding | Conf | Effort | Bar | In bounds | -| --- | -------- | --------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | ------ | ----------- | --------- | -| M1 | major | `src/core/persist-core.ts` | 976 | **`crossTab: true` + encrypted/compressed wrapper over `localStorage` silently ignores cross-tab updates.** `createStorage` sets `PersistStorage.raw` to the wrapper `StateStorage`; browser `StorageEvent.storageArea` is the underlying `localStorage`. Guard `event.storageArea !== raw` rejects every event. README decision matrix marks encrypted/compressed cross-tab as “inherits” (`README.md:502–503`) — misleading; users expect localStorage cross-tab to work. Workaround: `createBroadcastCrossTab` (`storageArea: null` → key-only). IDB + bridge path unaffected. | high | M | Correctness | yes | -| M2 | major | `docs/plans/remaining-roi.md` | 48 | Prose claims “`.nojekyll` already present” for GitHub Pages; file does not exist anywhere in repo (same root cause as B2). | high | S | Docs | yes | -| M3 | major | `docs/audits/2026-07-04-docs-adapters-roi.md` | 11 | Audit **TL;DR + appendix C** still describe pre-ship consumer-doc gaps (no hydration explainer, no IDB walkthrough, registry invisible, API site unlinked) that contradict the ROI table ✅ marks and the current README. Readers cannot trust audit status columns without a “historical snapshot vs post-branch” banner. | high | M | Docs | yes | -| M4 | major | `docs/audits/2026-07-04-docs-adapters-roi.md` | 158 | **`createMigrationChain` shipped** (core export, README recipe, `.changeset/migration-chain.md`) but audit ROI **#31** remains un-✅. Plan lifecycle drift vs `remaining-roi.md` contract. | high | S | Docs | yes | -| M5 | major | `docs/audits/2026-07-04-docs-adapters-roi.md` | 143 | **npm trusted publishing implemented** (`release.yml`, `publishConfig.provenance`) but audit ROI **#20** remains un-✅. | high | S | Docs | yes | -| M6 | major | `README.md` | 57 | Install optional-peer table lists `./frameworks/svelte` and `./frameworks/svelte-store` both as peer `svelte` with no version split; Entry points table + `architecture.md` require `>=5.7` for runes vs `>=3` for svelte-store. Intra-README drift. | high | S | Docs | yes | -| M7 | major | `src/adapters/frameworks/preact.test.ts` | 5 | `useSyncExternalStore` mock returns `getSnapshot()` only — **`subscribeHydrated` wiring and rerender-on-flip never exercised**; tests re-invoke the hook per assertion instead of a mounted component lifecycle. React has `tests-dom/` parity; Preact does not. | high | M | Tests | yes | -| M8 | major | `src/adapters/frameworks/svelte.test.ts` | 39 | Svelte 5 `hydratedRune` reactive auto-update / `createSubscriber` cleanup untested outside a Svelte owner (suite documents gap). Public subscribe path unverified at production bar. | high | L | Tests | yes | - -### Minor - -| # | Severity | File | Line | Finding | Conf | Effort | Bar | In bounds | -| --- | -------- | ------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------ | ---------- | --------- | -| m1 | minor | `src/adapters/backends/encrypted.test.ts` | — | No test for wrapper-backend + `crossTab: true` on localStorage (M1 combo). | high | S | Tests | yes | -| m2 | minor | `src/core/persist-core.test.ts` | 22 | Zero-dep gate scans **`persist-core.ts` only**; `hydration.ts` ships via `.` entry but has no matching value-import gate. | high | S | Structure | yes | -| m3 | minor | `README.md` | — | `alwaysHydratedSignal` exported from core; no README/skill mention (audit appendix still flags hidden). | high | S | Docs | yes | -| m4 | minor | `docs/audits/2026-07-04-docs-adapters-roi.md` | 635 | Appendix C.2 export inventory still documents flat legacy subpaths (`/seroval`, `/idb`, …) without “pre-refold” annotation. | high | S | Docs | yes | -| m5 | minor | `src/adapters/codecs/zod.ts` | 24 | `zodCodec` lacks `@example` (factory has one). | high | S | Public API | yes | -| m6 | minor | `src/core/persist-core.ts` | 422 | `CreateMigrationChainOptions.onNewer` / `onOlder` use prose defaults, not `@default` tags (unlike `PersistOptions`). | high | S | Public API | yes | -| m7 | minor | `README.md` | 403 | Entry table omits `toHydrationSignal` / `alwaysHydratedSignal`; mislabels `HydrationSignal` as ``(`hydration`)`` (no such export). | high | S | Public API | yes | -| m8 | minor | `.changeset/node-fs-pack-gate.md` | 7 | Changeset mixes consumer `./backends/node-fs` API with maintainer CI internals (attw, knip, publint). | high | S | Public API | yes | -| m9 | minor | `src/adapters/transport/crosstab.ts` | 100 | `wrap().setItem`/`removeItem` `.catch` branches (suppress broadcast on backend failure) uncovered. | high | S | Tests | yes | -| m10 | minor | `src/adapters/transport/crosstab.ts` | 67 | `crossTabEventTarget.removeEventListener` teardown path untested. | high | S | Tests | yes | -| m11 | minor | `src/adapters/backends/encrypted.ts` | 53 | Invalid-backend shape guard (lines 53–58) uncovered. | high | S | Tests | yes | -| m12 | minor | `src/adapters/backends/encrypted.ts` | 89 | Malformed ciphertext wire format (not wrong-key) untested. | high | S | Tests | yes | -| m13 | minor | `src/adapters/backends/compressed.ts` | 51 | Invalid-backend shape guard uncovered. | high | S | Tests | yes | -| m14 | minor | `src/adapters/backends/compressed.test.ts` | — | No corrupt/non-base64 decompress failure test. | high | S | Tests | yes | -| m15 | minor | `src/adapters/backends/compressed.ts` | 18 | Documented compress-then-encrypt stack has no integration test. | medium | M | Tests | yes | -| m16 | minor | `tests-dom/react.test.tsx` | — | DOM suite React-only; Preact shares `useSyncExternalStore` but has no jsdom parity suite. | high | M | Tests | yes | -| m17 | minor | `src/adapters/backends/{encrypted,compressed}.ts` | 100 | `toBase64` uses O(n²) per-byte string concat — latency on large payloads. | high | S | Ship shape | yes | -| m18 | minor | `src/adapters/transport/crosstab.ts` | 92 | Per-write BroadcastChannel post with no coalescing; peer tabs run overlapping full rehydrates under burst writes (mitigate with `throttleMs`). | high | M | Ship shape | yes | - -### Nit - -| # | Severity | File | Line | Finding | Conf | Effort | Bar | In bounds | -| --- | -------- | -------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------ | ------ | ------ | ---------- | --------- | -| n1 | nit | `docs/glossary.md` | 5 | No `transport/` seam term (`./transport/crosstab`) vs `architecture.md`. | high | S | Docs | yes | -| n2 | nit | `.changeset/subpath-mirror-folders.md` | 14 | Maintainer internals (tsdown keys, `persist-` prefix) in consumer changeset. | high | S | Public API | yes | -| n3 | nit | `src/adapters/codecs/seroval.ts` | 20 | `serovalCodec` lacks `@example` (factory has one). | high | S | Public API | yes | -| n4 | nit | `src/adapters/codecs/zod.ts` | 20 | JSDoc cites “persist-core's corrupt-payload path” — prefer consumer-facing language. | medium | S | Public API | yes | -| n5 | nit | `src/core/persist-core.test.ts` | 2094 | `createMigrationChain` `onOlder: 'throw'` unit-tested but no persistSource e2e (unlike discard/throwing-step e2e). | medium | S | Tests | yes | - ---- - -## Already deferred (LEDGER § Deferred — do not re-fix without architecture decision) - -| File | Finding | Reason | -| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| `package.json` peerDependencies | `./frameworks/svelte` needs `>=5.7` per README/JSDoc but package declares `svelte >=3.0.0` shared with `./frameworks/svelte-store` | Package-level peers cannot split per subpath without a separate package or peer rename policy | - -Related vetted items: M6 above (README tables); duplicate Public API entries merged here. - ---- - -## Dropped at vet (false / by-design / duplicate) - -| Claim | Verdict | -| ------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| Audit “5 subpath entries” historical counts | **Drop** — LEDGER § Rejections: dated audit record accurate to 2026-07-04 | -| “Missing changeset for new export” | **Drop** — all 11 feature changesets present; only B1 semver type wrong | -| “Exports / tsdown / typedoc misaligned” | **Drop** — verified aligned (23 subpaths + core) | -| “No co-located tests / isolation checks” | **Drop** — 22/22 adapter entries have `itImportsOnlyFromCore`; 202 tests pass | -| persist-core hot-path regression | **Drop** — diff adds `createMigrationChain` only; hydrate/write/cross-tab core unchanged vs main | - ---- - -## Passing (no action) - -- Core zero-dep gate on `persist-core.ts`; no cross-adapter imports detected. -- Architecture: `exports` ↔ `tsdown.config.ts` ↔ `typedoc.json` ↔ `architecture.md` ↔ README subpath tables aligned. -- CI: coverage, size-limit, pack validation (`attw`+`publint`+`knip`), audit gate, trusted publishing workflow in place. -- Co-located test + isolation pattern consistent across adapters. - ---- - -## Recommended fix order (if acting on this report) - -1. **B1** — bump `.changeset/subpath-mirror-folders.md` to `major`. -2. **B2 + M2 + M3** — reconcile audit ✅ marks with reality (either ship GitHub Pages + link, or un-✅ #3 and fix prose); add TL;DR banner for historical appendix. -3. **M1 + m1 + README matrix** — fix or document wrapper + `crossTab` limitation; add regression test. -4. **M4 + M5** — ✅ audit rows #31 and #20. -5. **M6** — README install table version split (peer range M7 stays deferred unless package split). -6. **M7 + m16** — Preact DOM tests or improve bun mock to exercise subscribe. -7. Remaining minor/nit doc + JSDoc + test gaps in pass 2. - ---- - -## Reviewers spawned - -| Role | Agent | -| -------------- | ---------------------------------------------------------------------------- | -| Correctness | [2397b09f-d885-4c63-99de-40fc178f3ff8](2397b09f-d885-4c63-99de-40fc178f3ff8) | -| Ship-readiness | [3ef32333-594e-48d4-b58c-fffba1b70eeb](3ef32333-594e-48d4-b58c-fffba1b70eeb) | -| Structure | [17732c03-4dc0-44b4-833b-a3693321bad3](17732c03-4dc0-44b4-833b-a3693321bad3) | -| Public API | [bb0db2ca-ba31-470d-a3e8-292f361ba339](bb0db2ca-ba31-470d-a3e8-292f361ba339) | -| Tests | [71a1a07b-1896-4217-a695-c2eedf6bef0c](71a1a07b-1896-4217-a695-c2eedf6bef0c) | -| Performance | [988590a5-2895-463a-83c3-1c0447edd7f5](988590a5-2895-463a-83c3-1c0447edd7f5) | - -**Passes run:** 1 of 3 (read-only stop — no fix loop per user request). diff --git a/docs/plans/remaining-roi.md b/docs/plans/remaining-roi.md index 223a38a..05c9fb8 100644 --- a/docs/plans/remaining-roi.md +++ b/docs/plans/remaining-roi.md @@ -56,11 +56,11 @@ Framework adapters mount `HydrationSignal` into each framework's external-store - **Acceptance (remaining):** the next changeset merge to `main` publishes with provenance — verify the npm version page shows the Provenance badge, or `npm view @stainless-code/persist@<ver> --json` includes `dist.attestations`. Then revoke + delete the old `NPM_TOKEN` repo secret. Strike this item once verified. - **Lands:** `.github/workflows/release.yml` + `package.json`. No changeset (infra-only). -### 5. Real-browser + SSR test matrix — Tier 3, M +### 5. Real-browser + SSR + framework-runtime test matrix — Tier 3, M -- **What:** add a Playwright job covering the React `useHydrated` rerender/detach path in a real browser (Chromium + WebKit/Safari); add a Next.js SSR smoke that asserts the server renders `hydrated: true` and the client hydrates without a flash. The `tests-dom` vitest/jsdom suite stays (fast); Playwright is the slow, real-environment gate. -- **Why:** today the matrix is jsdom only — no real browser, no Safari, no SSR-framework. `useSyncExternalStore` reactivity and SSR snapshot policy are the constraint-critical paths; jsdom can diverge from real browsers. -- **Acceptance:** CI `Test (Browser)` job runs Playwright green; `Test (SSR)` job runs a Next.js app green; both gated by `CI complete`. Co-locate fixtures under `tests-browser/` and `tests-ssr/` (outside `bun test ./src`'s scan, like `tests-dom/`). +- **What:** add a Playwright job covering the React `useHydrated` rerender/detach path in a real browser (Chromium + WebKit/Safari); add a Next.js SSR smoke that asserts the server renders `hydrated: true` and the client hydrates without a flash. The `tests-dom` vitest/jsdom suite stays (fast); Playwright is the slow, real-environment gate. **Framework-runtime coverage gaps to close here** (bun mocks can't exercise the reactive wiring): Preact `useHydrated` subscribe/unsubscribe + rerender-on-flip (add a `tests-dom` jsdom suite — parity with React); Svelte 5 runes `createSubscriber` reactive ownership + cleanup (needs a Svelte component runtime); Angular `effect()` async attach timing (needs an Angular runtime so the `angular.ts:30` re-read guard is exercised, not hidden by a sync mock). +- **Why:** today the matrix is jsdom only — no real browser, no Safari, no SSR-framework. `useSyncExternalStore` reactivity and SSR snapshot policy are the constraint-critical paths; jsdom can diverge from real browsers. The Preact/Svelte/Angular adapters ship reactive wiring that the bun mocks never actually drive. +- **Acceptance:** CI `Test (Browser)` job runs Playwright green; `Test (SSR)` job runs a Next.js app green; Preact jsdom suite green; Svelte + Angular runtime tests green (or an explicit decision per-adapter to defer to a community recipe). All gated by `CI complete`. Co-locate fixtures under `tests-browser/`, `tests-ssr/`, and `tests-dom/preact.test.tsx` (outside `bun test ./src`'s scan, like `tests-dom/`). - **Lands:** `.github/workflows/ci.yml` + new test dirs; `docs/architecture.md` § Test matrix updated. No changeset (test-only). ### 6. React ergonomics layer — Tier 4, M-L From e7f9f511126c70717ee062d5a733f2490e56c98d Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Mon, 6 Jul 2026 12:17:04 +0300 Subject: [PATCH 72/77] =?UTF-8?q?docs:=20fact-check=20sweep=20=E2=80=94=20?= =?UTF-8?q?fix=20drifted=20claims=20across=20README,=20architecture,=20glo?= =?UTF-8?q?ssary,=20roadmap,=20plans,=20CONTRIBUTING,=20AGENTS,=20changese?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five parallel fact-check passes against the current codebase (23 exports, 217 tests). Applied the real drift; skipped "keep" and optional items. - README: core entry row adds `toHydrationSignal` + `alwaysHydratedSignal`, drops the `(`hydration`)` pseudo-path; RN seam snippet uses `createAsyncStorage()` (AsyncStorage wasn't in scope); zodCodec matrix `Set/Map/Date` cell → `✗ (JSON wire; only via schema transforms)`; namespaced-IDB recipe imports `createStore` - architecture: "one subpath = one optional peer" reworded (6 entries have no peer — core/encrypted/compressed/node-fs/crosstab); hydration lifecycle lists Angular + Preact mechanisms - glossary: `entry` definition reworded (optional peer only when needed) - roadmap: Strategy table adds Source-adapters + Transport rows; framework adapter LOC `~20` → `~30–45` - remaining-roi: isolation test → `itImportsOnlyFromCore` helper; zero-dep gate → both `persist-core.ts` + `hydration.ts`; drop stale `persist-core.ts:12,263` + `react.ts:22` anchors; `three` → `four` new backends - upstream-tanstack-pitch: framework inventory adds Svelte-store - CONTRIBUTING: zero-dep gate scope (both files); DOM glob `.{ts,tsx}`; pre-commit `format:check`/`oxfmt --check` (not write-mode); `bunx changeset` (no script) - AGENTS: intent list adds `codemap` - changesets: encrypted fallback wording; node-fs pack-gate trims CI internals + "semver gate"; subpath-mirror strips contributor-layout internals; solid-vue uses `./frameworks/react` (not internal path); svelte-hydration peer wording (runtime ≥5.7, package peer stays >=3.0.0) Verified: `bun run check` green; 217 pass / 0 fail. --- .changeset/encrypted-compressed.md | 2 +- .changeset/node-fs-pack-gate.md | 2 +- .changeset/solid-vue-hydration.md | 2 +- .changeset/subpath-mirror-folders.md | 4 +- .changeset/svelte-hydration.md | 2 +- .github/CONTRIBUTING.md | 8 ++-- AGENTS.md | 2 +- README.md | 67 ++++++++++++++------------- docs/architecture.md | 6 +-- docs/glossary.md | 14 +++--- docs/plans/remaining-roi.md | 12 ++--- docs/plans/upstream-tanstack-pitch.md | 2 +- docs/roadmap.md | 4 +- 13 files changed, 65 insertions(+), 62 deletions(-) diff --git a/.changeset/encrypted-compressed.md b/.changeset/encrypted-compressed.md index 9270f19..b346317 100644 --- a/.changeset/encrypted-compressed.md +++ b/.changeset/encrypted-compressed.md @@ -4,7 +4,7 @@ Add two zero-dep storage wrappers over the `StateStorage` seam — both async web-global adapters (no peer dep), composing with `createStorage(backend, codec)`: -- `./backends/encrypted` — `createEncryptedStorage(getStorage, { key })`: AES-GCM via WebCrypto (`crypto.subtle`). Each stored value is `base64(iv).base64(ciphertext)`; the AES-GCM auth tag means a wrong key or tampered ciphertext throws on decrypt → the backend's async `getItem` rejects → persist-core reports it via `onError` phase `"hydrate"` (NOT the codec's `clearCorruptOnFailure` path, which only fires when the codec throws parsing a raw value). Returns `undefined` when `crypto.subtle` is unavailable so `createStorage` collapses to the no-op `PersistApi`. +- `./backends/encrypted` — `createEncryptedStorage(getStorage, { key })`: AES-GCM via WebCrypto (`crypto.subtle`). Each stored value is `base64(iv).base64(ciphertext)`; the AES-GCM auth tag means a wrong key or tampered ciphertext throws on decrypt → the backend's async `getItem` rejects → persist-core reports it via `onError` phase `"hydrate"` (NOT the codec's `clearCorruptOnFailure` path, which only fires when the codec throws parsing a raw value). Returns `undefined` when `crypto.subtle` is unavailable; `createStorage` then returns `undefined` (the consumer can fall back, or `persistSource` no-ops if no storage resolves). - `./backends/compressed` — `createCompressedStorage(getStorage, { format? })`: native `CompressionStream`/`DecompressionStream` (`gzip` | `deflate` | `deflate-raw`, default `gzip`); output is base64 so it stays string-wire. Returns `undefined` when the stream APIs are unavailable. Stacks with `createEncryptedStorage` (compress-then-encrypt is the standard order). **Design note:** encryption + compression are backend **wrappers**, not sync `StorageCodec`s, because `crypto.subtle` and the stream APIs are async and the `StorageCodec` seam is sync. The codec serializes the envelope (sync); the wrapper encrypts/compresses the serialized string (async). diff --git a/.changeset/node-fs-pack-gate.md b/.changeset/node-fs-pack-gate.md index d02019e..631ad84 100644 --- a/.changeset/node-fs-pack-gate.md +++ b/.changeset/node-fs-pack-gate.md @@ -4,4 +4,4 @@ Add `./backends/node-fs` — `nodeFsStateStorage({ dir })`, an async `StateStorage<string>` over Node `fs.promises` (one file per key under `dir`). No peer dep (`node:fs` is a Node built-in). Keys are sanitized to filename-safe segments with a short hash suffix (`app:prefs:v1` → `app_prefs_v1.<hash>`) so distinct keys that sanitize to the same segment don't collide on one file; `..`/`.`/empty keys are refused; missing files map to `null` (no throw); the dir is created lazily on first write. Compose with `createStorage(() => nodeFsStateStorage({ dir }), codec)`. Unblocks server / SSR / CLI persistence. -Also adds a pack-validation + semver gate (`check:pack`: `@arethetypeswrong/cli` + `publint` + `knip`) wired into CI (`check-pack` job) + `prepublishOnly`; and README storage + codec decision matrices. +Also adds export/pack validation (`check:pack`: `@arethetypeswrong/cli` + `publint` + `knip`) and README storage + codec decision matrices. diff --git a/.changeset/solid-vue-hydration.md b/.changeset/solid-vue-hydration.md index 357bf58..07727a6 100644 --- a/.changeset/solid-vue-hydration.md +++ b/.changeset/solid-vue-hydration.md @@ -2,7 +2,7 @@ "@stainless-code/persist": minor --- -Add `./frameworks/solid` and `./frameworks/vue` hydration subpaths — `useHydrated(signal)` over the `HydrationSignal` seam, mirroring the React adapter (`src/adapters/frameworks/react.ts`). +Add `./frameworks/solid` and `./frameworks/vue` hydration subpaths — `useHydrated(signal)` over the `HydrationSignal` seam, mirroring the React adapter (`./frameworks/react`). - `./frameworks/solid` (peer `solid-js >=1.6.0`): returns a Solid `Accessor<boolean>` via `from`; the subscription is owned by the reactive scope and cleaned up on scope dispose. Uses the `from(producer, initialValue)` overload so the accessor is `Accessor<boolean>` (not `boolean | undefined`); reads `isHydrated()` for the initial value (pull-model signal — no initial notification). - `./frameworks/vue` (peer `vue >=3.3.0`): returns a Vue `Ref<boolean>` via `shallowRef`; subscription cleaned up via `onScopeDispose` — call inside `setup()` or an `effectScope()`. diff --git a/.changeset/subpath-mirror-folders.md b/.changeset/subpath-mirror-folders.md index dd9404b..849827d 100644 --- a/.changeset/subpath-mirror-folders.md +++ b/.changeset/subpath-mirror-folders.md @@ -2,7 +2,7 @@ "@stainless-code/persist": minor --- -Reorganize the public subpath namespace to mirror the folder structure (`src/core/` + `src/adapters/<seam>/`). Adapter subpaths are now category-prefixed so the public surface is 1:1 with the source layout — a contributor adding `adapters/backends/opfs.ts` knows the subpath is `./backends/opfs` with zero mental mapping. **Breaking** (early package; consumers must update imports) — the four subpaths that existed on `main` are renamed: +Reorganize the public subpath namespace. Adapter subpaths are now category-prefixed. **Breaking** (early package; consumers must update imports) — the four subpaths that existed on `main` are renamed: - `./seroval` → `./codecs/seroval` - `./idb` → `./backends/idb` @@ -11,4 +11,4 @@ Reorganize the public subpath namespace to mirror the folder structure (`src/cor The rest of the surface (`./codecs/zod`, `./backends/{async-storage,mmkv,secure-store,encrypted,compressed,node-fs}`, `./transport/crosstab`, `./sources/{zustand,jotai,valtio,mobx}`, `./frameworks/{solid,vue,svelte,svelte-store,angular,preact}`) is NEW — added by sibling changesets, not renames. -The `.` (core) entry is unchanged. `dist/` mirrors `src/` (`dist/<seam>/<name>.mjs` via tsdown's record-form `entry` keyed by `<seam>/<name>`) — src folder → tsdown key → dist path → subpath, all 1:1. Internal `src/` was refolded into `core/` + `adapters/<seam>/` with the `persist-` filename prefix dropped (folder is the category, file is the subpath basename). +The `.` (core) entry is unchanged. diff --git a/.changeset/svelte-hydration.md b/.changeset/svelte-hydration.md index e522d0e..ee6f687 100644 --- a/.changeset/svelte-hydration.md +++ b/.changeset/svelte-hydration.md @@ -4,7 +4,7 @@ Add Svelte hydration adapters over the `HydrationSignal` seam, covering both pre- and post-runes Svelte. Two subpaths because `svelte/reactivity` (runes) is Svelte 5+ and would break a Svelte 4 import — each subpath owns its dep range: -- `./frameworks/svelte` (peer `svelte >=5.7.0` — `createSubscriber` from `svelte/reactivity` landed in 5.7) — `hydratedRune(signal)` via `svelte/reactivity` `createSubscriber`. Returns `{ readonly current: boolean }`; read `current` inside a reactive context (`$derived`/`$effect`/component/`{#if}`). Subscription owned by the reactive context, cleaned up on context dispose. Post-runes. +- `./frameworks/svelte` (requires Svelte ≥5.7 at runtime for `createSubscriber`; the package peer stays `>=3.0.0` shared with `./frameworks/svelte-store`) — `hydratedRune(signal)` via `svelte/reactivity` `createSubscriber`. Returns `{ readonly current: boolean }`; read `current` inside a reactive context (`$derived`/`$effect`/component/`{#if}`). Subscription owned by the reactive context, cleaned up on context 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 (for users who prefer the store API). Subscription tied to the store's subscriber lifecycle. Both render `true` on the server (no-op `PersistApi` is always-hydrated) — matching the `HydrationSignal` adapter contract. Each is its own subpath with `svelte` optional, no cross-entry value imports. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 02dff2c..3dcbbaa 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -3,7 +3,7 @@ `@stainless-code/persist` is a small, freshly extracted library. Before large PRs, please open an issue so we can align on: - **Public surface** — anything exported from an entry point (`src/core/index.ts` and the opt-in adapter subpaths) is the public API and must carry JSDoc that reads well in hovers and published typings. See [`docs/architecture.md`](../docs/architecture.md) for the seam model. -- **Runtimes** — **Node** `^20.19.0 || >=22.12.0` and **Bun** `>=1.0.0` (`package.json` **engines**). The core is zero-dep by design (enforced by a gate test); each subpath owns its optional peer. +- **Runtimes** — **Node** `^20.19.0 || >=22.12.0` and **Bun** `>=1.0.0` (`package.json` **engines**). The core is zero-dep by design (enforced by a gate test on both `persist-core.ts` and `hydration.ts`); each subpath owns its optional peer. ## Dev workflow @@ -21,7 +21,7 @@ bun run check-updates # interactive dependency updates (`bun update -i --latest bun run clean # remove untracked/ignored build artifacts (keeps .env) ``` -The test suite is split by what it needs: `bun:test` for `src/**/*.test.ts` (no DOM), and `vitest` + jsdom + @testing-library/react for `tests-dom/**/*.test.tsx` (the React `useHydrated` rerender path). See [`docs/architecture.md § Test matrix`](../docs/architecture.md#test-matrix). +The test suite is split by what it needs: `bun:test` for `src/**/*.test.ts` (no DOM), and `vitest` + jsdom + @testing-library/react for `tests-dom/**/*.test.{ts,tsx}` (the React `useHydrated` rerender path). See [`docs/architecture.md § Test matrix`](../docs/architecture.md#test-matrix). ### `main` and pull requests @@ -38,7 +38,7 @@ Then open a PR on GitHub into **`main`**. ### Git hooks -[Husky](https://github.com/typicode/husky) + [lint-staged](https://github.com/latstg/lint-staged) — see [`.husky/pre-commit`](../.husky/pre-commit). Pre-commit runs **`lint-staged`** only when `CURSOR_AGENT`, `CLAUDECODE`, or `AI_AGENT` is set (AI/agent commits). Staged files get `oxfmt`, `oxlint`, staged-only **`tsgo`**, and **`bun test`** on `*.test.ts` / paired co-located tests. +[Husky](https://github.com/typicode/husky) + [lint-staged](https://github.com/latstg/lint-staged) — see [`.husky/pre-commit`](../.husky/pre-commit). Pre-commit runs **`lint-staged`** only when `CURSOR_AGENT`, `CLAUDECODE`, or `AI_AGENT` is set (AI/agent commits). Staged files get `bun run format:check` (`oxfmt --check`), `oxlint`, staged-only **`tsgo`**, and **`bun test`** on `*.test.ts` / paired co-located tests. ### Style @@ -46,7 +46,7 @@ Match Oxfmt/Oxlint; prefer **straight-line code** and extracted helpers over lon ### Releases -[@changesets/cli](https://github.com/changesets/changesets) — run **`bun run changeset`** when your PR should bump the version, and commit the `.changeset/*.md` file. The Release workflow opens a "Version packages" PR and publishes to npm on merge via trusted publishing (GitHub OIDC; no `NPM_TOKEN`). +[@changesets/cli](https://github.com/changesets/changesets) — run **`bunx changeset`** when your PR should bump the version, and commit the `.changeset/*.md` file. The Release workflow opens a "Version packages" PR and publishes to npm on merge via trusted publishing (GitHub OIDC; no `NPM_TOKEN`). ### Issues diff --git a/AGENTS.md b/AGENTS.md index 12f7f07..5e02f02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,6 @@ Canonical read order: [`.agents/README.md`](.agents/README.md) (START HERE). | Architecture | [`.agents/rules/architecture-priming.md`](.agents/rules/architecture-priming.md) | | Lessons | [`.agents/lessons.md`](.agents/lessons.md) | -Intent (not always-on): `docs-governance`, `docs-lifecycle-sweep`, `grilling`, `grill-me`, `grill-with-docs`, `teach`, `ask-agents`, `improve-codebase-architecture`, `domain-modeling`, `agents-tier-system`, `authoring-discipline`, `verify-after-each-step`, `writing-agents-config`, `harden-pr`, `diagnosing-bugs`, `tdd`, `pr-comment-fact-check`, `writing-great-skills` — see `.agents/README.md`. +Intent (not always-on): `docs-governance`, `docs-lifecycle-sweep`, `grilling`, `grill-me`, `grill-with-docs`, `teach`, `ask-agents`, `improve-codebase-architecture`, `domain-modeling`, `agents-tier-system`, `authoring-discipline`, `verify-after-each-step`, `writing-agents-config`, `harden-pr`, `diagnosing-bugs`, `tdd`, `pr-comment-fact-check`, `codemap`, `writing-great-skills` — see `.agents/README.md`. Human Day-1: [README](README.md) Getting Started · [`.github/CONTRIBUTING.md`](.github/CONTRIBUTING.md). diff --git a/README.md b/README.md index 21c5f33..9554462 100644 --- a/README.md +++ b/README.md @@ -398,31 +398,31 @@ Persistence middleware for any `getState`/`setState`/`subscribe` store (TanStack ## Entry points (one subpath = one optional peer) -| Subpath | Symbols | Optional peer | -| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | -| `@stainless-code/persist` | `persistSource`, `PersistApi`, `createStorage`, `createJSONStorage`, `createMigrationChain`, `jsonCodec`, `identityCodec`, `createPersistRegistry`, `HydrationSignal` (`hydration`) | none | -| `@stainless-code/persist/codecs/seroval` | `serovalCodec`, `createSerovalStorage` | `seroval` | -| `@stainless-code/persist/codecs/zod` | `zodCodec`, `createZodStorage` | `zod` | -| `@stainless-code/persist/backends/idb` | `idbStateStorage`, `createIdbStorage` (structured-clone mode) | `idb-keyval` | -| `@stainless-code/persist/backends/async-storage` | `asyncStorageStateStorage`, `createAsyncStorage` | `@react-native-async-storage/async-storage` | -| `@stainless-code/persist/backends/mmkv` | `mmkvStateStorage`, `createMmkvStorage` | `react-native-mmkv` | -| `@stainless-code/persist/backends/secure-store` | `secureStoreStateStorage`, `createSecureStoreStorage` | `expo-secure-store` | -| `@stainless-code/persist/backends/encrypted` | `createEncryptedStorage` (AES-GCM WebCrypto) | none (web global) | -| `@stainless-code/persist/backends/compressed` | `createCompressedStorage` (gzip/deflate/deflate-raw) | none (web global) | -| `@stainless-code/persist/backends/node-fs` | `nodeFsStateStorage` (one file per key) | none (Node built-in) | -| `@stainless-code/persist/transport/crosstab` | `createBroadcastCrossTab` | none (web global) | -| `@stainless-code/persist/sources/tanstack-store` | `persistStore`, `persistAtom` | `@tanstack/store` (types only) | -| `@stainless-code/persist/sources/zustand` | `persistStore` | `zustand` | -| `@stainless-code/persist/sources/jotai` | `persistAtom` | `jotai` | -| `@stainless-code/persist/sources/valtio` | `persistProxy` | `valtio` | -| `@stainless-code/persist/sources/mobx` | `persistObservable` | `mobx` | -| `@stainless-code/persist/frameworks/react` | `useHydrated` React hook | `react` | -| `@stainless-code/persist/frameworks/solid` | `useHydrated` (Solid `Accessor<boolean>`) | `solid-js` | -| `@stainless-code/persist/frameworks/vue` | `useHydrated` (Vue `Ref<boolean>`) | `vue` | -| `@stainless-code/persist/frameworks/svelte` | `hydratedRune` (Svelte 5 runes `current`) | `svelte` (>=5.7) | -| `@stainless-code/persist/frameworks/svelte-store` | `hydratedStore` (Svelte `Readable<boolean>`) | `svelte` (>=3) | -| `@stainless-code/persist/frameworks/angular` | `useHydrated` (Angular `Signal<boolean>`) | `@angular/core` (>=17) | -| `@stainless-code/persist/frameworks/preact` | `useHydrated` (Preact `{ hydrated: boolean }`) | `preact` (>=10.19) | +| Subpath | Symbols | Optional peer | +| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| `@stainless-code/persist` | `persistSource`, `PersistApi`, `createStorage`, `createJSONStorage`, `createMigrationChain`, `jsonCodec`, `identityCodec`, `createPersistRegistry`, `toHydrationSignal`, `alwaysHydratedSignal` | none | +| `@stainless-code/persist/codecs/seroval` | `serovalCodec`, `createSerovalStorage` | `seroval` | +| `@stainless-code/persist/codecs/zod` | `zodCodec`, `createZodStorage` | `zod` | +| `@stainless-code/persist/backends/idb` | `idbStateStorage`, `createIdbStorage` (structured-clone mode) | `idb-keyval` | +| `@stainless-code/persist/backends/async-storage` | `asyncStorageStateStorage`, `createAsyncStorage` | `@react-native-async-storage/async-storage` | +| `@stainless-code/persist/backends/mmkv` | `mmkvStateStorage`, `createMmkvStorage` | `react-native-mmkv` | +| `@stainless-code/persist/backends/secure-store` | `secureStoreStateStorage`, `createSecureStoreStorage` | `expo-secure-store` | +| `@stainless-code/persist/backends/encrypted` | `createEncryptedStorage` (AES-GCM WebCrypto) | none (web global) | +| `@stainless-code/persist/backends/compressed` | `createCompressedStorage` (gzip/deflate/deflate-raw) | none (web global) | +| `@stainless-code/persist/backends/node-fs` | `nodeFsStateStorage` (one file per key) | none (Node built-in) | +| `@stainless-code/persist/transport/crosstab` | `createBroadcastCrossTab` | none (web global) | +| `@stainless-code/persist/sources/tanstack-store` | `persistStore`, `persistAtom` | `@tanstack/store` (types only) | +| `@stainless-code/persist/sources/zustand` | `persistStore` | `zustand` | +| `@stainless-code/persist/sources/jotai` | `persistAtom` | `jotai` | +| `@stainless-code/persist/sources/valtio` | `persistProxy` | `valtio` | +| `@stainless-code/persist/sources/mobx` | `persistObservable` | `mobx` | +| `@stainless-code/persist/frameworks/react` | `useHydrated` React hook | `react` | +| `@stainless-code/persist/frameworks/solid` | `useHydrated` (Solid `Accessor<boolean>`) | `solid-js` | +| `@stainless-code/persist/frameworks/vue` | `useHydrated` (Vue `Ref<boolean>`) | `vue` | +| `@stainless-code/persist/frameworks/svelte` | `hydratedRune` (Svelte 5 runes `current`) | `svelte` (>=5.7) | +| `@stainless-code/persist/frameworks/svelte-store` | `hydratedStore` (Svelte `Readable<boolean>`) | `svelte` (>=3) | +| `@stainless-code/persist/frameworks/angular` | `useHydrated` (Angular `Signal<boolean>`) | `@angular/core` (>=17) | +| `@stainless-code/persist/frameworks/preact` | `useHydrated` (Preact `{ hydrated: boolean }`) | `preact` (>=10.19) | No barrel — importing a subpath is the dependency opt-in. @@ -438,7 +438,7 @@ import { createJSONStorage } from "@stainless-code/persist"; createSerovalStorage(() => localStorage); // durable prefs createSerovalStorage(() => sessionStorage); // per-visit state (dies with the tab) createIdbStorage(); // IndexedDB, structured-clone mode -createJSONStorage(() => AsyncStorage); // React Native — or use ./backends/async-storage for the typed adapter +createAsyncStorage(); // React Native AsyncStorage — ./backends/async-storage createAsyncStorage(); // React Native AsyncStorage — async, useHydrated gating createMmkvStorage({ id: "app-prefs" }); // React Native MMKV — sync, no gate needed createSecureStoreStorage(); // expo-secure-store — OS keychain, ~2KB/key, for secrets @@ -510,13 +510,13 @@ IDB has no storage events — pair `./transport/crosstab` for cross-tab sync. En Pick by whether you need Set/Map/Date round-trips, schema-gated persistence, or a structured-clone backend. `identityCodec` only with structured-clone backends (IDB) — never string-wire. -| Codec | Set/Map/Date | Wire type | Schema validation | For backend | Subpath | -| --------------------- | -------------------- | -------------------------- | ----------------- | --------------------------- | ------------------------ | -| `jsonCodec` | ✗ | string | ✗ | string-wire | core | -| `serovalCodec` | ✓ | string (JSON-shaped) | ✗ | string-wire | `./codecs/seroval` | -| `zodCodec` | ✓ (via schema) | string | ✓ | string-wire | `./codecs/zod` | -| `identityCodec` | ✓ (structured clone) | `StorageValue<S>` (object) | ✗ | structured-clone only (IDB) | core | -| custom `StorageCodec` | yours | yours | yours | any | custom `encode`/`decode` | +| Codec | Set/Map/Date | Wire type | Schema validation | For backend | Subpath | +| --------------------- | ----------------------------------------- | -------------------------- | ----------------- | --------------------------- | ------------------------ | +| `jsonCodec` | ✗ | string | ✗ | string-wire | core | +| `serovalCodec` | ✓ | string (JSON-shaped) | ✗ | string-wire | `./codecs/seroval` | +| `zodCodec` | ✗ (JSON wire; only via schema transforms) | string | ✓ | string-wire | `./codecs/zod` | +| `identityCodec` | ✓ (structured clone) | `StorageValue<S>` (object) | ✗ | structured-clone only (IDB) | core | +| custom `StorageCodec` | yours | yours | yours | any | custom `encode`/`decode` | ## Recipes @@ -561,6 +561,7 @@ createStorage( createStorage(() => idbStateStorage(), serovalCodec()); // Namespaced IDB store away from other idb-keyval users +import { createStore } from "idb-keyval"; createIdbStorage({ store: createStore("my-db", "persist") }); // Cross-tab sync (localStorage): pair crossTab with onCrossTabRemove when using skipPersist diff --git a/docs/architecture.md b/docs/architecture.md index 9d01b17..41544f2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,7 +14,7 @@ Persistence is bound to a structural `PersistableSource` (`getState` / `setState `createStorage(backend, codec, options)` composes a `PersistStorage`; `persistSource(source, options)` wires it to a store. **Factory policy:** codec factories take the backend as an argument; a backend earns its own factory only when it needs real adaptation (IndexedDB). Everything else composes — no factory-per-combination. -## Entry points (one subpath = one optional peer) +## Entry points (optional peer when the adapter needs one) | Entry | Module | Optional peer | | ------------------------------------------------- | ------------------------------------------- | --------------------------------------------- | @@ -42,7 +42,7 @@ Persistence is bound to a structural `PersistableSource` (`getState` / `setState | `@stainless-code/persist/frameworks/angular` | `adapters/frameworks/angular` | `@angular/core` (>=17) | | `@stainless-code/persist/frameworks/preact` | `adapters/frameworks/preact` | `preact` (>=10.19) | -No barrel — importing a subpath is the dependency opt-in. Each subpath entry owns its peer dep, which stays external in the build (`tsdown.config.ts` `neverBundle`) so consumers tree-shake cleanly. +No barrel — importing a subpath is the dependency opt-in. Each subpath entry owns its optional peer dep when the adapter needs one — the no-peer entries are the core, encrypted, compressed, node-fs, and crosstab subpaths — and the peer stays external in the build (`tsdown.config.ts` `neverBundle`) so consumers tree-shake cleanly. ## Folder layout @@ -60,7 +60,7 @@ A per-entry self-check test pins the invariant: every adapter's relative imports ## Hydration lifecycle -`persistSource` hydrates on create (skip with `skipHydration`; `rehydrate()` is awaitable), subscribe-writes on every `setState` (gated until hydrated; optional trailing `throttleMs`), and tears down via `destroy()`. The hydration signal (`HydrationSignal` from `hydration`) is observed from outside the store — framework adapters mount it into their external-store mechanism (React `useSyncExternalStore` via `./frameworks/react`, Solid `from` via `./frameworks/solid`, Vue `shallowRef` + `onScopeDispose` via `./frameworks/vue`, Svelte runes `createSubscriber` via `./frameworks/svelte` / stores `readable` via `./frameworks/svelte-store`) without coupling to the store's read path. SSR policy: render `hydrated = true` on the server; `null` signal = no persistence = hydrated. +`persistSource` hydrates on create (skip with `skipHydration`; `rehydrate()` is awaitable), subscribe-writes on every `setState` (gated until hydrated; optional trailing `throttleMs`), and tears down via `destroy()`. The hydration signal (`HydrationSignal` from `hydration`) is observed from outside the store — framework adapters mount it into their external-store mechanism (React `useSyncExternalStore` via `./frameworks/react`, Solid `from` via `./frameworks/solid`, Vue `shallowRef` + `onScopeDispose` via `./frameworks/vue`, Svelte runes `createSubscriber` via `./frameworks/svelte` / stores `readable` via `./frameworks/svelte-store`, Angular `signal` + `effect` via `./frameworks/angular`, Preact `useSyncExternalStore` via `preact/compat` via `./frameworks/preact`) without coupling to the store's read path. SSR policy: render `hydrated = true` on the server; `null` signal = no persistence = hydrated. ## Sync vs async diff --git a/docs/glossary.md b/docs/glossary.md index 3f95d3d..4e6b589 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -4,13 +4,13 @@ ## Seams -| Term | Definition | Aliases / avoid | -| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -| **backend** | The `StateStorage<TRaw>` seam — `getItem` / `setItem` / `removeItem` against a physical store (sync or Promise). | storage driver, engine | -| **codec** | The `StorageCodec<S, TRaw>` seam — pure `encode` / `decode` between the persisted envelope and the backend's wire type. | serializer, (de)serializer | -| **source** | The `PersistableSource<TState>` seam — the reactive store being persisted (`getState` / `setState` / `subscribe`), structural and store-agnostic. | store (avoid — overloaded) | -| **storage** | The composed `PersistStorage<S>` — a backend × codec cell produced by `createStorage`; the keyed store of envelopes `persistSource` reads/writes. | persisted storage, PersistStorage | -| **entry** | A subpath export in `package.json` `exports` — one entry = one optional peer opt-in (`./codecs/seroval`, `./backends/idb`, `./sources/tanstack-store`, `./frameworks/react`). | subpath, entry point | +| Term | Definition | Aliases / avoid | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------- | +| **backend** | The `StateStorage<TRaw>` seam — `getItem` / `setItem` / `removeItem` against a physical store (sync or Promise). | storage driver, engine | +| **codec** | The `StorageCodec<S, TRaw>` seam — pure `encode` / `decode` between the persisted envelope and the backend's wire type. | serializer, (de)serializer | +| **source** | The `PersistableSource<TState>` seam — the reactive store being persisted (`getState` / `setState` / `subscribe`), structural and store-agnostic. | store (avoid — overloaded) | +| **storage** | The composed `PersistStorage<S>` — a backend × codec cell produced by `createStorage`; the keyed store of envelopes `persistSource` reads/writes. | persisted storage, PersistStorage | +| **entry** | A subpath export in `package.json` `exports`; one entry per seam — optional peer when the adapter needs an external dep. (`./codecs/seroval`, `./backends/idb`, `./sources/tanstack-store`, `./frameworks/react`). | subpath, entry point | ## Envelope & wire diff --git a/docs/plans/remaining-roi.md b/docs/plans/remaining-roi.md index 05c9fb8..5f50c3f 100644 --- a/docs/plans/remaining-roi.md +++ b/docs/plans/remaining-roi.md @@ -19,8 +19,8 @@ Framework adapters mount `HydrationSignal` into each framework's external-store ## Conventions (apply to every item) - **New subpath** = add to `package.json` `exports` + `peerDependencies` + `peerDependenciesMeta` (optional) + `tsdown.config.ts` `entry` + `deps.neverBundle` + `typedoc.json` `entryPoints`. Mirror `src/` → `dist/` → subpath 1:1. -- **Adapter isolation** — imports only from `core/`; co-locate a `*.test.ts` "dependency isolation" block asserting every relative import resolves into `../../core/`. -- **Zero-dep core gate** — `src/core/persist-core.ts` has no value imports (enforced by `src/core/persist-core.test.ts`). +- **Adapter isolation** — imports only from `core/`; call the shared `itImportsOnlyFromCore(sourceUrl)` helper (`src/testing/assert-core-only-imports.ts`) from each adapter's co-located test. +- **Zero-dep core gate** — `src/core/persist-core.ts` + `src/core/hydration.ts` have no value imports (enforced by `src/core/persist-core.test.ts`). - **Changeset** — `.changeset/<slug>.md` with `@stainless-code/persist: minor` (or `major` for breaking) for any public-surface change. - **Verify after each step** — `bun run lint:changes`, `bun run format:changes`, `bun test <co-located pair>`, `bun run typecheck`; use `bun run format` / `lint:fix` (pinned `oxfmt` / `oxlint`), not `bunx`. - **Pre-commit** runs format/lint/typecheck/tests on staged files — never `--no-verify`. Run the per-file checks first so the hook passes first try (stash/restore can eat untracked files). @@ -30,7 +30,7 @@ Framework adapters mount `HydrationSignal` into each framework's external-store ### 1. TanStack Query persister bridge — Tier 2, M - **What:** a `./sources/tanstack-query` (or `./integrations/tanstack-query`) subpath exposing a `persistQueryClient`-shaped adapter over `persistSource`. Supply a cache-shaped `PersistableSource` (`getState` → `queryClient.getQueryCache().getAll()`; `setState` → `setQueryData` per entry; `subscribe` → `getQueryCache().subscribe`). -- **Why:** the JSDoc repeatedly cites `@tanstack/query-persist-client` as the reference design (`src/core/persist-core.ts:12,263`); the README migration guide already maps the option names (`maxAge`, `buster`, `retryWrite` ↔ `retry`, `throttleMs` ↔ `throttleTime`). Flagship integration — converts the cache-bound incumbent's users. +- **Why:** the JSDoc cites TanStack Query persister patterns (`AsyncStorage`, throttle); the README migration guide already maps the option names (`maxAge`, `buster`, `retryWrite` ↔ `retry`, `throttleMs` ↔ `throttleTime`). Flagship integration — converts the cache-bound incumbent's users. - **Acceptance:** subpath ships + co-located test + README recipe; `persistQueryClient`-shaped call works against a mock `QueryClient`. Verify the cache-shaped source round-trips through `persistSource` with `createJSONStorage` + `maxAge`/`buster`. - **Lands:** README "Wrapping your store" / a new "Integrations" section; changeset. @@ -66,13 +66,13 @@ Framework adapters mount `HydrationSignal` into each framework's external-store ### 6. React ergonomics layer — Tier 4, M-L - **What:** a `./frameworks/react` ergonomics companion (or a new `./frameworks/react-context` subpath) — `<PersistProvider>` + React context + `usePersisted(store, selector)` selector binding + auto-`destroy()` on unmount. The existing `useHydrated` stays the reference primitive. -- **Why:** `useHydrated` is the entire React surface today — no provider, no auto store binding, no auto-teardown. Each consumer manually threads a `HydrationSignal` + `useEffect` cleanup (`src/adapters/frameworks/react.ts:22` signals this is intentionally deferred). +- **Why:** `useHydrated` is the entire React surface today — no provider, no auto store binding, no auto-teardown. The bare `useHydrated` path stays the reference primitive; a richer ergonomics layer (provider + auto-binding + auto-teardown) is a separate concern. - **Acceptance:** subpath ships + `tests-dom` coverage for mount/unmount teardown + selector rerender + provider scoping; README "React ergonomics" section. Keep it optional — the bare `useHydrated` path must remain valid. -- **Lands:** `src/adapters/frameworks/react-context.ts` (new subpath) + README section. Changeset: `minor`. **Decision needed:** ship in-repo or as a separate package (the JSDoc deferral hints at a higher-layer package). +- **Lands:** `src/adapters/frameworks/react-context.ts` (new subpath) + README section. Changeset: `minor`. **Decision needed:** ship in-repo or as a separate package (the bare `useHydrated` path stays the reference primitive; a richer ergonomics layer (provider + auto-binding + auto-teardown) is a separate concern). ### 7. OPFS + SQLite-WASM + Cloudflare KV/Durable Objects adapters — Tier 4, M-L -- **What:** three new `./backends/` subpaths: `opfs` (Origin Private File System, async, file-backed, high-volume structured state), `sqlite-wasm` (wa-sqlite / sqlite-wasm, structured-clone mode like IDB), `cloudflare-kv` + `cloudflare-do` (edge runtime, async `StateStorage`). +- **What:** four new `./backends/` subpaths: `opfs` (Origin Private File System, async, file-backed, high-volume structured state), `sqlite-wasm` (wa-sqlite / sqlite-wasm, structured-clone mode like IDB), `cloudflare-kv` + `cloudflare-do` (edge runtime, async `StateStorage`). - **Why:** extends the backend surface to high-volume browser state, structured-query WASM storage, and edge runtimes. All fit `StateStorage<TRaw>` cleanly; no core rework. - **Acceptance:** each ships as its own subpath with optional peer + co-located test (mock the runtime, like the MMKV/AsyncStorage tests) + README backend decision-matrix row. `sqlite-wasm` may be better as a community recipe than a shipped peer (heavy) — decide per-adapter. - **Lands:** `src/adapters/backends/<name>.ts` + README "Choosing a storage" row + changeset (one per adapter). diff --git a/docs/plans/upstream-tanstack-pitch.md b/docs/plans/upstream-tanstack-pitch.md index 104c2bf..cbef44f 100644 --- a/docs/plans/upstream-tanstack-pitch.md +++ b/docs/plans/upstream-tanstack-pitch.md @@ -6,7 +6,7 @@ Hi — sharing [`@stainless-code/persist`](https://github.com/stainless-code/persist), a hydration-aware persistence middleware for any reactive store. Published under stainless-code, extracted, test-covered. It could serve as a reference or a contribution toward TanStack Persist; happy to fold any of it upstream. Quick tour. -**1. What it is.** A middleware over a _structural_ `PersistableSource` (`getState`/`setState`/`subscribe`) — not a store-specific wrapper. `persistSource(source, options)` persists anything reactive; `persistStore`/`persistAtom` are thin adapters for `@tanstack/store`. Unlike a passive `StoragePersister` (dump/load a blob), the middleware owns the lifecycle — hydrate-on-create, subscribe-writes gated until hydrated, trailing throttle, teardown — so the store stays a plain store and persistence is a sidekick. Three seams (`StateStorage` × `StorageCodec` × `PersistableSource`) make every backend × codec cell a one-line composition; the surface is exercised across codecs (seroval, zod), backends (IndexedDB, AsyncStorage, MMKV, SecureStore, encrypted, compressed, node-fs), a BroadcastChannel cross-tab transport, store sources (TanStack Store, zustand, jotai, valtio, mobx), and framework hydration adapters (React/Solid/Vue/Svelte/Angular/Preact) — so the shape is proven to scale, not sketched. ([README § Extensibility guide](https://github.com/stainless-code/persist#extensibility-guide)) +**1. What it is.** A middleware over a _structural_ `PersistableSource` (`getState`/`setState`/`subscribe`) — not a store-specific wrapper. `persistSource(source, options)` persists anything reactive; `persistStore`/`persistAtom` are thin adapters for `@tanstack/store`. Unlike a passive `StoragePersister` (dump/load a blob), the middleware owns the lifecycle — hydrate-on-create, subscribe-writes gated until hydrated, trailing throttle, teardown — so the store stays a plain store and persistence is a sidekick. Three seams (`StateStorage` × `StorageCodec` × `PersistableSource`) make every backend × codec cell a one-line composition; the surface is exercised across codecs (seroval, zod), backends (IndexedDB, AsyncStorage, MMKV, SecureStore, encrypted, compressed, node-fs), a BroadcastChannel cross-tab transport, store sources (TanStack Store, zustand, jotai, valtio, mobx), and framework hydration adapters (React/Solid/Vue/Svelte (+ Svelte-store)/Angular/Preact) — so the shape is proven to scale, not sketched. ([README § Extensibility guide](https://github.com/stainless-code/persist#extensibility-guide)) **2. Sync vs async — one API.** No split into two APIs. `getItem` returning a native `Promise` switches the read path to async; sync backends stay sync and hydration settles before first paint (no flash, no `Suspense`). Async backends ride the same `getItem` Promise branch; UI gates on a hydration signal. `instanceof Promise` (not thenable duck-typing) so a stored value with a `then` field is never mistaken for a pending read. ([architecture § Sync vs async](https://github.com/stainless-code/persist/blob/main/docs/architecture.md#sync-vs-async)) diff --git a/docs/roadmap.md b/docs/roadmap.md index d8513d9..3126e0a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -17,7 +17,9 @@ Forward-looking plans only — **not** a mirror of `src/`. **Doc index:** [READM | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | **Core** (`persist-core` + `hydration`) | Zero-dep middleware: `persistSource`, `createStorage`, codecs, registry, hydration signal. No value imports (gate-test enforced). | | **Codec / backend subpaths** | Own their optional peer (`seroval`, `idb-keyval`); compose into `createStorage`. | -| **Framework adapters** | One entry per framework (`./frameworks/<fw>`); each is ~20 lines over `HydrationSignal`. | +| **Source adapters** | One entry per store library (`./sources/<lib>`); thin `persistSource` wrappers, shape-named. | +| **Transport** | Cross-tab transport adapters (`./transport/crosstab` — BroadcastChannel bridge). | +| **Framework adapters** | One entry per framework (`./frameworks/<fw>`); each is ~30–45 lines over `HydrationSignal`. | ## Non-goals (v1) From 92e50eb688f48b4102e20807ce6b70fc6e91eca6 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Mon, 6 Jul 2026 12:57:33 +0300 Subject: [PATCH 73/77] docs: remove the 2026-07-04 ROI audit; lift still-true limitations into architecture.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit was the input that drove this branch; the branch shipped, and the durable outputs (code, README, remaining-roi plan, PR description, commit log) are the record. The audit's content was a pre-ship snapshot — TL;DR answers, doc-adequacy table, CI gaps, adapter brainstorm, and the 32-item ROI ✅-ledger — all superseded by the current codebase or duplicated by remaining-roi.md. The verbatim subagent appendices had no reuse. Lifted the still-true A.5 "Gaps / limitations" into a new "Limitations (by design)" section in docs/architecture.md (15 items; dropped the 4 now-shipped — crosstab, migration-chain, compression, encryption). Cleaned the three references (roadmap "Next", remaining-roi opening + Reference) and the stale harden-pr LEDGER entry that cited the audit's historical counts. docs/audits/.gitkeep stays so the folder remains discoverable. Verified: `bun run check` green; 217 pass / 0 fail. --- .agents/skills/harden-pr/LEDGER.md | 2 - docs/architecture.md | 20 + docs/audits/2026-07-04-docs-adapters-roi.md | 786 -------------------- docs/plans/remaining-roi.md | 5 +- docs/roadmap.md | 2 +- 5 files changed, 23 insertions(+), 792 deletions(-) delete mode 100644 docs/audits/2026-07-04-docs-adapters-roi.md diff --git a/.agents/skills/harden-pr/LEDGER.md b/.agents/skills/harden-pr/LEDGER.md index 93e8903..534ac6c 100644 --- a/.agents/skills/harden-pr/LEDGER.md +++ b/.agents/skills/harden-pr/LEDGER.md @@ -14,8 +14,6 @@ By-design or false-positive findings — do not re-raise. - **[correctness]** `src/core/persist-core.ts:147` — sync-first read path: by-design — sync backends settle pre-paint; async rides the same getItem Promise branch. --> -- **[docs]** `docs/audits/2026-07-04-docs-adapters-roi.md` — "5 subpath entries" / historical-state counts: by-design — dated audit record (2026-07-04); counts are accurate to the audit date; the doc header carries the date. - ## Deferred Capped or out-of-scope-for-now — reconcile re-vets; remove lines when fixed. diff --git a/docs/architecture.md b/docs/architecture.md index 41544f2..6e8ffaa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -70,6 +70,26 @@ One API. Sync backends (localStorage) settle hydration before first paint; async `buster` / `maxAge` / `throttleMs` / `retryWrite` ship alongside versioned `migrate`, cross-tab sync (`crossTab` + `onCrossTabRemove`), the hydration signal, and the codec seam — beyond what TanStack Query's `persistQueryClient` offers. **Deliberate `maxAge` default divergence:** prefs shouldn't silently expire, so `maxAge` is opt-in rather than default-on. +## Limitations (by design) + +What the core deliberately does **not** do — the seams exist for these, but no shipped impl: + +- **No IndexedDB integration in core** — seam only (`StateStorage<TRaw>` + `identityCodec`); IDB lives in `./backends/idb` (idb-keyval peer). No transaction batching, key-range cursors, or IDB-specific error mapping in core. +- **No multi-key batching** — one key per `persistSource`; no atomic multi-key write. `PersistRegistry.clearAll` is best-effort `allSettled`, not a transaction. +- **No key-namespacing helper** — `name` is a raw string; no built-in prefix convention. +- **Trailing-only throttle** — no leading edge; the first write waits out the window (explicit trade-off vs TanStack Query's leading+trailing). +- **`retryWrite` uncapped by design** — no max-attempts / backoff; the callback owns termination (can spin forever). +- **`setOptions` cannot re-wire structural options** — `registry`, `crossTab`, `crossTabEventTarget` are set at create time only. +- **No selective / per-field hydrate** — `merge` is the only knob; no field-level hydration hooks. +- **No built-in cross-tab payload diff** — full rehydrate on every matching event; no "only changed fields" optimization. +- **No built-in size/quota introspection** — `retryWrite` reacts to failures; the core never probes quota. +- **No SSR serialize-and-rehydrate** — the server renders `hydrated: true`; shipping server state to the client for first-paint is the framework adapter's job (`useHydrated` server snapshot). +- **Async detection is native same-realm `Promise` only** — cross-realm / custom-thenable backends aren't supported (deliberate, so a stored value with a `then` property isn't mistaken for a pending read). +- **Cross-tab `onCrossTabRemove` is the only removal primitive** — no symmetric "another tab wrote" callback distinct from `rehydrate()`. +- **No telemetry / observability beyond `onError`** — no write-success / hydrate-success / retry-attempt events. +- **`partialize` runs on every `setState`** — no memoization hook for expensive projections (consumer's responsibility). +- **No built-in devtools / time-travel** — `setOptions` + `rehydrate` are the only runtime knobs. + ## Publishing & API docs - **Public surface** — every export from an entry point is the public API and carries JSDoc that reads well in hovers and published typings. `@default` / `@example` tags survive into the shipped `.d.mts` (tsdown dts preserves JSDoc). No `@internal` tags are currently warranted — every export is part of the public surface; `stripInternal: true` is set in `tsconfig.json` as a forward-looking guard so any future `@internal`-marked member is dropped from the dts. diff --git a/docs/audits/2026-07-04-docs-adapters-roi.md b/docs/audits/2026-07-04-docs-adapters-roi.md deleted file mode 100644 index 8e2326e..0000000 --- a/docs/audits/2026-07-04-docs-adapters-roi.md +++ /dev/null @@ -1,786 +0,0 @@ -# Audit — Docs adequacy, adapter landscape, ROI action items - -**Date:** 2026-07-04 -**Method:** Four GLM 5.2 subagents read every file in the repo (core, adapters, docs, build/CI), then synthesized. -**Audited lanes:** [core capabilities](#core-capability-inventory) · [adapter landscape](#adapter-landscape) · [consumer docs](#consumer-docs-adequacy) · [build/ci/release](#build--tooling). - ---- - -## TL;DR answers - -**Are consumer docs enough to explain full capabilities?** **No.** The README is a dense wall-of-text reference doubling as landing page. The real consumer doc is `skills/tanstack-store/SKILL.md` (excellent, but lives inside the tarball, not the repo surface). The library's namesake concept — **"hydration-aware" — is never defined** anywhere in consumer prose. The headline use case that justifies the library (IndexedDB + async hydration gate) has **no end-to-end example**. Five exported capabilities are effectively hidden: `createPersistRegistry`/clear-all-on-logout, `partialize`/`merge`, `retryWrite`, `alwaysHydratedSignal`, and the generated `docs/api/` site (built, git-ignored, never linked from README). Sufficient for an experienced TanStack Store user willing to read JSDoc; **not** sufficient for 5-minute cold onboarding. - -**Enough examples?** **No.** Zero `examples/`/`demo/`/`playground/` directory exists. Only inline JSDoc `@example` blocks + README/skill snippets + tests. No runnable app demonstrates store+storage+codec+hydration wiring outside the test suite. ~12 README snippets / ~8 skill snippets, but **no example** for: IDB+React end-to-end, IDB+identity wired to a store, IDB cross-tab via BroadcastChannel, zustand/Redux via `persistSource`, `partialize`, `merge`, `registry`/clearAll, `throttleMs`, `maxAge`, `buster`, `retryWrite` (in prose), SSR/Next.js. - -**More adapters?** **Yes — high ROI.** The core is a zero-dep engine with **three clean seams already in place** (`StateStorage`, `StorageCodec`, `PersistableSource`) plus the `HydrationSignal` framework seam. Adding adapters is a one-line composition, not a feature request — so adapter ROI is unusually high. Brainstormed matrix below. - ---- - -## Core capability inventory - -52 distinct capabilities confirmed by the core audit (read `src/core/persist-core.ts` + `src/core/persist-core.test.ts` in full). Highlights: - -Two-axis `storage × codec` seam · structured-clone mode via `identityCodec` (Set/Map/Date survive without a codec) · trailing throttle with bypass for `skipPersist` removals · `destroy()` flushes pending throttled writes in `noRetry` mode · uncapped `retryWrite` that owns termination and covers post-migrate write-back · write-generation guard spanning throttle+retry+destroy+rehydrate · cross-tab `storageArea` identity guard with key-only fallback · `onCrossTabRemove` ownership primitive · `PersistRegistry` clear-all with `allSettled` + rethrow-first · `HydrationSignal` pull-model adapter contract (no initial notify, no payload, SSR renders hydrated) · `alwaysHydratedSignal()` collapses the no-persist ternary · `instanceof Promise` (same-realm) over thenable duck-typing · Node 22+ broken-`localStorage` shape guard · expiry-before-migrate ordering. - -Most of these are **only documented in JSDoc + tests**, not consumer prose. - -### Extension seams (interface shapes) - -- **Storage backend** — `StateStorage<TRaw>` (`src/core/persist-core.ts:20`): `getItem`/`setItem`/`removeItem`, sync or async (detected via `instanceof Promise`). -- **Codec** — `StorageCodec<S, TRaw>` (`src/core/persist-core.ts:74`): `encode`/`decode` of `StorageValue<S>` ↔ wire type. -- **Reactive source** — `PersistableSource<TState>` (`src/core/persist-core.ts:357`): `getState`/`setState`/`subscribe`. -- **Framework hydration** — `HydrationSignal` (`src/core/hydration.ts:28`): `subscribeHydrated`/`isHydrated`, pull model. -- **Cross-tab event target** — `CrossTabEventTarget` (`src/core/persist-core.ts:113`). -- **Registry** — `PersistRegistry` (`src/core/persist-core.ts:369`). - -### Core gaps (no shipped impl, seam only) - -No IDB transactions · no `BroadcastChannel` transport shipped · no migration-chain helper · no compression/encryption shipped (seam only) · no multi-key batching · no key-namespacing helper · trailing-only throttle (no leading edge) · `retryWrite` uncapped by design (callback owns termination) · `setOptions` can't re-wire structural options (`registry`/`crossTab`/`crossTabEventTarget`) · no telemetry/observability beyond `onError` · no selective per-field hydrate · no SSR serialize-and-rehydrate (adapter's job). - ---- - -## Adapter landscape - -5 subpath entries today: `.` (zero-dep core) · `./codecs/seroval` (seroval codec) · `./backends/idb` (idb-keyval, structured-clone mode) · `./sources/tanstack-store` (`persistStore`/`persistAtom`) · `./frameworks/react` (`useHydrated` only). - -Each optional peer is isolated behind its own subpath entry — importing the subpath IS the dep opt-in (enforced by `src/adapters/backends/idb.test.ts:175`). - -**React surface is just `useHydrated`.** No provider/context, no auto store binding, no auto-`destroy()` on unmount, no RN-specific entry. `src/adapters/frameworks/react.ts:22` JSDoc signals richer ergonomics are intentionally deferred to a higher-layer package. - -### Missing adapters — brainstormed - -**Storage:** sessionStorage (S/med) · OPFS (M/med) · Node fs (S/med) · memory (S/low, dedupes 3 test copies) · Redis (M/med) · SQLite WASM (L/med) · **expo-secure-store (S/high)** · **MMKV (S/high)** · **AsyncStorage (S/high)** · Chrome storage.area (S/med) · cookies (M/low) · Cloudflare KV/DO (M/med) · **BroadcastChannel bridge for IDB cross-tab (S/high)**. - -**Codecs:** **zod-validated (S/high)** · **encryption-at-rest via WebCrypto (M/high)** · MessagePack/cbor-x/CBOR (S/med) · compression via `CompressionStream` (M/med) · superjson/devalue (S/med) · structuredClone (S/low) · protobuf (L/low). - -**Framework:** **Solid `from(signal)` (S/high)** · **Vue `shallowRef`+watch (S/high)** · Svelte (S/med) · Preact (S/med) · Angular signals (S/med) · **TanStack Query persister bridge (M/high)** · **React provider/context/auto-binding (M/high)** · zustand/jotai/valtio/mobx/signals source adapters (S each, decide ship vs recipe). - ---- - -## Consumer docs adequacy - -### Documented vs hidden (per public export) - -| Export | Status | Where | -| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | -------------------------------- | -| `persistSource`, `createStorage`, `jsonCodec`, `identityCodec`, `persistStore`, `persistAtom`, `useHydrated`, `serovalCodec`, `createSerovalStorage`, `createIdbStorage`, `toHydrationSignal` | ✅ | README + skill | -| `PersistApi` full surface, `createJSONStorage`, `idbStateStorage`, `HydrationSignal`/`HydrationSource`, `UseHydratedResult` | 🟡 | partial / JSDoc only | -| **`createPersistRegistry` / `registry` / clear-all** | ❌ | exported, zero consumer mention | -| **`alwaysHydratedSignal`** | ❌ | JSDoc only | -| **`partialize` / `merge` / `onRehydrateStorage`** | ❌ in README | skill only | -| **generated `docs/api/` site** | ❌ | built, git-ignored, never linked | -| `retryWrite`, `onError`, `maxAge`, `buster`, `throttleMs`, `skipHydration` | 🟡 | mentioned in prose, no recipe | - -### Onboarding gaps (ranked) - -1. No "what is hydration / why does it flash" explainer — namesake concept undefined. -2. No complete IDB + React + `useHydrated` + `destroy()` walkthrough. -3. No full React component example in the README. -4. `createPersistRegistry` / clear-all invisible. -5. Generated API reference not linked. -6. `bun add` only — no npm/pnpm/yarn; engines supports Node ≥20.19. -7. TanStack intent opaque in README — five equal-weight subpaths, no "blessed path" signal. - -### Missing doc types - -Docs site (VitePress/Starlight) · Getting Started · Adapters catalog · Storage/codec decision matrices · Recipes section · Migration/porting guide from incumbents · Comparison table (zustand-persist / redux-persist / query-persist-client / pinia-persist) · Playground/StackBlitz · API reference link · Common-mistakes page · FAQ/troubleshooting. - ---- - -## Build & tooling - -**Strengths:** zero-dep core (test-enforced), correct optional peers, `sideEffects:false`, ESM-only tsdown build, deliberate bun/vitest split (bun unit + vitest/jsdom DOM), changeset release flow, packaged Agent Skills (`skills/` in `files`) intentional. - -**Gaps:** - -- **CI:** three workflows (ci/release/check-skills), single-env matrix. No preview deploy, no pkg-size diff, no bundle visualizer, no semver/export pack-validation (`attw`/`knip`/`publint`). -- **Release:** `release.yml` lacks npm provenance/signing and `id-token: write` permission. -- **Tests:** jsdom only — no real browser (Playwright/Safari), no SSR-framework (Next.js) test, no coverage gate, no integration tests combining multiple adapters. -- **Consumer DX:** no `packageManager` pin, no TS consumer range, no Node/React/TanStack version compat table, no bundle-size badge, no FAQ. -- **Examples:** no `examples/` workspace, no playground, no StackBlitz. - ---- - -## ROI-ordered action items - -Ordered by ROI = impact ÷ effort. **Effort:** S / M / L. **Status as of 2026-07-04:** ✅ = shipped on the `audit/docs-adapters-roi` branch. - -### Tier 1 — Ship first (high impact, low effort) - -| # | Action | Effort | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -| ✅ 1 | Define "hydration-aware" in the README — one paragraph + a "what flashes without it" diagram. Fixes the single biggest tone gap. | S | -| ✅ 2 | Add a complete IDB + React + `useHydrated` + `destroy()` walkthrough to the README. Closes the gap that justifies the library's existence. | S | -| 3 | Link the generated `docs/api/` site from the README + publish to GitHub Pages (`.nojekyll` already present). | S | -| ✅ 4 | Document `createPersistRegistry` + clear-all-on-logout with a recipe. | S | -| ✅ 5 | Add recipes for `partialize`, `merge`, `retryWrite`, `throttleMs`, `maxAge`, `buster` — six hidden powers, one short block each. | S | -| ✅ 6 | BroadcastChannel → `CrossTabEventTarget` bridge adapter for IDB cross-tab. Seam exists (`src/core/persist-core.ts:113`); IDB fires no `storage` events so `crossTab` is silently broken on IDB without it (`src/adapters/backends/idb.ts:62`, `skill:116`). | S | -| ✅ 7 | `expo-secure-store` / `react-native-mmkv` / `AsyncStorage` storage adapters (one subpath each). Unlocks an entire platform. | S each | -| ✅ 8 | `zod`-validated codec adapter — decode runs in existing corrupt-payload try/catch (`src/core/persist-core.ts:473`); validation errors map cleanly to `clearCorruptOnFailure`. | S | -| ✅ 9 | Solid + Vue framework hydration adapters — `HydrationSignal` JSDoc names both as targets (`src/core/hydration.ts:9-10`); each is a one-liner. | S each | - -### Tier 2 — Build out the surface (high impact, medium effort) - -| # | Action | Effort | -| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -| ✅ 10 | Encryption-at-rest codec (WebCrypto + codec). Headline "custom codec" example in JSDoc (`src/core/persist-core.ts:69`, `src/adapters/backends/idb.ts:52`, `skill:155`) with no shipped impl. | M | -| 11 | `examples/` monorepo workspace — runnable `tanstack-idb-react`, `tanstack-localstorage-react`, `nextjs-ssr`, `react-native-mmkv`. No runnable demo today. | M | -| 12 | Docs site (VitePress / Astro Starlight) — split wall-of-text README into Getting Started → Adapters → Recipes → Adapter authoring → Reference; host `docs/api/` under it. | M | -| 13 | TanStack Query persister bridge (`persistQueryClient`-shaped). JSDoc cites Query as reference design (`src/core/persist-core.ts:12,263`). Flagship integration. | M | -| ✅ 14 | Migration/porting guide — option mapping + conceptual diff vs zustand-persist / redux-persist / query-persist-client / pinia-persist. | S | -| ✅ 15 | Comparison table across the 4 incumbents. README paragraph → table. | S | -| ✅ 16 | Storage & codec decision matrices — lift + expand the skill's 4-row version to consumer docs. | S | -| ✅ 17 | `CompressionStream` codec — native API, ~S now; pairs with binary `TRaw`. | M | -| ✅ 18 | Node `fs` storage adapter — trivial `StateStorage`; unblocks server/SSR/CLI. | S | -| ✅ 19 | Pack-validation + semver gate in CI — `attw --pack` + `knip` + `publint`. Prevents shipping a broken `exports` map. | S | -| ✅ 33 | Angular-signals hydration adapter — `signal` + `effect`-based gate over `HydrationSignal`; peer `@angular/core`. | S | -| ✅ 34 | Preact hydration adapter — near-clone of `./frameworks/react` (`useSyncExternalStore`); peer `preact`. | S | - -### Tier 3 — Maturity & polish (medium impact, medium effort) - -| # | Action | Effort | -| ----- | ------------------------------------------------------------------------------------------------------- | ------ | -| ✅ 20 | npm provenance + signing in `release.yml` (`id-token: write` + `--provenance`). | S | -| 21 | Test matrix — real browser (Playwright) + Safari + SSR-framework (Next.js hydration). Today jsdom only. | M | -| ✅ 22 | Coverage gate. | S | -| ✅ 23 | Bundle-size badge + `size-limit` gate. | S | -| ✅ 24 | `packageManager` pin + TS consumer range + Node/React/TanStack compat table. | S | -| ✅ 25 | `zustand`/`jotai`/`valtio`/`mobx` source adapters — shipped + recipes. | S each | -| ✅ 27 | FAQ / troubleshooting page. | S | - -### Tier 4 — Strategic bets (high impact, high effort) - -| # | Action | Effort | -| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| 28 | React ergonomics layer — `<PersistProvider>` + context + `usePersisted(store)` selector binding + auto-`destroy()`. `src/adapters/frameworks/react.ts:22` signals this is intentionally deferred. | M-L | -| 29 | StackBlitz / CodeSandbox playground embedded in docs site. | M | -| 30 | OPFS + SQLite-WASM + Cloudflare KV/Durable Objects storage adapters. | M-L | -| ✅ 31 | Migration-chain helper — `createMigrationChain({...})`; today `migrate` is a single callback, v0→v1→v2 chaining is user-written. | M | -| ✅ 32 | Continue the TanStack upstream pitch (`docs/plans/upstream-tanstack-pitch.md`) — adapter breadth (#13, #9) + docs polish (#1, #2, #12) are the leverage. Ongoing. | ongoing | - ---- - -## Recommended sequencing - -- **Week 1:** #1, #2, #3, #4, #5, #6, #9 — docs + BroadcastChannel + Solid/Vue. All S, mostly docs, unlock existing-but-hidden powers. -- **Week 2:** #7, #8, #10 — RN platform + encryption headline gap. -- **Week 3-4:** #11, #12, #14, #15, #16 — structural fix for wall-of-text README + zero-examples problem. -- **Ongoing:** #19, #20, #21, #23 — CI/release hygiene so the growing surface doesn't break consumers. -- **Strategic:** #28 (React ergonomics) + #32 (TanStack upstream) after the surface is documented and exemplified. - ---- - -## Audit agents - -Four GLM 5.2 subagents ran in parallel, one per lane. Resume any for a deeper drill: - -- [core capabilities](a4dcef3c-700e-4e3b-a222-7edf7311f017) -- [adapter landscape](9e6af533-7aa7-4d05-8e92-53ba25d25ee9) -- [consumer docs](b6cf6e7d-ac59-4c94-8d07-abbbb107c9c6) -- [build/CI](d0f5c223-e7eb-4495-b33d-2a55f0d1a43c) - ---- - -# Appendix A — Core capability inventory (full subagent report) - -Read-only audit of `src/core/persist-core.ts`, `src/core/persist-core.test.ts`, `src/core/hydration.ts`, `src/core/hydration.test.ts`, `src/core/index.ts`. All file:line refs point to `src/core/` (core) or `src/adapters/<seam>/` (adapters). - -## A.1 Public API surface - -### `src/core/persist-core.ts` - -**Types / interfaces** - -| Export | Line | One-line | -| ----------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `StateStorage<TRaw = string>` | `20:24` | Minimal string-keyed storage backend (matches `localStorage`); generic `TRaw` lets structured-clone backends carry objects. | -| `StorageValue<S>` | `27:41` | Envelope persisted per key: `state` + optional `version` / `timestamp` / `buster`. | -| `PersistStorage<S>` | `49:64` | Encoded storage layer `persistSource` reads/writes; `getItem`/`setItem`/`removeItem` over `StorageValue<S>` + optional `raw` for cross-tab area matching. | -| `StorageCodec<S, TRaw = string>` | `74:77` | Pure encode/decode of `StorageValue<S>` ↔ backend wire type. | -| `JsonStorageOptions` | `80:83` | `JSON.parse` reviver / `JSON.stringify` replacer pass-through. | -| `CreateStorageOptions` | `85:93` | `clearCorruptOnFailure` flag — self-heal a corrupt payload by removing the key. | -| `CrossTabStorageEvent` | `101:105` | Structural `storage`-event shape (so non-DOM targets can dispatch fakes). | -| `CrossTabEventTarget` | `113:122` | Event-target seam for cross-tab (inject `BroadcastChannel`/fake). | -| `PersistOptions<TState, TPersistedState>` | `124:309` | Full options bag (see capability table below for each field). | -| `PersistApi<TState, TPersistedState>` | `314:350` | Lifecycle handle returned by `persistSource` (`setOptions`/`clearStorage`/`rehydrate`/`hasHydrated`/`onHydrate`/`onFinishHydration`/`getOptions`/`destroy`). | -| `PersistableSource<TState>` | `357:361` | Minimal reactive source shape (`getState`/`setState`/`subscribe`) — plug anything in. | -| `PersistRegistry` | `369:377` | Clear-callback registry: `register`/`clearAll`. | - -**Functions / values** - -| Export | Line | One-line | -| ----------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------- | -| `createPersistRegistry()` | `384:401` | Build a `PersistRegistry`; `clearAll` uses `allSettled` + rethrow-first-rejection. | -| `jsonCodec(options?)` | `407:412` | JSON `StorageCodec` (no `Set`/`Map`/`Date` round-trip); accepts reviver/replacer. | -| `identityCodec<S>()` | `421:424` | Pass-through codec for structured-clone backends (`TRaw = StorageValue<S>`). | -| `createStorage<S, TRaw>(getStorage, codec, options?)` | `444:511` | Backend×codec plumbing: try-guard availability, sync-vs-Promise branch, corrupt-payload handling; exposes `raw`. | -| `createJSONStorage<S>(getStorage, options?)` | `521:526` | `createStorage(getStorage, jsonCodec(options))` convenience. | -| `persistSource(source, options)` | `586:1124` | Attach persist to any `PersistableSource`; returns `PersistApi` (or no-op API when storage unavailable). | - -### `src/core/hydration.ts` - -| Export | Line | One-line | -| --------------------------- | --------- | ------------------------------------------------------------------------------------------------ | -| `HydrationSignal` | `28:31` | `subscribeHydrated` + `isHydrated` — framework-agnostic external-store hydration signal. | -| `HydrationSource` | `38:42` | Minimal source shape (`hasHydrated`/`onHydrate`/`onFinishHydration`); `PersistApi` satisfies it. | -| `toHydrationSignal(source)` | `52:95` | Bridge a `HydrationSource` → `HydrationSignal`; null-tolerant (`null`→`null`). | -| `alwaysHydratedSignal()` | `103:108` | Always-hydrated signal for the no-persist path (uniform handle, no `null` branch at call site). | - -### `index.ts` - -Re-exports `./persist-core` + `./hydration` only. No barrel into optional peers. (`1:7`) - -## A.2 Capabilities (exhaustive, 52) - -| # | Capability | Where | Evidence | -| --- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 1 | **Storage-backend abstraction** | `StateStorage<TRaw>` `20:24`, `createStorage` `444:511` | Any backend with `getItem`/`setItem`/`removeItem`; sync or async (Promise detected via `instanceof Promise`, not thenable). | -| 2 | **Codec / serialization seam (two-axis)** | `StorageCodec` `74:77`, `createStorage` `444:511` | Swap serialization (JSON / seroval / superjson / devalue / compression / encryption) independently of backend. | -| 3 | **Structured-clone backends (no string round-trip)** | `identityCodec` `421:424`, test `182:212` | `TRaw = StorageValue<S>` carries objects natively (IndexedDB-friendly); `Set`/`Map`/`Date` survive. | -| 4 | **JSON default storage** | `resolveDefaultStorage` `570:579` | Default `createJSONStorage(() => localStorage)`; zero-dep by design. | -| 5 | **SSR / broken-backend guard** | `createStorage` `450:466`, `570:579`, tests `143:166`, `487:547` | `getStorage` throw → `undefined`; shape-check catches Node 22+ `localStorage` whose methods are `undefined`; no `window`/`localStorage` → no-op `PersistApi`. | -| 6 | **Corrupt-payload self-heal** | `parseStored` `468:489`, test `130:141` | `clearCorruptOnFailure` removes the key on a decode throw; default returns `null` (hydrate-to-nothing). | -| 7 | **Hydration lifecycle (awaitable, race-guarded)** | `hydrate` `982:1066`, `rehydrate` `1090` | `rehydrate()` returns a Promise that resolves after merge + finish listeners; `hydrationVersion` guard discards stale hydrates. | -| 8 | **Hydration listeners** | `onHydrate` `335`, `onFinishHydration` `337`, `beginHydration`/`settleHydration` `927:973` | Start + finish notifications; throwing finish listener contained (test `549:582`). | -| 9 | **`skipHydration`** (manual rehydrate) | option `175`, `1068:1070` | Skip the initial hydrate; caller drives `rehydrate()`. | -| 10 | **`partialize`** (selective field persistence) | option `138`, test `242:260` | Project `TState` → persisted slice; non-persisted field changes alone never write. | -| 11 | **`merge`** (hydrate combine) | option `170`, `shallowSpreadMerge` `532:538`, `applyResolvedState` `948:952` | Default shallow-spread persisted-over-current; custom merge supported; `persistAtom` overrides with replace. | -| 12 | **Schema versioning** | option `152`, `resolveStoredState` `900:922` | `version` stamped on writes; mismatch → `migrate`. | -| 13 | **Migration (`migrate`, sync or async)** | option `161`, `907:912`, tests `262:285`, `454:485` | Receives stored state + STORED version; returns new state or Promise. Throw → phase `"migrate"`. | -| 14 | **Post-migrate write-back** | `hydrate` `1034:1040`, tests `262:285`, `1423:1448` | One-shot, unthrottled write of migrated state with current version. | -| 15 | **`buster` cache-busting** | option `257`, `isStoredExpired` `887:895`, tests `1201:1257` | Mismatch discards + removes key; prefer over migrate when old values are simply wrong. | -| 16 | **`maxAge` expiry** | option `248`, `isStoredExpired` `887:895`, tests `1142:1199` | Payload older than `maxAge` (by `timestamp`; missing timestamp = expired) discarded + key removed. | -| 17 | **Expiry-before-migrate ordering** | `hydrate` `1018:1022`, test `1296:1321` | Expired data is never migrated. | -| 18 | **`timestamp` stamping on every write** | `buildEnvelope` `657:664`, test `1276:1294` | Always stamped (cheap, backward-compatible) — basis for `maxAge`; `buster` only when configured. | -| 19 | **`onRehydrateStorage` callback** | option `144`, `beginHydration` `927:932` | Pre-hydration callback; optional return invoked on settle with `(state, undefined)` or `(undefined, error)`. | -| 20 | **`onError` error sink (4 phases)** | option `193`, `reportError` `607:619` | Phases: `"write"` / `"hydrate"` / `"migrate"` / `"crossTab"`; dev-only `console.*` fallback (prod silent without sink). | -| 21 | **Error containment (never propagates to caller's setState)** | `writeSafe` `751:760`, `hydrate` catch `1049:1065`, tests `312:371` | Sync `setItem` throw / async reject / throwing migrate / throwing listener — all contained. | -| 22 | **`skipPersist` (conditional persistence)** | option `184`, `writeToStorage` `732:743`, test `662:680` | Evaluated against the partialized slice; when true → `removeItem` instead of write. | -| 23 | **Trailing throttle (`throttleMs`)** | option `273`, `scheduleWrite` `800:827`, tests `1342:1370` | Single `setTimeout`; coalesces burst into one trailing write with flush-time state; re-arms after flush. | -| 24 | **Throttle bypass for `skipPersist` removals** | `scheduleWrite` `805:809`, test `1399:1421` | Reset-to-default drops key immediately and cancels pending write. | -| 25 | **`destroy()` flushes pending throttled write** | `destroy` `1101:1120`, `flushPendingWrite` `779:794`, test `1372:1397` | One immediate attempt at teardown (noRetry mode) so no coalesced state is lost. | -| 26 | **`retryWrite` (uncapped shrink-and-retry)** | option `302`, `retryLoop` `673:707`, `writeGuarded` `714:730` | Callback gets `{state, error, errorCount}`; return smaller state to retry, `undefined` to give up (last error reported once); applies to subscribe-writes AND post-migrate write-back. | -| 27 | **Write-generation guard** | `writeGeneration` `652`, `scheduleWrite` `814`, `retryLoop` `682,694`, tests `1551:1656`, `1736:1775` | Newer `setState` / `destroy` / coalesced setState silently abandons in-flight retry loop; stale shrunk state never clobbers fresher state. | -| 28 | **Retry envelope rebuilt fresh per attempt** | `attemptWrite` `666:667`, `buildEnvelope` `657:664`, test `1884:1924` | New `timestamp`, current `version`/`buster` on every retry. | -| 29 | **Sync-path purity** | `writeGuarded` `714:730`, test `1862:1882` | Sync `setItem` failure with no `retryWrite` → reported synchronously, no promise hop. | -| 30 | **Cross-tab sync (`crossTab`)** | option `221`, listener `846:882`, tests `801:998` | Listens for `storage` events on `window` (or injected target); matches key + `storageArea` against `raw`; calls `rehydrate()`. No echo loops (browser never fires in originating tab; `hydrationVersion` dedupes overlapping). | -| 31 | **Cross-tab event-target injection** | option `228`, `CrossTabEventTarget` `113:122` | Inject `BroadcastChannel` bridge or fake (tests, non-DOM runtimes). | -| 32 | **Cross-tab `storageArea` identity guard w/ key-only fallback** | listener `856:863`, test `848:897` | `event.storageArea` compared to `PersistStorage.raw`; hand-rolled impls without `raw` fall back to key-only. | -| 33 | **`onCrossTabRemove` (removal-event ownership)** | option `240`, listener `869:876`, tests `1000:1132` | `newValue: null` events go to callback (rehydrate can't express "reset"); throws contained → phase `"crossTab"`. Without it → rehydrate fallback (documented keep-state divergence). | -| 34 | **Cross-tab listener attaches regardless of `skipHydration`** | `846:882`, test `899:928` | Manual rehydrate path still cross-tab-syncs. | -| 35 | **`destroy()` removes cross-tab listener** | `1104:1106`, test `930:959` | Clean teardown, no post-destroy rehydrate. | -| 36 | **`PersistRegistry` (logout-style clearAll)** | option `206`, `createPersistRegistry` `384:401`, tests `1999:2074` | Stores register `clearStorage`; `clearAll()` wipes every registered key; `allSettled` + rethrow-first-rejection; no ambient registry (opt-in only). | -| 37 | **Registry unregister on destroy (no leak)** | `1103`, tests `694:767` | Non-singleton stores created per mount cleanly unregister. | -| 38 | **`setOptions` (live option merge)** | `1078:1086` | Merge new options (explicit `undefined` ignored via `mergeDefined`); `storage` swappable; structural options (`registry`/`crossTab`/`crossTabEventTarget`) NOT re-wired. | -| 39 | **`mergeDefined` undefined-guard** | `545:553`, tests `1938:1985` | `{ merge: undefined }` can't clobber a default at create time or via `setOptions`. | -| 40 | **`clearStorage()`** | `1087:1089` | Removes the key; state stays in memory. | -| 41 | **`destroy()` full teardown** | `1101:1120` | Detaches source subscription, removes cross-tab listener, unregisters from registry, flushes pending throttled write (noRetry), cancels in-flight hydrate + retryWrite loop. | -| 42 | **`destroy()` cancels in-flight hydrate** | `1116`, test `584:608` | `hydrationVersion++` discards pending async `getItem`/`migrate` — no post-teardown setState. | -| 43 | **No-op `PersistApi` when storage unavailable** | `createNoopApi` `555:568`, tests `487:547` | Always-hydrated stub; reports `storage unavailable` to `onError` once. | -| 44 | **Re-schedule cancelled write post-rehydrate** | `hydrate` `992:993`, `1048`, test `1486:1509` | A pending throttled write cancelled by an incoming hydrate is re-scheduled so unpersisted state isn't stranded. | -| 45 | **Hydration signal for framework adapters** | `toHydrationSignal` `52:95` | Bridges `onHydrate`/`onFinishHydration` into one external-store subscribe target (React `useSyncExternalStore`, Svelte/Solid/Vue). | -| 46 | **Lazy signal attach/detach** | `66:91`, tests `281:310` | Source subscribed on first listener, torn down on last unsubscribe — no leak from recreated wrappers. | -| 47 | **Independent per-subscription wrappers** | `75:84`, test `214:234` | Same listener fn subscribed twice → two independent subs; either unsubscribe doesn't kill the other. | -| 48 | **Idempotent unsubscribe** | `84:90`, test `236:255` | Second call to a stale unsub doesn't re-trigger teardown. | -| 49 | **Pull-model signal (no initial notification, no payload)** | JSDoc `18:21`, tests `198:279` | Listeners never invoked on subscribe; (re)read `isHydrated()` after attaching; missed transitions recovered by snapshot re-read. | -| 50 | **`alwaysHydratedSignal()` (no-persist uniform handle)** | `103:108` | Drops the `persist ? toHydrationSignal(persist) : null` ternary. | -| 51 | **Zero-dep core (enforced by test)** | test `19:30` | `src/core/persist-core.ts` has no value imports; type-only imports only. | -| 52 | **Async-vs-sync detection via `instanceof Promise` (not thenable)** | JSDoc `13:18`, `createStorage` `494` | A stored value with a `.then` property is never mistaken for a pending read; same-realm native promises only. | - -## A.3 Extension points / seams (interface shapes) - -**Storage backend** — `StateStorage<TRaw>` (`20:24`): - -```ts -export interface StateStorage<TRaw = string> { - getItem: (name: string) => TRaw | null | Promise<TRaw | null>; - setItem: (name: string, value: TRaw) => void | Promise<void>; - removeItem: (name: string) => void | Promise<void>; -} -``` - -**Codec** — `StorageCodec<S, TRaw>` (`74:77`): - -```ts -export interface StorageCodec<S, TRaw = string> { - encode: (value: StorageValue<S>) => TRaw; - decode: (raw: TRaw) => StorageValue<S>; -} -``` - -**Encoded layer** (build with `createStorage` or hand-roll) — `PersistStorage<S>` (`49:64`): - -```ts -export interface PersistStorage<S> { - getItem: ( - name: string, - ) => StorageValue<S> | null | Promise<StorageValue<S> | null>; - setItem: (name: string, value: StorageValue<S>) => void | Promise<void>; - removeItem: (name: string) => void | Promise<void>; - raw?: unknown; // cross-tab storageArea identity compare -} -``` - -**Reactive source** — `PersistableSource<TState>` (`357:361`): - -```ts -export interface PersistableSource<TState> { - getState: () => TState; - setState: (updater: (prev: TState) => TState) => void; - subscribe: (listener: () => void) => { unsubscribe: () => void }; -} -``` - -**Cross-tab event target** — `CrossTabEventTarget` (`113:122`): - -```ts -export interface CrossTabEventTarget { - addEventListener: ( - type: "storage", - listener: (event: CrossTabStorageEvent) => void, - ) => void; - removeEventListener: ( - type: "storage", - listener: (event: CrossTabStorageEvent) => void, - ) => void; -} -``` - -**Registry** — `PersistRegistry` (`369:377`): - -```ts -export interface PersistRegistry { - register: (clearStorage: () => void | Promise<void>) => () => void; - clearAll: () => Promise<void>; -} -``` - -**Hydration source for signal** — `HydrationSource` (`38:42`): - -```ts -export interface HydrationSource { - hasHydrated: () => boolean; - onHydrate: (listener: () => void) => () => void; - onFinishHydration: (listener: () => void) => () => void; -} -``` - -**Error sink signature** (`193:199`): `(error: unknown, ctx: { name; phase: "write"|"hydrate"|"migrate"|"crossTab" }) => void`. - -**`retryWrite` signature** (`302:308`): `(ctx: { state: TPersistedState; error: unknown; errorCount: number }) => TPersistedState | undefined | Promise<TPersistedState | undefined>`. - -**User-implemented option callbacks**: `partialize`, `merge`, `migrate`, `onRehydrateStorage`, `skipPersist`, `onCrossTabRemove`, `onError`, `retryWrite`. - -## A.4 Hidden / under-documented powers - -- **`PersistStorage.raw` identity compare** (`63`, `505:509`) — re-exposes the backend so cross-tab can match `event.storageArea`; hand-rolled impls silently fall back to key-only. Not obvious unless you read the cross-tab guard. -- **`identityCodec` for structured-clone backends** (`421:424`) — skips serialization entirely; only mentioned in a seroval/IndexedDB context. Powerful for any structured-clone store (in-memory `Map`s, IndexedDB). -- **`retryWrite` is uncapped and IS the termination policy** (`275:288`) — a callback that always returns a state spins forever; `errorCount` is the aggressiveness dial. Not surfaced as a "policy" in consumer docs. -- **`retryWrite` covers the post-migrate write-back** (`1034:1040`, test `1814:1860`) — not just subscribe-writes. -- **Write-generation guard spans throttle + retry + destroy + rehydrate** (`652`, `814`, `999`) — a coalesced `setState` _during the throttle window_ supersedes an in-flight retry loop at schedule time, not flush time. Subtle correctness invariant. -- **Re-scheduling of a cancelled throttled write after rehydrate** (`1048`, test `1486:1509`) — prevents stranded unpersisted state. Easy to miss. -- **`destroy()` teardown flush uses `noRetry` mode** (`779:794`, `1112`) — bypasses retry loop so a teardown-flush failure reaches `onError` instead of being silently abandoned by the generation bump. -- **`mergeDefined` protects against `merge: undefined`** (`545:553`) — both at create and `setOptions`; matters for `persistAtom`'s replace-merge default. -- **Sync-path purity** (`714:730`) — no promise allocation when the first attempt succeeds or fails without `retryWrite`. Performance detail unlikely in docs. -- **`instanceof Promise` (same-realm) over thenable duck-typing** (`13:18`, `494`) — deliberately avoids misclassifying stored values carrying a `then` property. -- **Node 22+ broken-`localStorage` shape check** (`456:466`) — handles the global-exists-but-methods-undefined case the `typeof` guard misses. -- **`HydrationSignal` pull model + lazy attach/detach + fresh-wrapper-per-sub** (`66:91`) — the full adapter contract (independent subs for same fn, idempotent unsub, snapshot re-read recovers missed transitions). Adapter authors need this but it's deep in JSDoc. -- **`alwaysHydratedSignal()` collapses the conditional ternary** — uniform handle drops `persist ? toHydrationSignal(persist) : null`. -- **Cross-tab `storageArea` guard prevents echo across different storage areas** (`856:863`) — same key in `sessionStorage` vs `localStorage` won't cross-fire. -- **`crossTab` listener attaches even with `skipHydration`** (`846:882`) — manual-rehydrate users still get cross-tab. -- **Expiry runs before migrate** (`1018:1022`) — expired data is never migrated; not obvious from option names. -- **`maxAge`/`buster` missing-on-payload = expired/mismatch** (`887:895`, tests `1182:1199`, `1240:1257`) — payloads written by other persist impls (no `timestamp`/`buster`) count as expired when those options are configured. -- **Dev-only `console.error`/`console.warn` fallback is `process.env.NODE_ENV`-gated** (`613`, `628`) — bundler-replaceable; prod tree-shakes it. Prod without `onError` is silent by design. - -## A.5 Gaps / limitations - -- **No IndexedDB integration in core** — only the seam (`StateStorage<TRaw>` + `identityCodec`). Actual IDB lives in the `./backends/idb` subpath (idb-keyval peer). No transaction batching, no key-range cursors, no IDB-specific error mapping in core. -- **No `BroadcastChannel` transport shipped** — only the `CrossTabEventTarget` seam. Default is `window` `storage` events (same-origin only, no large-payload guarantee). A `BroadcastChannel` bridge is user-implemented. -- **No schema-migration helper / migration chain** — `migrate` is a single callback receiving the stored version; multi-step v0→v1→v2 chaining is user-written. -- **No compression** — pluggable via `StorageCodec` (encode/decode), but nothing shipped. -- **No encryption** — same: codec seam only, nothing shipped. -- **No batching of multiple keys** — one key per `persistSource`; no atomic multi-key write, no `clearAll`-across-stores transaction (registry `clearAll` is best-effort `allSettled`). -- **No key namespacing helper** — `name` is a raw string; no built-in prefix convention. -- **Trailing-only throttle (no leading edge)** — first write waits out the window; explicit trade-off vs TanStack Query's leading+trailing (JSDoc `262:266`). -- **`retryWrite` is uncapped by design** — no built-in max-attempts / backoff; the callback owns termination (can spin forever). -- **`setOptions` cannot re-wire structural options** — `registry`, `crossTab`, `crossTabEventTarget` set at create time only (JSDoc `316:320`). -- **No selective rehydrate / per-field hydrate** — `merge` is the only knob; no field-level hydration hooks. -- **No built-in cross-tab payload diff** — full rehydrate on every matching `storage` event; no "only changed fields" optimization. -- **No built-in size/quota introspection** — `retryWrite` reacts to failures but the core never probes quota. -- **No SSR serialize-and-rehydrate** — server-side renders `hydrated = true` (no storage); no mechanism to ship server state to client for first-paint hydration (adapter's job via `useHydrated` server snapshot). -- **Async detection limited to native same-realm `Promise`** — cross-realm or custom thenable backends aren't supported (`13:18`). -- **Cross-tab `onCrossTabRemove` is the only removal primitive** — no symmetric "another tab wrote" callback distinct from `rehydrate()`; consumers must derive intent from a fresh read. -- **No telemetry / observability hooks beyond `onError`** — no write-success, hydrate-success, or retry-attempt events (only start/finish hydration listeners + `onError`). -- **`partialize` runs on every `setState`** — no memoization hook for expensive projections (consumer's responsibility). -- **No built-in devtools / time-travel** — `setOptions` + `rehydrate` are the only runtime knobs. - -### Notes for docs-adequacy / ROI analysis - -- The JSDoc on `PersistOptions` is exceptionally thorough (every invariant, every trade-off) — but several runtime invariants (write-generation guard, re-schedule-after-rehydrate, `noRetry` teardown flush, `instanceof Promise` choice, Node-22 shape check) live only in code comments + tests, not in any consumer-facing README. -- The `HydrationSignal` adapter contract is fully specified in `src/core/hydration.ts` JSDoc but is exactly the kind of thing adapter authors need promoted to top-level docs. -- Gaps are mostly "ship more codecs/transports" (compression, encryption, `BroadcastChannel`, IDB transactions) — all have clean seams already in place, so ROI on adding them is high (no core rework needed). - ---- - -# Appendix B — Adapter landscape (full subagent report) - -## B.1 Current adapters / entry points - -The `exports` map in `package.json` ships 5 subpath entries. Each optional peer is isolated behind its own subpath entry — importing the subpath IS the dep opt-in (enforced by a per-entry dependency-isolation test, e.g. `src/adapters/backends/idb.test.ts`). - -### `.` (the core entry) - -- **Provides:** re-exports `persist-core` + `hydration` (`src/core/index.ts:5-6`). Zero-dep. -- **Implements:** the canonical adapter contracts + `persistSource` engine + `createJSONStorage` / `createStorage` / `jsonCodec` / `identityCodec` / `createPersistRegistry` + `toHydrationSignal` / `alwaysHydratedSignal`. -- **Deps:** none. `peerDependenciesMeta` in `package.json` marks everything optional. -- **Consumer meaning:** the framework-agnostic persistence middleware. Bring your own reactive source via `persistSource`, or wire a framework adapter on top. This is also the entry non-TanStack users (zustand/Redux/hand-rolled atom) use — `persistSource` is the universal seam. - -### `./codecs/seroval` - -- **Provides:** `serovalCodec` + `createSerovalStorage` (`src/adapters/codecs/seroval.ts:21,37`). -- **Implements:** `StorageCodec<S>` from persist-core (`src/adapters/codecs/seroval.ts:9`). -- **Deps:** `seroval` (optional peer, `>=1.0.0`). -- **Consumer meaning:** a drop-in codec so `Set` / `Map` / `Date` round-trip through any _string-keyed_ backend (`localStorage`, `sessionStorage`, custom). One factory: `createSerovalStorage(() => localStorage)` (`src/adapters/codecs/seroval.ts:31-35`). The codec is also exposed standalone so it composes over `createStorage` for non-default backends (`src/adapters/codecs/seroval.test.ts:159-180`). - -### `./backends/idb` - -- **Provides:** `idbStateStorage` + `createIdbStorage` (`src/adapters/backends/idb.ts:34,74`). -- **Implements:** `StateStorage<TRaw>` (`src/adapters/backends/idb.ts:11`) with `TRaw = StorageValue<S>` — the **structured-clone mode** that the generic wire-type seam enables. -- **Deps:** `idb-keyval` (optional peer, `>=4.0.0`). -- **Consumer meaning:** IndexedDB-backed persistence that stores the `StorageValue` envelope _natively_ — `Set`/`Map`/`Date` round-trip via structured clone with **no codec at all** (`identityCodec`), better DevTools inspection (objects, not encoded strings). Codec use cases (encryption/compression) compose as a one-liner over `idbStateStorage` + `createStorage` (`src/adapters/backends/idb.ts:52-58`). Custom idb-keyval `store` for namespacing (`src/adapters/backends/idb.ts:16-23`). - -### `./sources/tanstack-store` - -- **Provides:** `persistStore` + `persistAtom` (`src/adapters/sources/tanstack-store.ts:24,56`). -- **Implements:** thin wrappers that supply the `PersistableSource` shape (`src/core/persist-core.ts:357-361`) to `persistSource`. -- **Deps:** `@tanstack/store` (optional peer, `>=0.10.0`); types only (`src/adapters/sources/tanstack-store.ts:4`). -- **Consumer meaning:** the only shipped reactive-source adapters. `persistStore` wraps a `Store` (action-bearing via `StoreActionMap`); `persistAtom` wraps a writable `Atom` and overrides default `merge` to **replace** (not shallow-spread) so primitive atom values aren't corrupted (`src/adapters/sources/tanstack-store.ts:74-80`), and throws on readonly atoms (`src/adapters/sources/tanstack-store.ts:60-62`). - -### `./frameworks/react` - -- **Provides:** `useHydrated` (`src/adapters/frameworks/react.ts:37`). -- **Implements:** mounts a `HydrationSignal` (`src/core/hydration.ts:28-31`) into React via `useSyncExternalStore`. -- **Deps:** `react` (optional peer, `^18.0.0 || ^19.0.0`). -- **Consumer meaning:** the _only_ React surface. Returns `{ hydrated }` to gate the hydrate flash on async backends (IndexedDB). State reads stay on the store (`useSelector`), not this hook (`src/adapters/frameworks/react.ts:7-10`). Null signal → `hydrated: true`. Server snapshot is always `true` (`src/adapters/frameworks/react.ts:42`). - -## B.2 Adapter pattern - -Three orthogonal seams, all in `src/core/persist-core.ts` / `src/core/hydration.ts`. An "adapter" picks exactly one seam to extend; it never reimplements the `persistSource` plumbing. - -### Seam A — storage backend: `StateStorage<TRaw>` - -```src/core/persist-core.ts:20:24 -export interface StateStorage<TRaw = string> { - getItem: (name: string) => TRaw | null | Promise<TRaw | null>; - setItem: (name: string, value: TRaw) => void | Promise<void>; - removeItem: (name: string) => void | Promise<void>; -} -``` - -Sync or async (detected via `instanceof Promise`, _not_ thenable duck-typing — `src/core/persist-core.ts:14-19`). A new backend adapter either hand-rolls these three methods, or wraps an existing backend (like `idbStateStorage` wraps idb-keyval, `src/adapters/backends/idb.ts:37-42`) and passes it to `createStorage`. - -### Seam B — codec: `StorageCodec<S, TRaw>` - -```src/core/persist-core.ts:74:77 -export interface StorageCodec<S, TRaw = string> { - encode: (value: StorageValue<S>) => TRaw; - decode: (raw: TRaw) => StorageValue<S>; -} -``` - -A new codec plugs into `createStorage(getStorage, codec, options)` (`src/core/persist-core.ts:444-448`) — that's the entire integration surface. `serovalCodec` (`src/adapters/codecs/seroval.ts:21-24`) is the reference. - -### Seam C — reactive source: `PersistableSource<TState>` - -```src/core/persist-core.ts:357:361 -export interface PersistableSource<TState> { - getState: () => TState; - setState: (updater: (prev: TState) => TState) => void; - subscribe: (listener: () => void) => { unsubscribe: () => void }; -} -``` - -A new framework/store adapter constructs this shape and calls `persistSource(source, opts)`. `persistStore`/`persistAtom` (`src/adapters/sources/tanstack-store.ts:28-38`, `64-72`) are the reference. - -### Seam D — framework hydration: `HydrationSignal` - -```src/core/hydration.ts:28:31 -export interface HydrationSignal { - subscribeHydrated: (listener: () => void) => () => void; - isHydrated: () => boolean; -} -``` - -Adapter contract (`src/core/hydration.ts:14-27`): multiple concurrent subscribers, idempotent unsubscribe, **no** initial notification, **no** payload (pull model), SSR renders `hydrated: true`, `null` signal → hydrated. `useHydrated` is the reference implementation (`src/adapters/frameworks/react.ts:37-44`). - -**Adapter contract checklist for a new adapter:** - -1. Own your dep — ship a new subpath entry, mark it optional in `peerDependenciesMeta`, never import it from core/other entries (the isolation test pattern at `src/adapters/backends/idb.test.ts:175-199`). -2. Map onto exactly one seam (A/B/C/D); compose via the exposed factory (`createStorage` / `persistSource` / `toHydrationSignal`), never reimplement the engine. -3. For framework adapters, implement the SSR policy (`getServerSnapshot` returns `true`) in the adapter, not the signal (`src/core/hydration.ts:22-25`). - -## B.3 Missing adapters - -### Storage backends - -| Adapter | Effort | Demand | Justification | -| ---------------------------------------------------------------- | ------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **sessionStorage** wrapper (`createSessionStorage` / doc recipe) | S | med | Already works today via `createJSONStorage(() => sessionStorage)` — but no named factory or SKILL entry. Cross-tab caveat is documented (`skills/tanstack-store/SKILL.md:116`). Low payoff; mostly a DX/ discoverability gap. | -| **OPFS** (Origin Private File System) | M | med | High-volume structured state in browsers; async, file-backed. Fits `StateStorage<TRaw>` naturally. Growing demand as localStorage hits quota. | -| **Node fs / file** | S | med | Server/SSR/CLI persistence. Trivial `StateStorage` over `fs.readFileSync`/`writeFileSync` (or async). Unblocks Node usage entirely — currently the lib is browser-flavored. | -| **memory** (test/fixture storage) | S | low | Already hand-rolled in every test file (`src/adapters/codecs/seroval.test.ts:7-25`, `src/adapters/sources/tanstack-store.test.ts:10-28`, `src/adapters/frameworks/react.test.ts:25-39`). Shipping one would dedupe ~3 copies. | -| **Redis** | M | med | Server-side persistent state; async. Pairs with Node fs entry for a real backend story. | -| **SQLite WASM** (wa-sqlite / sqlite-wasm) | L | med | Structured-clone mode like IDB; powerful but heavy. Better as a community recipe than a shipped peer. | -| **expo-secure-store** | S | high | React Native secure persistence is a top requested feature for any persist lib; small surface. | -| **MMKV** (react-native-mmkv) | S | high | RN default fast KV; synchronous, drops straight into `StateStorage`. Very high RN demand signal. | -| **AsyncStorage** (RN) | S | high | Legacy RN fallback; still widely used. | -| **Chrome `storage.area`** (`local`/`sync`/`session`) | S | med | Extension developers — `localStorage` is forbidden in MV3 service workers. Strong niche demand. | -| **cookies** | M | low | Server-rendered hydration story; awkward (size limits, HTTP coupling). Better as recipe. | -| **Cloudflare KV / Durable Objects** | M | med | Edge runtime persistence; async `StateStorage`. Growing with Workers adoption. | -| **BroadcastChannel bridge** for IDB cross-tab | S | high | Explicitly called out as missing (`src/adapters/backends/idb.ts:62-64`, `skills/tanstack-store/SKILL.md:116`) — IDB fires no `storage` events, so `crossTab` is broken on IDB without a `crossTabEventTarget` bridge. The seam exists (`CrossTabEventTarget`, `src/core/persist-core.ts:113-122`); only the adapter doesn't. | - -### Codecs - -| Codec | Effort | Demand | Justification | -| ------------------------------------------------------------ | ------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **JSON** | — | — | Already default (`jsonCodec`, `src/core/persist-core.ts:407-412`). | -| **seroval** | — | — | Shipped. | -| **structuredClone** codec | S | low | Largely subsumed by IDB identity mode; marginal value as a codec. | -| **MessagePack / cbor-x / CBOR** | S | med | Compact binary wire format; `cbor-x` is very fast. Drops into `StorageCodec<S, TRaw=Uint8Array>` — needs `TRaw` plumbing already in the seam. | -| **zod-validated encode/decode** | S | high | Schema-gated persistence is a frequently-requested feature; `decode` runs in the existing try/catch corrupt-payload path (`src/core/persist-core.ts:473-488`), so validation errors map cleanly to `clearCorruptOnFailure`. | -| **protobuf** | L | low | Strongly-typed but heavy toolchain; better as recipe. | -| **encryption-at-rest** (WebCrypto + codec) | M | high | Explicitly framed as the canonical "custom codec" use case (`src/core/persist-core.ts:69-70`, `src/adapters/backends/idb.ts:52-58`, `skills/tanstack-store/SKILL.md:155`). No shipped adapter despite being the headline composition example. | -| **compression** (gzip/brotli via WASM / `CompressionStream`) | M | med | Native `CompressionStream` makes this a ~S now; pairs with binary `TRaw`. | -| **superjson / devalue** | S | med | Already name-dropped as drop-ins (`src/core/persist-core.ts:69`, `src/core/persist-core.ts:437-440`); seroval covers the same niche so demand is partial. | -| **immutable-hamt** | L | low | Niche; structural sharing for huge state. Better as external lib. | - -### Framework integrations - -| Adapter | Effort | Demand | Justification | -| ------------------------------------------------------------- | ------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **React `useHydrated`** | — | — | Shipped. | -| **Solid** `from(signal)` adapter | S | high | The `HydrationSignal` contract (`src/core/hydration.ts:8-10`) explicitly names Solid `from` as a target. Trivial one-liner; high Solid demand. | -| **Vue** (`shallowRef` + watch) | S | high | Also explicitly named in the signal JSDoc (`src/core/hydration.ts:10`). | -| **Svelte** (`createSubscriber` / readable store) | S | med | Also named (`src/core/hydration.ts:9`). | -| **Preact** | S | med | `useSyncExternalStore` compatible — near-clone of React adapter. | -| **Angular signals** | S | med | `signal` + `effect`-based hydration gate; growing signals userbase. | -| **TanStack Query** persister bridge | M | high | Natural cross-sell (the JSDoc repeatedly cites TanStack Query as the reference design, e.g. `src/core/persist-core.ts:12`, `src/core/persist-core.ts:263-264`, `src/core/persist-core.ts:88`). A `persistQueryClient`-shaped adapter would be a flagship integration. | -| **React provider/context/auto-binding** | M | high | See §B.5 — React users get only a hook, no ergonomics layer. | -| **Zustand / Jotai / Valtio / MobX / signals** source adapters | S each | med | All reduce to `PersistableSource` (the skill explicitly says "pass a custom implementation to persist anything else", `skills/tanstack-store/SKILL.md:135-139`). Each is ~10 lines; the question is whether to ship them or keep them as recipes. | - -**Highest-leverage gaps (rough ranking):** MMKV/AsyncStorage/expo-secure-store (RN block), BroadcastChannel IDB cross-tab bridge (completes a documented-but-missing feature), encryption-at-rest codec (the headline example with no implementation), zod-validated codec, Solid/Vue framework adapters, TanStack Query bridge. - -## B.4 Examples inventory - -**None.** No `examples/`, `example/`, `demo/`, `playground/`, `snippets/`, or `sandboxes/` directory exists (Glob returned 0). The only runnable artefacts beyond `src/*.test.ts` and `tests-dom/*.test.tsx` are: - -- `skills/tanstack-store/SKILL.md` — the single shipped skill (the `skills` dir contains only `tanstack-store/`, confirmed by `ls`). -- Inline `@example` JSDoc blocks in each adapter module (`src/adapters/backends/idb.ts:67-72`, `src/adapters/codecs/seroval.ts:29-35`, `src/adapters/sources/tanstack-store.ts:13-22`, `src/adapters/frameworks/react.ts:25-35`). -- `README.md` and `docs/architecture.md` prose snippets. - -The `package.json` `files` array ships `dist` + `skills` only — no examples are published either. There is no end-to-end runnable app demonstrating wiring (store + storage + codec + hydration gate) outside the test suite. - -## B.5 `./frameworks/react` entry nuance - -`useHydrated` is the **entire** React surface. Confirmed: `exports` maps `./frameworks/react` → only `./dist/frameworks/react.{d.,}mts` in `package.json`, and `src/adapters/frameworks/react.ts` exports one function (`src/adapters/frameworks/react.ts:37`). - -**What React users do NOT get:** - -- **No provider / context.** No `<PersistProvider>`, no React context, no Devtools. Each component manually threads a `HydrationSignal` to `useHydrated` (`src/adapters/frameworks/react.ts:32-34`). -- **No automatic store binding.** The hook returns _only_ `hydrated` (`src/adapters/frameworks/react.ts:5-11`); state reads are explicitly the caller's job via `useSelector` from `@tanstack/store` (the JSDoc is emphatic: "Returns ONLY `hydrated` — state reads go through `useSelector`", `src/adapters/frameworks/react.ts:18-19`). There is no `usePersisted(store)` or selector-binding helper. -- **No `persistStore`-aware React hook.** The TanStack adapter (`./sources/tanstack-store`) and the React adapter (`./frameworks/react`) are decoupled — a React user imports `persistStore` from one subpath, `toHydrationSignal` from `.` core, and `useHydrated` from another, wiring them by hand (the canonical 3-line recipe at `skills/tanstack-store/SKILL.md:75-82`). -- **No automatic `destroy()` on unmount.** Teardown is manual — the skill documents the `useEffect` cleanup pattern as user responsibility (`skills/tanstack-store/SKILL.md:96-101`). -- **No hydration-aware `<Suspense>`/`use()` integration, no `useSyncExternalStore` selector helper, no SSR helper beyond the implicit server-snapshot policy.** -- **No React Native-specific entry** (no MMKV/AsyncStorage/expo-secure-store wiring — see §B.3). - -The design is deliberately minimal — `src/adapters/frameworks/react.ts:22-24` states the hook is "the reference" implementation of the framework-agnostic `HydrationSignal` adapter contract, signaling that richer React ergonomics (provider, auto-binding, Devtools) are intentionally left to consumers or a future higher-layer package. - ---- - -# Appendix C — Consumer docs adequacy (full subagent report) - -## C.1 Doc inventory - -| Doc | Audience | Quality (1–2 sentences) | -| --------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `README.md` (root) | **Consumers** — primary landing page | Strong. Install + quick start + a dense "Extensibility guide" with entry table, three-seam breakdown, recipes, framework-adapter sketch, lifecycle paragraph. Dense to the point of being a wall of text; no table of contents, no progressive disclosure. | -| `docs/README.md` | Maintainers | Thin index. Explicitly says consumer doc is the root README; this folder is "maintainer-facing reference." | -| `docs/architecture.md` | Maintainers | Good maintainer reference for seams, hydration lifecycle, sync/async, test matrix, publishing/API-docs policy. Not consumer-prose — assumes familiarity. | -| `docs/glossary.md` | Maintainers | Excellent ubiquitous-language table. Useful to consumers too, but not linked from README. | -| `docs/roadmap.md` | Maintainers | Forward-looking only; explicitly "not a mirror of src/". Fine. | -| `docs/plans/upstream-tanstack-pitch.md` | Maintainers / TanStack maintainers | A pitch draft, not consumer doc. Good context but irrelevant to a new user. | -| `.github/CONTRIBUTING.md` | Contributors | Dev workflow, hooks, releases, agent rules. Solid. | -| `skills/tanstack-store/SKILL.md` | **Consumers via TanStack Intent** (packaged in tarball) | The single best consumer doc in the repo — wiring, `persistAtom` vs `persistStore`, hydration gate, throttle, teardown, cross-tab, migrate, mistakes, backend×codec matrix, full options/API surface. | -| `typedoc.json` | Tooling config | TypeDoc over 5 entry points → `docs/api/` (git-ignored). `treatWarningsAsErrors`, `invalidLink` gated. | -| `.changeset/README.md` | Contributors | Boilerplate + pointer to CONTRIBUTING releases. Fine. | -| `CHANGELOG.md` | Consumers (release notes) | Two entries (0.1.0, 0.1.1). Adequate. | -| `docs/api/` | Consumers (generated reference) | Generated TypeDoc HTML site (`index.html`, `modules/`, `interfaces/`, `types/`, `hierarchy.html`). **Git-ignored** — so not in the repo, and **not linked from the README**. | - -## C.2 Capabilities: documented vs hidden - -### `@stainless-code/persist` (core) - -| Export | Status | Where | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `persistSource` | ✅ | README:117, skill:131–139 | -| `PersistApi` (interface: `rehydrate`, `hasHydrated`, `onHydrate`, `onFinishHydration`, `setOptions`, `clearStorage`, `getOptions`, `destroy`) | 🟡 | Lifecycle paragraph README:183 mentions `rehydrate`/`destroy`/`onError`; full surface only enumerated in skill:164. README never lists `setOptions`/`getOptions`/`clearStorage`/`onHydrate`/`onFinishHydration`. | -| `createStorage` | ✅ | README:120, 125, 131 | -| `createJSONStorage` | 🟡 | Only appears in README:75 inside a backend example; never explained as a public factory. | -| `jsonCodec` | ✅ | README:95 | -| `identityCodec` | ✅ | README:97, skill:144 | -| `registry` / `createPersistRegistry` / `PersistRegistry` | ❌ | `createPersistRegistry` is exported in `src/core/persist-core.ts:384` but **not mentioned anywhere in consumer docs**. The skill:163 lists `registry` as an option and skill:166 mentions `registry.clearAll()` once, but there is no example, no "clear-all-on-logout" recipe, no link to `createPersistRegistry`. | -| `HydrationSignal` / `toHydrationSignal` / `alwaysHydratedSignal` / `HydrationSource` | 🟡 | `toHydrationSignal` in quick-start README:40 and skill:79. `HydrationSignal` named in adapter section README:156. **`alwaysHydratedSignal` is undocumented** in any consumer doc — only in JSDoc. `HydrationSource` not mentioned. | -| Types: `StateStorage`, `StorageValue`, `PersistStorage`, `StorageCodec`, `JsonStorageOptions`, `CreateStorageOptions`, `CrossTabStorageEvent`, `CrossTabEventTarget`, `PersistOptions`, `PersistableSource` | 🟡 | `StateStorage`/`StorageCodec`/`PersistableSource` named in README:70–106. `CrossTabEventTarget` mentioned README:149. `StorageValue`, `PersistStorage`, `CreateStorageOptions`, `JsonStorageOptions`, `CrossTabStorageEvent` not surfaced in prose (only JSDoc + typedoc). | - -### `@stainless-code/persist/seroval` - -| Export | Status | Where | -| ---------------------- | ------ | -------------------------------- | -| `serovalCodec` | ✅ | README:96 | -| `createSerovalStorage` | ✅ | README quick-start:30, README:77 | - -### `@stainless-code/persist/idb` - -| Export | Status | Where | -| ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `idbStateStorage` | 🟡 | README:93, 131, 136 — appears in recipes but its async/cross-tab caveats are buried; `skill:116` mentions BroadcastChannel bridge. Never given a standalone "this is the raw backend" explainer. | -| `createIdbStorage` | ✅ | README:79, 139 | - -### `@stainless-code/persist/tanstack-store` - -| Export | Status | Where | -| -------------- | ------ | ------------------------- | -| `persistStore` | ✅ | README quick-start, skill | -| `persistAtom` | ✅ | README:111, skill:52–66 | - -### `@stainless-code/persist/react` - -| Export | Status | Where | -| ------------------- | ------ | --------------------------------------------- | -| `useHydrated` | ✅ | README quick-start:43, skill:81 | -| `UseHydratedResult` | 🟡 | Type only; `hydrated` field shown in example. | - -### Options on `PersistOptions` (the real capability surface) - -Documented in skill:163 list, but in the **README** only a subset is shown in prose/recipes: - -| Option | README | Skill | -| ----------------------------------------------------- | ---------------------------------- | --------------- | -| `name`, `storage`, `version`, `migrate` | ✅ | ✅ | -| `partialize`, `merge`, `onRehydrateStorage` | ❌ in README | ✅ | -| `skipHydration`, `skipPersist` | 🟡 (`skipHydration` README:183) | ✅ | -| `crossTab`, `crossTabEventTarget`, `onCrossTabRemove` | ✅ README:142–149 | ✅ | -| `maxAge`, `buster` | 🟡 (mentioned README:183) | ✅ | -| `throttleMs` | 🟡 (README:183) | ✅ | -| `retryWrite` | 🟡 (README:183, JSDoc has example) | ❌ not in skill | -| `onError` | 🟡 (README:183) | ❌ not in skill | -| `registry` | ❌ | 🟡 | - -**Biggest hidden capabilities:** - -- **`createPersistRegistry` + `registry` clear-all-on-logout** — fully undocumented in README; the only "logout wipes everything" path is invisible to a new reader. -- **`alwaysHydratedSignal`** — exported, undocumented in prose. -- **`partialize` / `merge` / `onRehydrateStorage`** — core projection/merge hooks, absent from the README entirely (only in skill). -- **`retryWrite`** — has a great JSDoc example (`src/core/persist-core.ts:290–299`) but no README recipe; the quota-shrink story is a selling point and is buried. -- **`setOptions` / `getOptions` / `clearStorage`** on `PersistApi` — never enumerated in README. -- **The generated API site** (`docs/api/`) — built, validated, but **never linked from the README** and is git-ignored so consumers can't browse it in-repo; they must run `bun run docs:api` or read hovers. - -## C.3 Onboarding path quality (5-minute test) - -A brand-new user landing on `README.md`: - -1. **What is this?** README:1–3 — one sentence. Crisp but jargon-dense ("storage × codec seams", "structural `PersistableSource`"). A newcomer who doesn't know "seam" or "hydration-aware" is lost on line 3. -2. **Why use it?** README:46–48 — the comparison paragraph. Good, but dense; "hydration lifecycle" is asserted, not explained. -3. **Install?** ✅ README:5–24 — clear, optional-peer table is excellent. -4. **Wire it up (TanStack Store + localStorage + seroval + React)?** ✅ README:26–44 quick start does exactly this. **But** the quick start uses `createSerovalStorage(() => localStorage)` + `useHydrated` — it does **not** show IndexedDB, and IndexedDB is where the hydration gate actually matters (async). The headline demo skips the case that justifies the library's "hydration-aware" name. -5. **Where they get stuck:** - - **No "what is hydration?" explainer.** The word "hydration" appears 20+ times in the README; it is never defined for a reader who only knows it from the Next.js/SSR sense (where it means something different). README:152 says "gate UI on `useHydrated`" but never says _what flashes_ or _why_. - - **No IndexedDB end-to-end example.** A user wanting IDB (the second-most-common backend) must assemble it from recipes README:131–139, which show `createStorage(() => idbStateStorage(), encryptedCodec, …)` and `createIdbStorage()` but never a complete `persistStore(store, { storage: createIdbStorage() })` + `useHydrated` gate + `destroy()` on unmount chain. - - **No React component context.** The quick start ends with `// in a component:` and one line. No full component, no `<Skeleton />` fallback, no `useEffect(() => { const persist = persistStore(...); return () => persist.destroy() }, [])` pattern in the README (it's in skill:96–101 but a README reader won't find it). - - **`useHydrated` import path is shown but `useSelector` is never mentioned** in the README — a TanStack Store user needs to know state reads go through `useSelector`, only the skill says so (skill:33 area via JSDoc example). - - **No SSR/Next.js example** despite the library shipping an SSR policy (`alwaysTrue` server snapshot, `null` signal = hydrated). README:156 mentions "render `hydrated: true` on the server" in the adapter-author section, not the consumer section. - - **`bun add` only.** No npm/pnpm/yarn equivalent. Minor, but `bun`-only install alienates non-Bun users (the engines field supports Node 20.19+/22.12+). - - **The generated API site is invisible** — a user wanting reference has only hovers or `docs/api/` which they must build themselves and is not linked. - -**Biggest onboarding gaps, ranked:** - -1. No "what is hydration / why does it flash" explainer — the library's namesake concept is undefined. -2. No complete IndexedDB + React + `useHydrated` + `destroy()` walkthrough. -3. No full React component example in the README. -4. `createPersistRegistry` / clear-all invisible. -5. Generated API reference not linked. - -## C.4 Examples in docs - -Counting copy-pasteable code blocks across README + skill: - -| Adapter combination | Example? | Where | -| ------------------------------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------- | -| TanStack Store + localStorage + seroval + React `useHydrated` | ✅ | README:28–44 | -| TanStack Store + localStorage + seroval (no React) | ✅ | skill:38–48 | -| TanStack Atom + (default JSON localStorage) | ✅ | skill:59–66 | -| TanStack Store + localStorage + jsonCodec (default) | 🟡 implied | quick start uses seroval; no plain-JSON example | -| TanStack Store + IndexedDB (`createIdbStorage`) + React | ❌ | never shown end-to-end | -| TanStack Store + IndexedDB + `identityCodec` | ❌ | `createIdbStorage()` shown standalone README:79, never wired to `persistStore` | -| TanStack Store + IndexedDB + encrypted codec | 🟡 | README:131–133 shows `createStorage(...)` but not the `persistStore(store, { storage })` wiring | -| TanStack Store + IndexedDB + seroval (legacy string payloads) | 🟡 | README:136, not wired | -| TanStack Store + sessionStorage | 🟡 | README:78 shows `createSerovalStorage(() => sessionStorage)` standalone | -| TanStack Store + cross-tab (localStorage) | ✅ | README:142–147, skill:107–114 | -| TanStack Store + cross-tab (IDB via BroadcastChannel) | ❌ | only prose README:149, skill:116 — no code | -| `persistSource` + zustand/Redux/hand-rolled | ❌ | README:117 shows the call shape; no real zustand/Redux example | -| React Native (`AsyncStorage`) | 🟡 | README:80 shows `createJSONStorage(() => AsyncStorage)` standalone, not wired | -| `partialize` | ❌ | no example anywhere | -| `merge` custom | ❌ | no example | -| `migrate` | ✅ | skill:122–129 | -| `retryWrite` | ✅ JSDoc only | `src/core/persist-core.ts:290–299` — not in README/skill | -| `registry` / `clearAll` | ❌ | no example | -| `skipPersist` | ✅ | skill (via persistStore JSDoc `src/adapters/sources/tanstack-store.ts:18`), README mentions | -| `throttleMs` | ❌ | no example, only prose | -| `maxAge` / `buster` | ❌ | no example, only prose | -| Svelte / Solid / Vue adapter | 🟡 | README:161–178 Svelte sketch only | -| Custom codec (superjson / encrypted) | ✅ | README:99–103 | - -**Rough count:** ~12 distinct code blocks in README, ~8 in skill. **No example** for: IDB+React end-to-end, IDB+identity wired to a store, IDB cross-tab BroadcastChannel, zustand/Redux via `persistSource`, `partialize`, `merge`, `registry`/clearAll, `throttleMs`, `maxAge`, `buster`, `retryWrite` (in prose docs), SSR/Next.js. - -**No `examples/` directory exists** in the repo — no runnable demo apps at all. - -## C.5 Missing doc types - -- **Docs site (VitePress / MkDocs / Astro Starlight).** Currently the consumer surface is one giant README. A multi-page site with sidebar nav would fix the "wall of text" problem and let the generated `docs/api/` be hosted and linked. -- **"Getting Started" guide** — progressive (install → 30-sec localStorage → IDB + hydration gate → SSR), distinct from the reference-dense README. -- **"Adapters" catalog page** — one page per entry (`./codecs/seroval`, `./backends/idb`, `./sources/tanstack-store`, `./frameworks/react`) with install, API, and a complete example each. -- **"Choose your storage" decision matrix** — localStorage vs sessionStorage vs IndexedDB vs AsyncStorage vs custom: sync/async, cross-tab support, structured-clone, size limits, when to gate UI. The skill has a 4-row version (skill:150–155); a fuller one belongs in consumer docs. -- **"Choose your codec" matrix** — jsonCodec vs serovalCodec vs identityCodec vs custom: Set/Map/Date support, wire type, backend compatibility, perf notes. Currently scattered across README:95–103. -- **Recipes section** (separate page) — encryption-at-rest, cross-tab IDB via BroadcastChannel, partialize+merge, retryWrite quota-shrink, registry clear-all-on-logout, SSR/Next.js, React Native AsyncStorage, throttled high-frequency writes, schema migration chains. Most of these have no example today. -- **Migration guide** — for users coming from zustand-persist / redux-persist / @tanstack/query-persist-client / pinia-persist: option-name mapping, conceptual diffs, copy-paste port. The README:46–48 paragraph compares positioning but gives no port guide. -- **Comparison page** — vs zustand-persist, redux-persist, `@tanstack/query-persist-client`, pinia-persist. README:46–48 does this in one paragraph; a table (store-agnostic? hydration signal? codec seam? cross-tab? retryWrite? migrate? throttle?) would land harder. -- **Playground / StackBlitz / CodeSandbox** — none. A live editable example is the fastest on-ramp. -- **Interactive REPL** — none. -- **API reference caveats** — `docs/api/` is generated and git-ignored; **the README never links to it** and never tells a user how to read it (`bun run docs:api` is in CONTRIBUTING, not README). A "Reference" section pointing at the generated site (hosted on GitHub Pages via the `.nojekyll` already present in `docs/api/`) is missing. -- **A "Common mistakes" page** — skill:141–146 has 4; lift to consumer docs. -- **An "Examples" repo / `examples/` dir** — none; runnable TanStack+IDB+React and Next.js SSR apps would close the biggest onboarding gap. - -## C.6 Tone & positioning - -- **Value proposition:** Crisp at the seam/structure level ("every 'can it do X?' is a one-line composition instead of a feature request", README:3). But it leads with mechanism (seams, `PersistableSource`) before outcome (your prefs survive reload, no UI flash, works with any store). A new user learns _how it's built_ before _what it does for them_. The headline should answer "what do I get?" first. -- **"Hydration-aware":** **Not explained.** The word appears in the title (`README.md:1`), in `package.json` description, and ~20 times in the README, but is never defined. A reader who knows "hydration" only from React/Next.js SSR will be confused — here it means _"has the persisted state finished loading from storage yet,"_ a completely different concept. README:152 says "gate UI on `useHydrated`" but never shows _what_ flashes (the default state rendering briefly before stored state lands). **This is the single biggest tone gap.** -- **TanStack intent:** Clear in `skills/tanstack-store/SKILL.md` and `docs/plans/upstream-tanstack-pitch.md`, but **opaque in the README**. The README never says "this is meant to become TanStack Persist" or that `./sources/tanstack-store` is the primary adapter. A reader sees five equal-weight subpaths and doesn't know `@tanstack/store` is the blessed path. The "Relationship to TanStack Persist / zustand persist" section (README:46) frames it as a _competitor/alternative_ rather than a _collaboration target_, which undersells the TanStack intent. -- **Density vs audience mismatch:** The README is one document trying to serve "give me 5 minutes" (quick start) and "I want the full seam theory" (Extensibility guide) and "I'm writing a Svelte adapter" (adapter section). All three audiences get one scroll. Progressive disclosure (a Getting Started page → Extensibility guide → Adapter authoring) would serve each without overwhelming the others. -- **Voice:** Confident and opinionated (good — "deliberate divergence", "no barrel", "prefs shouldn't silently expire"). This is a strength; preserve it in any restructure. -- **`bun`-centrism:** README assumes Bun (`bun add`). Engines field supports Node ≥20.19. The install command should show npm/pnpm/yarn too, or a note that any package manager works. - -### Bottom line - -The **skill file** (`skills/tanstack-store/SKILL.md`) is the real consumer doc and is excellent; the **README** is a dense reference that doubles as a landing page and underserves the brand-new user. The library's headline concept ("hydration-aware") is never defined; the headline use case that justifies the library (IndexedDB + async hydration gate) has no end-to-end example; the clear-all registry, `partialize`/`merge`, `retryWrite`, and `alwaysHydratedSignal` are effectively hidden; and the generated API site is built but unlinked. Sufficient for an experienced TanStack Store user willing to read JSDoc; **not** sufficient for a 5-minute cold onboarding. - ---- - -# Appendix D — Build / tooling (subagent report) - -> The build/CI agent returned a high-level summary rather than a full structured report (it refused to expand twice on resume). The summary is reproduced verbatim below; every point it raised is captured in the synthesis `Build & tooling` section and the ROI tiers. If deeper file:line detail is needed for this lane, resume [the build/CI agent](d0f5c223-e7eb-4495-b33d-2a55f0d1a43c). - -**Verbatim summary returned by the build/CI agent:** - -> Re-output the complete 6-section build/tooling/test/release audit of `@stainless-code/persist` with file:line citations: zero-dep core with optional peers and ESM-only output; bun-unit + vitest/jsdom DOM split with no coverage/matrix/real-browser/SSR-framework tests; three CI workflows (ci/release/check-skills) with single-env matrix and gaps in preview deploy, pkg-size diff, and semver/export pack-validation; changesets publish flow lacking npm provenance/signing and id-token permission; consumer DX gaps in attw/knip/exportslint, bundle badge, packageManager pin, TS range, compatibility table, FAQ; and no examples/playground infra. - -**Cross-checked against the synthesis `Build & tooling` section:** all six points (zero-dep core + optional peers + ESM-only; bun/vitest-jsdom split with no coverage/matrix/real-browser/SSR-framework tests; three CI workflows with single-env matrix + gaps; changesets lacking provenance/signing/id-token; consumer DX gaps; no examples/playground) are present. No information lost from this lane. diff --git a/docs/plans/remaining-roi.md b/docs/plans/remaining-roi.md index 5f50c3f..4356e8a 100644 --- a/docs/plans/remaining-roi.md +++ b/docs/plans/remaining-roi.md @@ -1,6 +1,6 @@ # Remaining ROI work -Actionable items not yet shipped from the [2026-07-04 ROI audit](../audits/2026-07-04-docs-adapters-roi.md). Shipped items are ✅-marked in the audit; this plan tracks the rest. When an item ships, lift its durable bits to `docs/architecture.md` / `docs/roadmap.md` / a rule and strike it here; when the list is empty, delete this file (per [docs-governance § Closing a plan](../../.agents/skills/docs-governance/LIFECYCLE.md)). +Actionable items not yet shipped from the docs-adapters ROI work. When an item ships, lift its durable bits to `docs/architecture.md` / `docs/roadmap.md` / a rule and strike it here; when the list is empty, delete this file (per [docs-governance § Closing a plan](../../.agents/skills/docs-governance/LIFECYCLE.md)). ## Context (read this first) @@ -106,8 +106,7 @@ From audit Appendix B.3. Each is a one-line composition over an existing seam; s ## Reference -- [Audit (full findings + shipped ✅ marks)](../audits/2026-07-04-docs-adapters-roi.md) -- [Architecture — seams, entry points, test matrix](../architecture.md) +- [Architecture — seams, entry points, test matrix, limitations](../architecture.md) - [Roadmap](../roadmap.md) - [Upstream TanStack pitch](./upstream-tanstack-pitch.md) - Root [README](../../README.md) — install, quick start, recipes, decision matrices diff --git a/docs/roadmap.md b/docs/roadmap.md index 3126e0a..7c5f5db 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,7 +6,7 @@ Forward-looking plans only — **not** a mirror of `src/`. **Doc index:** [READM ## Next -- **Remaining ROI work** — actionable items not yet shipped from the [2026-07-04 audit](./audits/2026-07-04-docs-adapters-roi.md): TanStack Query bridge, `examples/` workspace, docs site, real-browser + SSR test matrix, React ergonomics layer, OPFS/SQLite/Cloudflare adapters, playground. Plan: [`plans/remaining-roi.md`](./plans/remaining-roi.md). +- **Remaining ROI work** — actionable items not yet shipped: TanStack Query bridge, `examples/` workspace, docs site, real-browser + SSR + framework-runtime test matrix, React ergonomics layer, OPFS/SQLite/Cloudflare adapters, playground. Plan: [`plans/remaining-roi.md`](./plans/remaining-roi.md). - **Upstream TanStack Persist collaboration** — pitch the `persistSource` middleware model (structural `PersistableSource` + first-class hydration lifecycle) to the TanStack Persist maintainers as a merge target, after the stainless-code publish stabilises. Draft: [`plans/upstream-tanstack-pitch.md`](./plans/upstream-tanstack-pitch.md). --- From abb227a68bef4f298c1fa96be737df5e00861645 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Mon, 6 Jul 2026 13:10:09 +0300 Subject: [PATCH 74/77] docs(plans): strike shipped #4 (npm provenance) from remaining-roi; lift verify-on-release to roadmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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. --- docs/plans/remaining-roi.md | 24 +++++++----------------- docs/roadmap.md | 1 + 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/docs/plans/remaining-roi.md b/docs/plans/remaining-roi.md index 4356e8a..02bf618 100644 --- a/docs/plans/remaining-roi.md +++ b/docs/plans/remaining-roi.md @@ -48,36 +48,28 @@ Framework adapters mount `HydrationSignal` into each framework's external-store - **Acceptance:** site builds (`bun run docs:site` or equivalent), deployed to GitHub Pages (add `.nojekyll` at publish time); README links to it; `docs/api/` reachable from the site nav. No content duplication — the site pulls from the same source prose where possible. - **Lands:** `docs/site/` (or a `docs/` restructure); README trimmed to landing digest. Changeset: `minor`. -### 4. npm provenance + signing — Tier 3, S ✅ implemented (verify on next release) - -- **What:** npm **trusted publishing** (GitHub OIDC) — no long-lived `NPM_TOKEN`. `.github/workflows/release.yml`: add `id-token: write` + `environment: release` (matches the npm trusted-publisher binding's environment claim); remove `NPM_TOKEN` from the changesets step env. `package.json`: `publishConfig.provenance: true`. Provenance is auto-generated under trusted publishing (no `--provenance` flag); `changesets/action@v1` detects OIDC and skips the `.npmrc` token write, and `changeset publish` routes through `npm publish` (non-pnpm → npm) which does the OIDC exchange. -- **Why:** eliminates the long-lived token secret (the biggest supply-chain risk) + ships Sigstore provenance. Low effort, high integrity payoff. -- **Implemented:** `ci(release): switch to npm trusted publishing` — workflow + `package.json` committed. npm side configured: trusted publisher = `stainless-code/persist` + `release.yml` + environment `release` + `Allow npm publish`; publishing access = "require 2FA and disallow tokens". -- **Acceptance (remaining):** the next changeset merge to `main` publishes with provenance — verify the npm version page shows the Provenance badge, or `npm view @stainless-code/persist@<ver> --json` includes `dist.attestations`. Then revoke + delete the old `NPM_TOKEN` repo secret. Strike this item once verified. -- **Lands:** `.github/workflows/release.yml` + `package.json`. No changeset (infra-only). - -### 5. Real-browser + SSR + framework-runtime test matrix — Tier 3, M +### 4. Real-browser + SSR + framework-runtime test matrix — Tier 3, M - **What:** add a Playwright job covering the React `useHydrated` rerender/detach path in a real browser (Chromium + WebKit/Safari); add a Next.js SSR smoke that asserts the server renders `hydrated: true` and the client hydrates without a flash. The `tests-dom` vitest/jsdom suite stays (fast); Playwright is the slow, real-environment gate. **Framework-runtime coverage gaps to close here** (bun mocks can't exercise the reactive wiring): Preact `useHydrated` subscribe/unsubscribe + rerender-on-flip (add a `tests-dom` jsdom suite — parity with React); Svelte 5 runes `createSubscriber` reactive ownership + cleanup (needs a Svelte component runtime); Angular `effect()` async attach timing (needs an Angular runtime so the `angular.ts:30` re-read guard is exercised, not hidden by a sync mock). - **Why:** today the matrix is jsdom only — no real browser, no Safari, no SSR-framework. `useSyncExternalStore` reactivity and SSR snapshot policy are the constraint-critical paths; jsdom can diverge from real browsers. The Preact/Svelte/Angular adapters ship reactive wiring that the bun mocks never actually drive. - **Acceptance:** CI `Test (Browser)` job runs Playwright green; `Test (SSR)` job runs a Next.js app green; Preact jsdom suite green; Svelte + Angular runtime tests green (or an explicit decision per-adapter to defer to a community recipe). All gated by `CI complete`. Co-locate fixtures under `tests-browser/`, `tests-ssr/`, and `tests-dom/preact.test.tsx` (outside `bun test ./src`'s scan, like `tests-dom/`). - **Lands:** `.github/workflows/ci.yml` + new test dirs; `docs/architecture.md` § Test matrix updated. No changeset (test-only). -### 6. React ergonomics layer — Tier 4, M-L +### 5. React ergonomics layer — Tier 4, M-L - **What:** a `./frameworks/react` ergonomics companion (or a new `./frameworks/react-context` subpath) — `<PersistProvider>` + React context + `usePersisted(store, selector)` selector binding + auto-`destroy()` on unmount. The existing `useHydrated` stays the reference primitive. - **Why:** `useHydrated` is the entire React surface today — no provider, no auto store binding, no auto-teardown. The bare `useHydrated` path stays the reference primitive; a richer ergonomics layer (provider + auto-binding + auto-teardown) is a separate concern. - **Acceptance:** subpath ships + `tests-dom` coverage for mount/unmount teardown + selector rerender + provider scoping; README "React ergonomics" section. Keep it optional — the bare `useHydrated` path must remain valid. - **Lands:** `src/adapters/frameworks/react-context.ts` (new subpath) + README section. Changeset: `minor`. **Decision needed:** ship in-repo or as a separate package (the bare `useHydrated` path stays the reference primitive; a richer ergonomics layer (provider + auto-binding + auto-teardown) is a separate concern). -### 7. OPFS + SQLite-WASM + Cloudflare KV/Durable Objects adapters — Tier 4, M-L +### 6. OPFS + SQLite-WASM + Cloudflare KV/Durable Objects adapters — Tier 4, M-L - **What:** four new `./backends/` subpaths: `opfs` (Origin Private File System, async, file-backed, high-volume structured state), `sqlite-wasm` (wa-sqlite / sqlite-wasm, structured-clone mode like IDB), `cloudflare-kv` + `cloudflare-do` (edge runtime, async `StateStorage`). - **Why:** extends the backend surface to high-volume browser state, structured-query WASM storage, and edge runtimes. All fit `StateStorage<TRaw>` cleanly; no core rework. - **Acceptance:** each ships as its own subpath with optional peer + co-located test (mock the runtime, like the MMKV/AsyncStorage tests) + README backend decision-matrix row. `sqlite-wasm` may be better as a community recipe than a shipped peer (heavy) — decide per-adapter. - **Lands:** `src/adapters/backends/<name>.ts` + README "Choosing a storage" row + changeset (one per adapter). -### 8. StackBlitz / CodeSandbox playground — Tier 4, M +### 7. StackBlitz / CodeSandbox playground — Tier 4, M - **What:** an embedded live-editable example (StackBlitz or CodeSandbox) linked from the docs site (item 3) and README — the fastest on-ramp for a new user. - **Why:** no playground today; a new user can't try a wiring without cloning. Pairs with the docs site (item 3) and `examples/` (item 2). @@ -97,12 +89,10 @@ From audit Appendix B.3. Each is a one-line composition over an existing seam; s ## Sequencing -(#4 is implemented; pending first-release verification — strike once the npm Provenance badge lands.) - 1. **#1 (Query bridge)** — M, pure code, high adoption payoff, no deps. Best next pick. -2. **#5 (real-browser + SSR matrix)** — M, de-risks the hydration-critical paths before more surface lands. -3. **#2 (examples/) → #3 (docs site) → #8 (playground)** — the docs/demo arc; sequence so each builds on the prior. -4. **#6 (React ergonomics) + #7 (OPFS/SQLite/Cloudflare)** — strategic; decide ship-vs-recipe per item. +2. **#4 (real-browser + SSR matrix)** — M, de-risks the hydration-critical paths before more surface lands. +3. **#2 (examples/) → #3 (docs site) → #7 (playground)** — the docs/demo arc; sequence so each builds on the prior. +4. **#5 (React ergonomics) + #6 (OPFS/SQLite/Cloudflare)** — strategic; decide ship-vs-recipe per item. ## Reference diff --git a/docs/roadmap.md b/docs/roadmap.md index 7c5f5db..4f58d48 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -7,6 +7,7 @@ Forward-looking plans only — **not** a mirror of `src/`. **Doc index:** [READM ## Next - **Remaining ROI work** — actionable items not yet shipped: TanStack Query bridge, `examples/` workspace, docs site, real-browser + SSR + framework-runtime test matrix, React ergonomics layer, OPFS/SQLite/Cloudflare adapters, playground. Plan: [`plans/remaining-roi.md`](./plans/remaining-roi.md). +- **npm provenance — verify on next release** — trusted publishing + `publishConfig.provenance: true` shipped (`.github/workflows/release.yml`). On the next changeset merge to `main`, confirm the npm version page shows the Provenance badge (or `npm view @stainless-code/persist@<ver> --json` includes `dist.attestations`), then revoke + delete the legacy `NPM_TOKEN` repo secret. - **Upstream TanStack Persist collaboration** — pitch the `persistSource` middleware model (structural `PersistableSource` + first-class hydration lifecycle) to the TanStack Persist maintainers as a merge target, after the stainless-code publish stabilises. Draft: [`plans/upstream-tanstack-pitch.md`](./plans/upstream-tanstack-pitch.md). --- From e919a3e4859e48c7ca9ba7a2c2b17c5aaec1deaf Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Mon, 6 Jul 2026 13:22:47 +0300 Subject: [PATCH 75/77] =?UTF-8?q?feat(core):=20add=20createSessionStorage?= =?UTF-8?q?=20=E2=80=94=20DX=20factory=20over=20sessionStorage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-tab; crossTab is meaningless. Returns undefined when sessionStorage is unavailable (SSR / non-DOM) or defined-but-broken (Node 22+ half-built global). No new subpath — exports from the core . entry. Co-located test pins the broken-global guard + round-trip; README entry-points + storage tables updated for discoverability. Strikes the sessionStorage named- factory backlog item (shipped). --- .changeset/session-storage-factory.md | 5 ++ README.md | 72 +++++++++++++-------------- docs/plans/remaining-roi.md | 1 - src/core/persist-core.test.ts | 43 ++++++++++++++++ src/core/persist-core.ts | 11 ++++ 5 files changed, 95 insertions(+), 37 deletions(-) create mode 100644 .changeset/session-storage-factory.md diff --git a/.changeset/session-storage-factory.md b/.changeset/session-storage-factory.md new file mode 100644 index 0000000..98bfb9b --- /dev/null +++ b/.changeset/session-storage-factory.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/persist": minor +--- + +Add `createSessionStorage` — a zero-dep core DX factory over `sessionStorage` (per-tab; `crossTab` is meaningless). Returns `undefined` when `sessionStorage` is unavailable (SSR / non-DOM) or defined-but-broken (Node 22+ half-built global). No new subpath — exports from the core `.` entry. diff --git a/README.md b/README.md index 9554462..0f687db 100644 --- a/README.md +++ b/README.md @@ -398,31 +398,31 @@ Persistence middleware for any `getState`/`setState`/`subscribe` store (TanStack ## Entry points (one subpath = one optional peer) -| Subpath | Symbols | Optional peer | -| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | -| `@stainless-code/persist` | `persistSource`, `PersistApi`, `createStorage`, `createJSONStorage`, `createMigrationChain`, `jsonCodec`, `identityCodec`, `createPersistRegistry`, `toHydrationSignal`, `alwaysHydratedSignal` | none | -| `@stainless-code/persist/codecs/seroval` | `serovalCodec`, `createSerovalStorage` | `seroval` | -| `@stainless-code/persist/codecs/zod` | `zodCodec`, `createZodStorage` | `zod` | -| `@stainless-code/persist/backends/idb` | `idbStateStorage`, `createIdbStorage` (structured-clone mode) | `idb-keyval` | -| `@stainless-code/persist/backends/async-storage` | `asyncStorageStateStorage`, `createAsyncStorage` | `@react-native-async-storage/async-storage` | -| `@stainless-code/persist/backends/mmkv` | `mmkvStateStorage`, `createMmkvStorage` | `react-native-mmkv` | -| `@stainless-code/persist/backends/secure-store` | `secureStoreStateStorage`, `createSecureStoreStorage` | `expo-secure-store` | -| `@stainless-code/persist/backends/encrypted` | `createEncryptedStorage` (AES-GCM WebCrypto) | none (web global) | -| `@stainless-code/persist/backends/compressed` | `createCompressedStorage` (gzip/deflate/deflate-raw) | none (web global) | -| `@stainless-code/persist/backends/node-fs` | `nodeFsStateStorage` (one file per key) | none (Node built-in) | -| `@stainless-code/persist/transport/crosstab` | `createBroadcastCrossTab` | none (web global) | -| `@stainless-code/persist/sources/tanstack-store` | `persistStore`, `persistAtom` | `@tanstack/store` (types only) | -| `@stainless-code/persist/sources/zustand` | `persistStore` | `zustand` | -| `@stainless-code/persist/sources/jotai` | `persistAtom` | `jotai` | -| `@stainless-code/persist/sources/valtio` | `persistProxy` | `valtio` | -| `@stainless-code/persist/sources/mobx` | `persistObservable` | `mobx` | -| `@stainless-code/persist/frameworks/react` | `useHydrated` React hook | `react` | -| `@stainless-code/persist/frameworks/solid` | `useHydrated` (Solid `Accessor<boolean>`) | `solid-js` | -| `@stainless-code/persist/frameworks/vue` | `useHydrated` (Vue `Ref<boolean>`) | `vue` | -| `@stainless-code/persist/frameworks/svelte` | `hydratedRune` (Svelte 5 runes `current`) | `svelte` (>=5.7) | -| `@stainless-code/persist/frameworks/svelte-store` | `hydratedStore` (Svelte `Readable<boolean>`) | `svelte` (>=3) | -| `@stainless-code/persist/frameworks/angular` | `useHydrated` (Angular `Signal<boolean>`) | `@angular/core` (>=17) | -| `@stainless-code/persist/frameworks/preact` | `useHydrated` (Preact `{ hydrated: boolean }`) | `preact` (>=10.19) | +| Subpath | Symbols | Optional peer | +| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| `@stainless-code/persist` | `persistSource`, `PersistApi`, `createStorage`, `createJSONStorage`, `createSessionStorage`, `createMigrationChain`, `jsonCodec`, `identityCodec`, `createPersistRegistry`, `toHydrationSignal`, `alwaysHydratedSignal` | none | +| `@stainless-code/persist/codecs/seroval` | `serovalCodec`, `createSerovalStorage` | `seroval` | +| `@stainless-code/persist/codecs/zod` | `zodCodec`, `createZodStorage` | `zod` | +| `@stainless-code/persist/backends/idb` | `idbStateStorage`, `createIdbStorage` (structured-clone mode) | `idb-keyval` | +| `@stainless-code/persist/backends/async-storage` | `asyncStorageStateStorage`, `createAsyncStorage` | `@react-native-async-storage/async-storage` | +| `@stainless-code/persist/backends/mmkv` | `mmkvStateStorage`, `createMmkvStorage` | `react-native-mmkv` | +| `@stainless-code/persist/backends/secure-store` | `secureStoreStateStorage`, `createSecureStoreStorage` | `expo-secure-store` | +| `@stainless-code/persist/backends/encrypted` | `createEncryptedStorage` (AES-GCM WebCrypto) | none (web global) | +| `@stainless-code/persist/backends/compressed` | `createCompressedStorage` (gzip/deflate/deflate-raw) | none (web global) | +| `@stainless-code/persist/backends/node-fs` | `nodeFsStateStorage` (one file per key) | none (Node built-in) | +| `@stainless-code/persist/transport/crosstab` | `createBroadcastCrossTab` | none (web global) | +| `@stainless-code/persist/sources/tanstack-store` | `persistStore`, `persistAtom` | `@tanstack/store` (types only) | +| `@stainless-code/persist/sources/zustand` | `persistStore` | `zustand` | +| `@stainless-code/persist/sources/jotai` | `persistAtom` | `jotai` | +| `@stainless-code/persist/sources/valtio` | `persistProxy` | `valtio` | +| `@stainless-code/persist/sources/mobx` | `persistObservable` | `mobx` | +| `@stainless-code/persist/frameworks/react` | `useHydrated` React hook | `react` | +| `@stainless-code/persist/frameworks/solid` | `useHydrated` (Solid `Accessor<boolean>`) | `solid-js` | +| `@stainless-code/persist/frameworks/vue` | `useHydrated` (Vue `Ref<boolean>`) | `vue` | +| `@stainless-code/persist/frameworks/svelte` | `hydratedRune` (Svelte 5 runes `current`) | `svelte` (>=5.7) | +| `@stainless-code/persist/frameworks/svelte-store` | `hydratedStore` (Svelte `Readable<boolean>`) | `svelte` (>=3) | +| `@stainless-code/persist/frameworks/angular` | `useHydrated` (Angular `Signal<boolean>`) | `@angular/core` (>=17) | +| `@stainless-code/persist/frameworks/preact` | `useHydrated` (Preact `{ hydrated: boolean }`) | `preact` (>=10.19) | No barrel — importing a subpath is the dependency opt-in. @@ -492,17 +492,17 @@ Compose freely: `createStorage(backend, codec, options)` covers every backend × Pick by sync-vs-async (does it gate UI?), cross-tab needs, and whether you want structured-clone (Set/Map/Date natively). -| Backend | Sync? | Cross-tab | Structured-clone | Size | Gate UI? | Subpath | -| -------------------- | ----- | --------- | ---------------- | ------------------ | -------- | -------------------------- | -| IndexedDB | ✗ | ✗ | ✓ | large | ✓ | `./backends/idb` | -| AsyncStorage (RN) | ✗ | ✗ | ✗ | large | ✓ | `./backends/async-storage` | -| MMKV (RN) | ✓ | ✗ | ✗ | large | ✗ | `./backends/mmkv` | -| Secure Store (Expo) | ✗ | ✗ | ✗ | ~2KB/key | ✓ | `./backends/secure-store` | -| Node fs | ✗ | ✗ | ✗ | disk | ✓ | `./backends/node-fs` | -| Encrypted (wrapper) | ✗ | ✗ | ✗ | inherits | ✓ | `./backends/encrypted` | -| Compressed (wrapper) | ✗ | ✗ | ✗ | inherits (smaller) | ✓ | `./backends/compressed` | -| localStorage | ✓ | ✓ | ✗ | ~5MB | ✗ | core `createJSONStorage` | -| sessionStorage | ✓ | ✗ | ✗ | ~5MB | ✗ | core `createJSONStorage` | +| Backend | Sync? | Cross-tab | Structured-clone | Size | Gate UI? | Subpath | +| -------------------- | ----- | --------- | ---------------- | ------------------ | -------- | ------------------------------------------------- | +| IndexedDB | ✗ | ✗ | ✓ | large | ✓ | `./backends/idb` | +| AsyncStorage (RN) | ✗ | ✗ | ✗ | large | ✓ | `./backends/async-storage` | +| MMKV (RN) | ✓ | ✗ | ✗ | large | ✗ | `./backends/mmkv` | +| Secure Store (Expo) | ✗ | ✗ | ✗ | ~2KB/key | ✓ | `./backends/secure-store` | +| Node fs | ✗ | ✗ | ✗ | disk | ✓ | `./backends/node-fs` | +| Encrypted (wrapper) | ✗ | ✗ | ✗ | inherits | ✓ | `./backends/encrypted` | +| Compressed (wrapper) | ✗ | ✗ | ✗ | inherits (smaller) | ✓ | `./backends/compressed` | +| localStorage | ✓ | ✓ | ✗ | ~5MB | ✗ | core `createJSONStorage` | +| sessionStorage | ✓ | ✗ | ✗ | ~5MB | ✗ | core `createJSONStorage` / `createSessionStorage` | IDB has no storage events — pair `./transport/crosstab` for cross-tab sync. Encrypted/compressed wrappers over `localStorage` also don't receive native `storage` events (`raw` is the wrapper, not `localStorage`) — use `./transport/crosstab` for cross-tab there too. diff --git a/docs/plans/remaining-roi.md b/docs/plans/remaining-roi.md index 02bf618..3a429e0 100644 --- a/docs/plans/remaining-roi.md +++ b/docs/plans/remaining-roi.md @@ -81,7 +81,6 @@ Framework adapters mount `HydrationSignal` into each framework's external-store From audit Appendix B.3. Each is a one-line composition over an existing seam; ship only if demand surfaces. -- **sessionStorage named factory** (S) — already works via `createJSONStorage(() => sessionStorage)`; a named factory is pure DX/discoverability. Cross-tab is meaningless (per-tab). - **Redis backend** (M) — server-side persistent state; async `StateStorage`. Pairs with `./backends/node-fs` for a real server story. - **Chrome `storage.area`** (S) — `local` / `sync` / `session` for MV3 extensions (`localStorage` is forbidden in MV3 service workers). - **cookies backend** (M) — server-rendered hydration; size limits + HTTP coupling make it awkward — likely a recipe, not a shipped peer. diff --git a/src/core/persist-core.test.ts b/src/core/persist-core.test.ts index 2bc8f6c..c780198 100644 --- a/src/core/persist-core.test.ts +++ b/src/core/persist-core.test.ts @@ -7,6 +7,7 @@ import { createJSONStorage, createMigrationChain, createPersistRegistry, + createSessionStorage, createStorage, identityCodec, jsonCodec, @@ -163,6 +164,48 @@ describe("createJSONStorage codec seam", () => { }); }); +describe("createSessionStorage", () => { + it("returns undefined when sessionStorage is defined but broken (Node 22+ half-built global)", () => { + const saved = globalThis.sessionStorage; + globalThis.sessionStorage = { + getItem: undefined, + setItem: undefined, + removeItem: undefined, + } as unknown as typeof globalThis.sessionStorage; + try { + expect(createSessionStorage<{ count: number }>()).toBeUndefined(); + } finally { + if (saved === undefined) { + // @ts-expect-error restore the undefined global + delete globalThis.sessionStorage; + } else { + globalThis.sessionStorage = saved; + } + } + }); + + it("round-trips JSON values over a stubbed sessionStorage", async () => { + const stub = new MemoryStorage(); + const saved = globalThis.sessionStorage; + // @ts-expect-error — MemoryStorage satisfies the 3-method shape createJSONStorage checks + globalThis.sessionStorage = stub; + try { + const storage = createSessionStorage<{ count: number }>()!; + await storage.setItem("s", { state: { count: 5 }, version: 0 }); + const stored = await storage.getItem("s"); + expect(stored?.state.count).toBe(5); + expect(stub.getItem("s")).not.toBeNull(); + } finally { + if (saved === undefined) { + // @ts-expect-error + delete globalThis.sessionStorage; + } else { + globalThis.sessionStorage = saved; + } + } + }); +}); + describe("persistSource", () => { let memory: MemoryStorage; diff --git a/src/core/persist-core.ts b/src/core/persist-core.ts index 6d210f7..c1a74bd 100644 --- a/src/core/persist-core.ts +++ b/src/core/persist-core.ts @@ -645,6 +645,17 @@ export function createJSONStorage<S>( return createStorage(getStorage, jsonCodec<S>(options)); } +/** + * Build a JSON-encoded `PersistStorage` over `sessionStorage` — per-tab, so + * `crossTab` is meaningless. + */ +export function createSessionStorage<S>( + options?: JsonStorageOptions, +): PersistStorage<S> | undefined { + if (typeof sessionStorage === "undefined") return; + return createJSONStorage<S>(() => sessionStorage, options); +} + /** * Default merge: shallow-spread persisted over current. Single definition * referenced by both the options initializer and the hydrate fallback. From 4e4e137a1e17c135733e21648925c8045b573138 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Mon, 6 Jul 2026 13:28:27 +0300 Subject: [PATCH 76/77] docs(plans): correct structuredClone category + hydrate binary-codec blocker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit structuredClone isn't a codec (returns an object, not a wire type) — move it out of the Codecs candidate list into a trailing "isn't a codec" note; IDB identity mode covers its only use case. Hydrate the MessagePack/CBOR note: the real blocker isn't TRaw = Uint8Array (the seam already supports it) — it's that no shipped backend is binary-wire, so a binary codec needs a base64 bridge or a new binary backend. --- docs/plans/remaining-roi.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/remaining-roi.md b/docs/plans/remaining-roi.md index 3a429e0..9568941 100644 --- a/docs/plans/remaining-roi.md +++ b/docs/plans/remaining-roi.md @@ -84,7 +84,7 @@ From audit Appendix B.3. Each is a one-line composition over an existing seam; s - **Redis backend** (M) — server-side persistent state; async `StateStorage`. Pairs with `./backends/node-fs` for a real server story. - **Chrome `storage.area`** (S) — `local` / `sync` / `session` for MV3 extensions (`localStorage` is forbidden in MV3 service workers). - **cookies backend** (M) — server-rendered hydration; size limits + HTTP coupling make it awkward — likely a recipe, not a shipped peer. -- **Codecs** — MessagePack / cbor-x / CBOR (S, compact binary wire — needs `TRaw = Uint8Array`), superjson / devalue (S, class-instance round-trip), structuredClone (S, largely subsumed by IDB identity mode), protobuf (L, heavy toolchain — recipe). +- **Codecs** — MessagePack / cbor-x / CBOR (S codec, but needs `TRaw = Uint8Array` + a binary backend or base64 bridge — no shipped backend is binary-wire today), superjson / devalue (S, class-instance round-trip — overlaps `seroval`), protobuf (L, heavy toolchain — recipe). `structuredClone` isn't a codec (returns an object, not a wire type); IDB identity mode covers its only use case. ## Sequencing From e207bc5cdea4df10f2ad8f284ffebba818489317 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian <sebiitv@gmail.com> Date: Mon, 6 Jul 2026 13:41:21 +0300 Subject: [PATCH 77/77] docs: fix 6 fact-check drifts across README, architecture, changeset README, CONTRIBUTING, improve-codebase-architecture skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README partialize recipe: "ephemeral fields changing alone never write" overstated (a setState still writes the prefs slice; suppressing writes is skipPersist's job) → "excluded from the payload". - README node-fs recipe: comment said ./persist but the code uses dir: "./.persist" → align comment with the hidden-dir convention used. - docs/architecture.md test matrix: glob was tests-dom/**/*.test.tsx but vitest.config.ts uses tests-dom/**/*.test.{ts,tsx} → match the runner. - .changeset/README.md: stale NPM_TOKEN reference (release uses npm trusted publishing / OIDC, no token) → removed. - .github/CONTRIBUTING.md: lint-staged link typo latstg → lint-staged. - improve-codebase-architecture/REFERENCE.md: zero-dep core gate example over-listed react-dom (not a peerDependency — only a devDep + neverBundle entry; the architecture-priming rule's list omits it) → removed. CHANGELOG.md 0.1.0 "tanstack-intent keyword" verified historically accurate (added in 0.1.0 dbf0428, removed in a later unreleased refresh 31f9636) — left untouched. --- .agents/skills/improve-codebase-architecture/REFERENCE.md | 8 +------- .changeset/README.md | 2 +- .github/CONTRIBUTING.md | 2 +- README.md | 4 ++-- docs/architecture.md | 8 ++++---- 5 files changed, 9 insertions(+), 15 deletions(-) diff --git a/.agents/skills/improve-codebase-architecture/REFERENCE.md b/.agents/skills/improve-codebase-architecture/REFERENCE.md index 5aef871..24038e7 100644 --- a/.agents/skills/improve-codebase-architecture/REFERENCE.md +++ b/.agents/skills/improve-codebase-architecture/REFERENCE.md @@ -89,13 +89,7 @@ Example leaf for a directional rule (keep `persist-core` / `hydration` free of p { "patterns": [ { - "group": [ - "seroval", - "idb-keyval", - "@tanstack/store", - "react", - "react-dom" - ], + "group": ["seroval", "idb-keyval", "@tanstack/store", "react"], "message": "Zero-dep core: peer deps are subpath opt-in, not core imports. Use the matching subpath entry." } ] diff --git a/.changeset/README.md b/.changeset/README.md index 704752e..74a93c5 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -9,4 +9,4 @@ We have a quick list of common questions to get you started engaging with this p --- -**This repo:** How versioning, **`NPM_TOKEN`**, **`bun run version`** (includes **oxfmt** after **`changeset version`**), and the GitHub Release workflow fit together — see [`.github/CONTRIBUTING.md` § Releases](../.github/CONTRIBUTING.md#releases) (single place; don’t duplicate here). Run **`bun run changeset`** when your PR should bump the version, and commit the `.changeset/*.md` file it generates. +**This repo:** How versioning, **`bun run version`** (includes **oxfmt** after **`changeset version`**), and the GitHub Release workflow fit together — see [`.github/CONTRIBUTING.md` § Releases](../.github/CONTRIBUTING.md#releases) (single place; don’t duplicate here). Run **`bun run changeset`** when your PR should bump the version, and commit the `.changeset/*.md` file it generates. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 3dcbbaa..ff567d0 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -38,7 +38,7 @@ Then open a PR on GitHub into **`main`**. ### Git hooks -[Husky](https://github.com/typicode/husky) + [lint-staged](https://github.com/latstg/lint-staged) — see [`.husky/pre-commit`](../.husky/pre-commit). Pre-commit runs **`lint-staged`** only when `CURSOR_AGENT`, `CLAUDECODE`, or `AI_AGENT` is set (AI/agent commits). Staged files get `bun run format:check` (`oxfmt --check`), `oxlint`, staged-only **`tsgo`**, and **`bun test`** on `*.test.ts` / paired co-located tests. +[Husky](https://github.com/typicode/husky) + [lint-staged](https://github.com/lint-staged/lint-staged) — see [`.husky/pre-commit`](../.husky/pre-commit). Pre-commit runs **`lint-staged`** only when `CURSOR_AGENT`, `CLAUDECODE`, or `AI_AGENT` is set (AI/agent commits). Staged files get `bun run format:check` (`oxfmt --check`), `oxlint`, staged-only **`tsgo`**, and **`bun test`** on `*.test.ts` / paired co-located tests. ### Style diff --git a/README.md b/README.md index 0f687db..0fb7980 100644 --- a/README.md +++ b/README.md @@ -599,7 +599,7 @@ async function logout() { ### Partialize ```ts -// Persist only prefs — ephemeral fields (scroll, modal) changing alone never write +// Persist only prefs — ephemeral fields (scroll, modal) are excluded from the payload persistStore(store, { name: "app:state", storage, @@ -806,7 +806,7 @@ import { createStorage } from "@stainless-code/persist"; import { nodeFsStateStorage } from "@stainless-code/persist/backends/node-fs"; import { serovalCodec } from "@stainless-code/persist/codecs/seroval"; -// One file per key under ./persist — async (fs.promises); gate UI on useHydrated in SSR +// One file per key under ./.persist — async (fs.promises); gate UI on useHydrated in SSR const storage = createStorage<Prefs>( () => nodeFsStateStorage({ dir: "./.persist" }), serovalCodec(), diff --git a/docs/architecture.md b/docs/architecture.md index 6e8ffaa..17c83ef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -101,10 +101,10 @@ What the core deliberately does **not** do — the seams exist for these, but no Two runners, split by what they need: -| Runner | Scope | Pattern | Why | -| ----------------------------------------- | ------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | -| `bun:test` | `src/**/*.test.ts` | `bun test ./src` | No DOM — fast unit tests for the core, codecs, backends, transport, source + framework adapters, and hydration SSR/snapshot contracts. | -| `vitest` + jsdom + @testing-library/react | `tests-dom/**/*.test.tsx` | `bun run test:dom` | The React `useHydrated` rerender + unmount-detach path needs a DOM + a client renderer (`useSyncExternalStore` reactivity). | +| Runner | Scope | Pattern | Why | +| ----------------------------------------- | ------------------------------ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | +| `bun:test` | `src/**/*.test.ts` | `bun test ./src` | No DOM — fast unit tests for the core, codecs, backends, transport, source + framework adapters, and hydration SSR/snapshot contracts. | +| `vitest` + jsdom + @testing-library/react | `tests-dom/**/*.test.{ts,tsx}` | `bun run test:dom` | The React `useHydrated` rerender + unmount-detach path needs a DOM + a client renderer (`useSyncExternalStore` reactivity). | The split is structural — `tests-dom/` is a top-level directory outside `bun test ./src`'s scan, so the two runners never pick up the same file. `check` runs both in parallel; CI runs them as separate jobs (`Test`, `Test (DOM)`) gated by the single `CI complete` job. The bun suite's `src/adapters/frameworks/react.test.ts` header documents which contracts it pins and which it deliberately leaves to the vitest suite.