From 2ede8cfe9d49e61e73dc3f0856aaafd9374a24c9 Mon Sep 17 00:00:00 2001 From: webcoderspeed Date: Thu, 6 Aug 2026 01:09:28 +0530 Subject: [PATCH 1/4] feat: add query layer, framework adapters, plugin pipeline and repo infrastructure Turns Exostate into a single package covering both client state and server state, and fixes several bugs that would have shipped broken. Query layer (new) - QueryClient with stale-while-revalidate caching, request deduplication, retries with exponential backoff, refetch on focus/reconnect, polling, and garbage collection of unobserved entries. - createMutation with onMutate/onError context for optimistic updates and rollback. - dehydrate/hydrate for SSR; dataUpdatedAt is preserved so staleTime is measured from the server's fetch time and the client avoids a refetch waterfall on first paint. - React bindings: QueryClientProvider, useQuery, useMutation. Core store - Single commit() write path so plugins and batching cannot be bypassed. - Plugin pipeline is now actually wired: onBeforeUpdate (can transform the committed value), onAfterUpdate, onSubscribe, onUnsubscribe, onDestroy. Previously plugins were registered into a WeakMap that nothing ever read. - Lifecycle hooks with unmountDelay for lazy resource management. - Microtask notification batching plus flush(). - destroy() tears down plugins and cancels pending notifications. Bug fixes - combineStores returned state frozen at construction time when read without an active subscription, and did not catch up after detach/re-attach. - asyncAction shared one AbortController across invocations, so aborting one call cancelled a different one; added latestOnly so a slow earlier request cannot clobber a newer one. - useSelector recreated its subscribe callback every render (tearing down and re-adding the listener) and returned an uncached getSnapshot, which trips React's "getSnapshot should be cached" infinite loop with inline object selectors. - withMiddleware did not proxy destroyed, listeners or current. - Unsubscribe was not idempotent, so a double call fired lifecycle hooks twice. - freeze() could recurse forever on self-referential state. - persistFs could interleave concurrent writes into a torn file. Packaging (these would have shipped broken) - src/vue and src/solid were excluded from tsconfig while package.json advertised ./vue and ./solid exports, so those entry points were never built. - persistFs imported node:fs from the core entry, breaking browser bundlers; moved to the exostate/node subpath. CI now asserts the core stays free of node: builtins. - Switched to moduleResolution NodeNext, which surfaced extensionless relative imports that emitted ESM Node cannot resolve. Also adds: IndexedDB persistence, shallow/deepEqual comparators, Svelte and Solid selector adapters, CI with a Node 18/20/22 matrix and published-ESM smoke test, semantic-release, CodeQL, size budgets, issue/PR templates, dependabot, CONTRIBUTING, SECURITY, LICENSE, and a rewritten README. Test suite: 205 tests across 29 files, all passing. --- .github/ISSUE_TEMPLATE/bug_report.yml | 87 ++ .github/ISSUE_TEMPLATE/config.yml | 14 + .github/ISSUE_TEMPLATE/documentation.yml | 26 + .github/ISSUE_TEMPLATE/feature_request.yml | 58 + .github/dependabot.yml | 75 + .github/pull_request_template.md | 42 + .github/workflows/ci.yml | 173 +++ .github/workflows/codeql.yml | 38 + .github/workflows/release.yml | 48 + .github/workflows/size-limit.yml | 34 + .gitignore | 4 + .releaserc.json | 90 ++ CHANGELOG.md | 6 + CODE_OF_CONDUCT.md | 161 +++ CONTRIBUTING.md | 155 ++ LICENSE | 21 + README.md | 1490 +++++++++++++------- SECURITY.md | 40 + eslint.config.cjs | 17 + package-lock.json | 553 +++++++- package.json | 212 ++- scripts/bundle-for-size.mjs | 54 + src/async-action.ts | 81 ++ src/combine.ts | 55 +- src/computed.ts | 21 + src/define-store.ts | 39 + src/derived.ts | 4 +- src/devtools-redux.ts | 103 ++ src/devtools.ts | 2 +- src/equality.ts | 92 ++ src/errors.ts | 1 + src/event-source.ts | 129 ++ src/history.ts | 16 +- src/index.ts | 14 + src/middleware.ts | 49 +- src/node/index.ts | 72 + src/persist-idb.ts | 131 ++ src/persist.ts | 66 +- src/plugin.ts | 106 ++ src/query.ts | 851 +++++++++++ src/react/index.ts | 132 +- src/react/query.ts | 155 ++ src/serialize.ts | 2 +- src/solid/index.ts | 31 + src/ssr.ts | 6 +- src/state.ts | 2 +- src/store-factory.ts | 83 ++ src/store.ts | 247 +++- src/svelte/index.ts | 42 + src/transaction.ts | 17 +- src/types.ts | 63 + src/vue/index.ts | 36 + tests/async-action.test.ts | 65 + tests/computed.test.ts | 43 + tests/define-store.test.ts | 76 + tests/destroy.test.ts | 42 + tests/event-source.test.ts | 130 ++ tests/patch.test.ts | 50 + tests/persist.fs.test.ts | 2 +- tests/plugin.test.ts | 79 ++ tests/public-api.test.ts | 207 +++ tests/query.test.ts | 299 ++++ tests/react.query.test.ts | 111 ++ tests/react.selector.stability.test.ts | 117 ++ tests/regressions.test.ts | 267 ++++ tests/store-factory.test.ts | 79 ++ tsconfig.json | 6 +- 67 files changed, 6946 insertions(+), 673 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/documentation.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/size-limit.yml create mode 100644 .releaserc.json create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 SECURITY.md create mode 100644 scripts/bundle-for-size.mjs create mode 100644 src/async-action.ts create mode 100644 src/computed.ts create mode 100644 src/define-store.ts create mode 100644 src/devtools-redux.ts create mode 100644 src/equality.ts create mode 100644 src/event-source.ts create mode 100644 src/node/index.ts create mode 100644 src/persist-idb.ts create mode 100644 src/plugin.ts create mode 100644 src/query.ts create mode 100644 src/react/query.ts create mode 100644 src/solid/index.ts create mode 100644 src/store-factory.ts create mode 100644 src/svelte/index.ts create mode 100644 src/vue/index.ts create mode 100644 tests/async-action.test.ts create mode 100644 tests/computed.test.ts create mode 100644 tests/define-store.test.ts create mode 100644 tests/destroy.test.ts create mode 100644 tests/event-source.test.ts create mode 100644 tests/patch.test.ts create mode 100644 tests/plugin.test.ts create mode 100644 tests/public-api.test.ts create mode 100644 tests/query.test.ts create mode 100644 tests/react.query.test.ts create mode 100644 tests/react.selector.stability.test.ts create mode 100644 tests/regressions.test.ts create mode 100644 tests/store-factory.test.ts diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..bf7350a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,87 @@ +name: πŸ› Bug Report +description: Report something that behaves incorrectly +title: '[Bug]: ' +labels: ['bug', 'needs-triage'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug report. A minimal reproduction + is by far the most useful thing you can include. + + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Describe the bug and what you expected instead. + placeholder: Calling `store.patch()` inside a subscriber notified listeners twice… + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Minimal reproduction + description: The smallest code snippet that shows the problem. + render: typescript + placeholder: | + import { createStore } from 'exostate' + + const store = createStore({ count: 0 }) + // … + validations: + required: true + + - type: dropdown + id: area + attributes: + label: Which part of Exostate? + multiple: true + options: + - Core store (createStore, patch, subscribe) + - Query layer (QueryClient, useQuery, mutations) + - React adapter + - Vue adapter + - Svelte adapter + - Solid adapter + - Persistence (localStorage / IndexedDB / filesystem) + - History / time travel + - Plugins / middleware / devtools + - Types / TypeScript inference + - SSR / hydration + - Other + validations: + required: true + + - type: input + id: version + attributes: + label: Exostate version + placeholder: 1.2.0 + validations: + required: true + + - type: input + id: environment + attributes: + label: Environment + description: Runtime, framework version, bundler, OS. + placeholder: Node 22, React 18.3, Vite 5, macOS 15 + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Relevant logs or stack trace + render: shell + + - type: checkboxes + id: checks + attributes: + label: Before submitting + options: + - label: I searched existing issues and this is not a duplicate + required: true + - label: I am using a supported version of Exostate + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0c73164 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: false +contact_links: + - name: πŸ’¬ Discussions + url: https://github.com/webcoderspeed/exostate/discussions + about: Ask questions, share patterns, and propose ideas + - name: πŸ“– Documentation + url: https://github.com/webcoderspeed/exostate#readme + about: Full API reference, framework guides, and examples + - name: πŸ” Search existing issues + url: https://github.com/webcoderspeed/exostate/issues?q=is%3Aissue + about: Someone may have already reported this + - name: πŸ” Report a security vulnerability + url: https://github.com/webcoderspeed/exostate/security/advisories/new + about: Please report security issues privately, not as a public issue diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 0000000..941fc34 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -0,0 +1,26 @@ +name: πŸ“š Documentation +description: Report unclear, missing, or incorrect documentation +title: '[Docs]: ' +labels: ['documentation', 'good first issue'] +body: + - type: input + id: location + attributes: + label: Where? + description: README section, JSDoc on a specific export, or an example file. + placeholder: README β†’ "Query layer" section + validations: + required: true + + - type: textarea + id: problem + attributes: + label: What is wrong or missing? + validations: + required: true + + - type: textarea + id: suggestion + attributes: + label: Suggested wording + description: A concrete replacement is welcome but not required. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..3954da3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,58 @@ +name: ✨ Feature Request +description: Suggest a new capability or improvement +title: '[Feature]: ' +labels: ['enhancement', 'needs-triage'] +body: + - type: markdown + attributes: + value: | + Exostate aims to stay small and dependency-free. Proposals that explain + the concrete problem β€” rather than only the proposed API β€” are much + easier to evaluate. + + - type: textarea + id: problem + attributes: + label: What problem does this solve? + description: Describe the situation where the current API falls short. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed API + render: typescript + placeholder: | + const store = createStore({ count: 0 }, { /* … */ }) + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives you considered + description: Including how other libraries (Zustand, Jotai, TanStack Query, Nanostores) handle it. + + - type: dropdown + id: scope + attributes: + label: Where would this live? + options: + - Core (ships in the main entry) + - Query layer + - A framework adapter + - A separate subpath export + - Not sure + validations: + required: true + + - type: checkboxes + id: checks + attributes: + label: Before submitting + options: + - label: I searched existing issues and discussions for this idea + required: true + - label: I am willing to help implement this + required: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..75e2b6d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,75 @@ +version: 2 + +updates: + # ── npm dependencies ───────────────────────────────────────────── + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + time: '09:00' + timezone: Asia/Kolkata + open-pull-requests-limit: 10 + # Group non-major updates so the queue stays reviewable. + groups: + eslint-and-plugins: + patterns: + - 'eslint*' + - '@eslint/*' + - 'typescript-eslint' + - '@typescript-eslint/*' + - 'eslint-plugin-*' + - 'eslint-config-*' + testing: + patterns: + - 'vitest' + - '@vitest/*' + - 'jsdom' + - '@testing-library/*' + - 'fast-check' + typescript: + patterns: + - 'typescript' + - '@types/*' + frameworks: + patterns: + - 'react' + - 'react-dom' + - 'vue' + - 'svelte' + - 'solid-js' + labels: + - 'dependencies' + - 'automated' + commit-message: + prefix: 'chore' + prefix-development: 'chore' + include: scope + # Framework majors change peer-dependency semantics β€” review by hand. + ignore: + - dependency-name: 'typescript' + update-types: ['version-update:semver-major'] + - dependency-name: 'react' + update-types: ['version-update:semver-major'] + - dependency-name: 'vue' + update-types: ['version-update:semver-major'] + - dependency-name: 'svelte' + update-types: ['version-update:semver-major'] + - dependency-name: 'solid-js' + update-types: ['version-update:semver-major'] + + # ── GitHub Actions ──────────────────────────────────────────────── + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: '09:00' + timezone: Asia/Kolkata + open-pull-requests-limit: 5 + labels: + - 'github-actions' + - 'automated' + commit-message: + prefix: 'ci' + include: scope diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..2e5f66a --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,42 @@ + + +## What does this change? + + + +## Why? + + + +## Type of change + +- [ ] Bug fix (non-breaking change that fixes an issue) +- [ ] New feature (non-breaking change that adds capability) +- [ ] Breaking change (existing API behaves differently) +- [ ] Performance improvement +- [ ] Documentation only +- [ ] Internal / tooling + +## Checklist + +- [ ] `npm run validate` passes locally (build, typecheck, lint, tests) +- [ ] Added or updated tests covering this change +- [ ] Public API changes are documented (JSDoc + README) +- [ ] No new runtime dependency was added (Exostate is dependency-free) +- [ ] The core entry point stays browser-safe (no `node:` builtins outside `src/node/`) +- [ ] Bundle size impact considered (`npm run size`) + +## Notes for reviewers + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..070fe74 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,173 @@ +name: CI + +on: + push: + branches: [master, main, develop] + pull_request: + branches: [master, main, develop] + +# PRs: cancel the old run when new commits arrive (saves CI minutes). +# Default-branch pushes: never cancel β€” the release job lives in this run, +# and cancelling it mid-flight would abort a release in progress. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + quality: + name: Quality (lint Β· typecheck) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint:check + + - name: Type check + run: npm run typecheck + + test: + name: Test (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + needs: quality + strategy: + fail-fast: false + matrix: + node-version: ['18', '20', '22'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + build: + name: Build & verify package + runs-on: ubuntu-latest + needs: quality + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + # Every subpath advertised in package.json "exports" must exist, or + # consumers get a bare ERR_MODULE_NOT_FOUND at import time. + - name: Verify every exported entry point exists + run: | + for f in \ + dist/index.js dist/index.d.ts \ + dist/react/index.js dist/react/index.d.ts \ + dist/react/query.js dist/react/query.d.ts \ + dist/node/index.js dist/node/index.d.ts \ + dist/vue/index.js dist/vue/index.d.ts \ + dist/svelte/index.js dist/svelte/index.d.ts \ + dist/solid/index.js dist/solid/index.d.ts ; do + test -f "$f" || { echo "missing: $f"; exit 1; } + done + echo "All exported entry points present" + + # Guards the browser build: a node: builtin reachable from the core + # entry breaks bundlers that cannot polyfill it. + - name: Assert core entry is browser-safe + run: | + if grep -rlE "from \"node:|require\(['\"]node:" dist --include="*.js" \ + | grep -v '^dist/node/'; then + echo "A node: builtin is reachable outside dist/node/ β€” core must stay browser-safe" + exit 1 + fi + echo "Core entry is free of node: builtins" + + - name: Smoke test published ESM + run: | + node --input-type=module -e " + import { createStore, QueryClient } from './dist/index.js'; + const s = createStore({ n: 0 }); + s.patch({ n: 5 }); + if (s.read().n !== 5) throw new Error('store broken'); + const c = new QueryClient(); + const d = await c.fetchQuery({ queryKey: ['x'], queryFn: async () => 42 }); + if (d !== 42) throw new Error('query broken'); + await import('./dist/node/index.js'); + console.log('ESM smoke test passed'); + " + + - name: Upload dist artifact + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + retention-days: 3 + + release: + name: Semantic Release + runs-on: ubuntu-latest + needs: [test, build] + if: (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') && github.event_name == 'push' + permissions: + contents: write # push release tag + chore(release) commit + issues: write # comment on released issues + pull-requests: write # comment on released PRs + id-token: write # npm provenance attestation + + steps: + - name: Checkout (full history) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + # semantic-release refuses to publish when local HEAD is behind origin. + # Fast-forwarding here absorbs any commit that landed after github.sha + # was captured (bots, rapid successive pushes). + - name: Sync with remote + run: git fetch origin && git reset --hard origin/${{ github.ref_name }} + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: true + run: npx --yes -p semantic-release@25 -p @semantic-release/changelog@6 -p @semantic-release/git@10 -p conventional-changelog-conventionalcommits@8 semantic-release diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..4bf9934 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,38 @@ +name: CodeQL + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + schedule: + - cron: '0 6 * * 1' # Monday 06:00 UTC + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: ['javascript-typescript'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + queries: security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a9a0ed7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +name: Release (manual fallback) + +# The primary release path is the `release` job inside ci.yml. +# This workflow is a manual escape hatch β€” use it to cut a release without +# pushing a new commit (e.g. after fixing a broken NPM_TOKEN, or recovering +# from a release that failed mid-flight). +on: + workflow_dispatch: + +concurrency: + group: release + cancel-in-progress: false # never cancel an in-flight release β€” queue instead + +jobs: + release: + name: Semantic Release (manual) + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + id-token: write + + steps: + - name: Checkout (full history) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: true + run: npx --yes -p semantic-release@25 -p @semantic-release/changelog@6 -p @semantic-release/git@10 -p conventional-changelog-conventionalcommits@8 semantic-release diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml new file mode 100644 index 0000000..3e61bc0 --- /dev/null +++ b/.github/workflows/size-limit.yml @@ -0,0 +1,34 @@ +name: Size Limit + +on: + pull_request: + branches: [master, main] + +jobs: + size: + name: Bundle size + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + # Bundle size is a headline claim for this package β€” a regression here + # is a real regression, not a nit. + - name: Check size limits + run: npm run size diff --git a/.gitignore b/.gitignore index 0ea96e7..3b37a13 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ coverage/ .vscode/ .idea/ + +.size/ +release/ +coverage/ diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..46724b9 --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,90 @@ +{ + "branches": [ + "master", + "main", + { + "name": "beta", + "prerelease": true + }, + { + "name": "alpha", + "prerelease": true + } + ], + "plugins": [ + [ + "@semantic-release/commit-analyzer", + { + "preset": "conventionalcommits", + "releaseRules": [ + { "type": "feat", "release": "minor" }, + { "type": "fix", "release": "patch" }, + { "type": "perf", "release": "patch" }, + { "type": "revert", "release": "patch" }, + { "type": "docs", "release": "patch" }, + { "type": "style", "release": false }, + { "type": "refactor", "release": "patch" }, + { "type": "test", "release": false }, + { "type": "build", "release": false }, + { "type": "ci", "release": false }, + { "type": "chore", "release": false }, + { "breaking": true, "release": "major" } + ], + "parserOpts": { + "noteKeywords": ["BREAKING CHANGE", "BREAKING CHANGES", "BREAKING"] + } + } + ], + [ + "@semantic-release/release-notes-generator", + { + "preset": "conventionalcommits", + "presetConfig": { + "types": [ + { "type": "feat", "section": "✨ Features" }, + { "type": "fix", "section": "πŸ› Bug Fixes" }, + { "type": "perf", "section": "⚑ Performance" }, + { "type": "revert", "section": "βͺ Reverts" }, + { "type": "docs", "section": "πŸ“š Documentation" }, + { "type": "refactor", "section": "♻️ Code Refactoring" }, + { "type": "test", "section": "πŸ§ͺ Tests", "hidden": true }, + { "type": "build", "section": "πŸ—οΈ Build", "hidden": true }, + { "type": "ci", "section": "πŸ‘· CI", "hidden": true }, + { "type": "chore", "section": "πŸ”§ Chores", "hidden": true }, + { "type": "style", "section": "πŸ’„ Styles", "hidden": true } + ] + } + } + ], + [ + "@semantic-release/changelog", + { + "changelogFile": "CHANGELOG.md", + "changelogTitle": "# Changelog\n\nAll notable changes to **exostate** are documented in this file.\nThis project adheres to [Semantic Versioning](https://semver.org)." + } + ], + [ + "@semantic-release/npm", + { + "npmPublish": true, + "tarballDir": "release" + } + ], + [ + "@semantic-release/github", + { + "assets": [{ "path": "release/*.tgz", "label": "npm tarball" }], + "successComment": "πŸŽ‰ This issue has been resolved in version ${nextRelease.version}.\nInstall it: `npm install exostate@${nextRelease.version}`", + "labels": ["released"], + "releasedLabels": ["released@${nextRelease.channel}"] + } + ], + [ + "@semantic-release/git", + { + "assets": ["CHANGELOG.md", "package.json", "package-lock.json"], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ] + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3f81800 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +All notable changes to **exostate** are documented in this file. +This project adheres to [Semantic Versioning](https://semver.org). + + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..8ef15ac --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,161 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community +- Using welcoming and inclusive language +- Being patient with newcomers and helping them learn +- Celebrating the contributions of all community members + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Spam or low-quality contributions designed to game systems like Hacktoberfest +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[INSERT CONTACT METHOD]. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + +--- + +## 🀝 Community Values + +At Exostate, we believe in: + +- **Inclusivity**: Everyone is welcome, regardless of background or experience level +- **Respect**: Treating all community members with dignity and kindness +- **Collaboration**: Working together to build something amazing +- **Learning**: Supporting each other's growth and development +- **Quality**: Maintaining high standards while being patient with newcomers +- **Fun**: Enjoying the process of building great software together + +## πŸ“ž Reporting Issues + +If you experience or witness unacceptable behavior, please report it by: + +1. **GitHub Issues**: For public discussions about community standards +2. **Direct Contact**: Email maintainers for sensitive matters +3. **Anonymous Reporting**: Use our anonymous reporting form (if available) + +All reports will be handled with discretion and confidentiality. + +**Remember**: We're all here to learn, grow, and build amazing things together! πŸš€ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..bb3fcc1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,155 @@ +# Contributing to Exostate + +Thanks for your interest in improving Exostate. This document covers everything +you need to get productive quickly. + +## Table of Contents + +- [Getting started](#getting-started) +- [Project layout](#project-layout) +- [Development workflow](#development-workflow) +- [Design constraints](#design-constraints) +- [Testing](#testing) +- [Commit convention](#commit-convention) +- [Releasing](#releasing) +- [Reporting bugs](#reporting-bugs) + +## Getting started + +```bash +git clone https://github.com/webcoderspeed/exostate.git +cd exostate +npm install +npm run validate # build + typecheck + lint + tests +``` + +Node 18 or newer is required. + +## Project layout + +``` +src/ +β”œβ”€β”€ store.ts Core store: commit pipeline, plugins, batching, lifecycle +β”œβ”€β”€ types.ts Shared types, plugin and options contracts +β”œβ”€β”€ query.ts Query cache: SWR, dedup, retries, GC, mutations, SSR +β”œβ”€β”€ equality.ts shallow / deepEqual comparators +β”œβ”€β”€ combine.ts Multi-store composition +β”œβ”€β”€ computed.ts Version-cached derived values +β”œβ”€β”€ derived.ts Thin selector wrapper (kept for compatibility) +β”œβ”€β”€ history.ts Undo / redo / time travel +β”œβ”€β”€ transaction.ts Atomic multi-step updates with rollback +β”œβ”€β”€ persist.ts localStorage-style persistence (browser-safe) +β”œβ”€β”€ persist-idb.ts IndexedDB persistence +β”œβ”€β”€ plugin.ts Plugin helpers and built-in plugins +β”œβ”€β”€ middleware.ts Operation-level middleware wrapper +β”œβ”€β”€ devtools.ts Devtools middleware +β”œβ”€β”€ devtools-redux.ts Redux DevTools extension bridge +β”œβ”€β”€ event-source.ts Append-only event log +β”œβ”€β”€ define-store.ts Creator-pattern store definition +β”œβ”€β”€ store-factory.ts Scoped and cached store factories +β”œβ”€β”€ serialize.ts Versioned serialization with migrations +β”œβ”€β”€ ssr.ts dehydrate / rehydrate +β”œβ”€β”€ errors.ts SafeError and error policies +β”œβ”€β”€ schema.ts Schema validation adapters (incl. Zod) +β”œβ”€β”€ node/ Node-only entry (filesystem persistence) +β”œβ”€β”€ react/ React adapter and query hooks +β”œβ”€β”€ vue/ Vue adapter +β”œβ”€β”€ svelte/ Svelte adapter +└── solid/ Solid adapter + +tests/ Vitest suites, one per area +benchmarks/ Comparative benchmarks vs Redux and Zustand +examples/ Runnable usage examples +``` + +## Development workflow + +```bash +npm run test # run the suite once +npm run test:watch # watch mode +npm run typecheck # tsc --noEmit +npm run lint # eslint, zero warnings tolerated +npm run lint:fix # auto-fix what can be auto-fixed +npm run build # emit dist/ +npm run size # bundle size budgets +npm run bench # micro-benchmarks +npm run validate # everything above, as CI runs it +``` + +Run `npm run validate` before opening a pull request. + +## Design constraints + +These are the non-negotiables that shape every change: + +1. **Zero runtime dependencies.** Exostate ships no `dependencies`. Framework + packages are optional peer dependencies. +2. **The core entry must stay browser-safe.** No `node:` builtin may be + reachable from `src/index.ts`. Node-only code lives in `src/node/` and is + published as the `exostate/node` subpath. CI enforces this. +3. **Relative imports need explicit `.js` extensions.** The package builds with + `moduleResolution: NodeNext`; extensionless specifiers emit ESM that Node + cannot resolve. +4. **State is immutable.** Mutating methods produce a new value and go through + `StoreImpl.commit`, which is the single write path β€” plugins and batching + must never be bypassable. +5. **Every public export carries JSDoc.** Types are the documentation for most + users. +6. **Bundle size is a feature.** Check `npm run size` when adding to the core + entry; prefer a new subpath export over growing the default import. + +## Testing + +Tests use [Vitest](https://vitest.dev) with the jsdom environment. + +- Put a test next to the concern it covers (`tests/query.test.ts`, + `tests/regressions.test.ts`, …). +- Every bug fix needs a regression test that fails without the fix. +- Async timing tests should use an injected clock where possible + (`new QueryClient({ now: () => clock })`) rather than long sleeps. +- React tests follow the existing `React.createElement` style so the `.ts` + lint configuration applies to them. + +```bash +npm test -- tests/query.test.ts # single file +``` + +## Commit convention + +This project uses [Conventional Commits](https://www.conventionalcommits.org). +Release versions are derived from commit titles automatically: + +| Prefix | Release | +| ------------------- | ------- | +| `feat:` | minor | +| `fix:` | patch | +| `perf:` | patch | +| `refactor:` | patch | +| `docs:` | patch | +| `test:` `chore:` `ci:` `build:` `style:` | none | +| `feat!:` or a `BREAKING CHANGE:` footer | major | + +Examples: + +``` +feat(query): add refetchInterval option +fix(react): cache getSnapshot so inline selectors cannot loop +perf(store): skip the plugin pipeline when no plugins are attached +``` + +## Releasing + +Releases are fully automated. Merging to the default branch runs +[semantic-release](https://semantic-release.gitbook.io), which determines the +version from commit history, updates `CHANGELOG.md`, publishes to npm with +provenance, and creates a GitHub release. Maintainers never bump versions by +hand. + +## Reporting bugs + +Open an issue with the [bug report template](https://github.com/webcoderspeed/exostate/issues/new?template=bug_report.yml). +A minimal reproduction is worth more than any amount of description. + +Security vulnerabilities should be reported privately through +[GitHub Security Advisories](https://github.com/webcoderspeed/exostate/security/advisories/new), +not as a public issue. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ed50409 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sanjeev Sharma + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b6be375..86df715 100644 --- a/README.md +++ b/README.md @@ -1,670 +1,1158 @@ -# Exostate Documentation +# exostate -## Table of Contents -1. [Introduction & Philosophy](#introduction--philosophy) -2. [Getting Started](#getting-started) -3. [React Integration](#react-integration) -4. [Transactional State](#transactional-state) -5. [Middleware & Observability](#middleware--observability) -6. [Store Composition](#store-composition) -7. [Validation & Error Handling](#validation--error-handling) -8. [Server-Side Rendering (SSR)](#server-side-rendering-ssr) -9. [Comparison with Other Libraries](#comparison-with-other-libraries) -10. [API Reference](#api-reference) - -## Introduction & Philosophy +

+ State management and async data fetching in one 1.2 kB package.
+ Stale-while-revalidate cache · Request deduplication · Optimistic updates · SSR hydration
+ React · Vue · Svelte · Solid · Vanilla JS · Node.js +

-Exostate is a next-generation state management library designed for **backend-grade reliability** in frontend applications. Our philosophy centers around three core principles: +

+ npm version + npm downloads + bundle size + CI + MIT + TypeScript + zero dependencies +

-### 1. **Strict Type Safety** -- Zero tolerance for `any` types -- Full TypeScript generics throughout -- Compile-time guarantees for runtime safety +

+ GitHub · + npm · + Issues · + Discussions +

-### 2. **Transactional Integrity** -- Inspired by database transaction models -- Atomic commit/rollback semantics -- Consistent state guarantees during complex operations +--- -### 3. **Universal Compatibility** -- Framework-agnostic core (works with React, Vue, Svelte, Vanilla JS) -- Server-side rendering ready -- Node.js and browser compatible +## The two-library problem -### Why Exostate? +Every serious frontend app installs two state libraries and glues them together: -Traditional state management libraries often prioritize simplicity over reliability. Exostate brings the rigor of backend systems to frontend state management: +```bash +# Client state +npm install zustand -- **For complex applications**: When your state logic involves multi-step operations, conditional updates, or error recovery -- **For mission-critical systems**: When data consistency and reliability are non-negotiable -- **For teams that value types**: When you want compile-time guarantees about your state transitions +# Server state +npm install @tanstack/react-query -## Getting Started +# ...then discover they don't share a cache +# ...then write the bridge code yourself +# ...then repeat all of it for your Vue admin panel +``` -### Installation +Exostate is one package, one mental model, every framework: ```bash npm install exostate ``` -For React applications: +```typescript +import { createStore, QueryClient } from 'exostate' + +// Client state β€” synchronous, immutable, type-safe +const ui = createStore({ theme: 'dark', sidebarOpen: false }) +ui.patch({ sidebarOpen: true }) + +// Server state β€” cached, deduplicated, revalidated +const client = new QueryClient() +const user = await client.fetchQuery({ + queryKey: ['user', 42], + queryFn: ({ signal }) => fetch('/api/users/42', { signal }).then(r => r.json()), + staleTime: 30_000, +}) +``` + +Same store primitive underneath. Same subscription model. Works in React, Vue, +Svelte, Solid, and plain JavaScript β€” including on the server. + +--- + +## Table of Contents + +- [Why Exostate](#why-exostate) +- [Feature comparison](#feature-comparison) +- [Bundle size](#bundle-size) +- [Performance](#performance) +- [Installation](#installation) +- [Quick start](#quick-start) +- [Core concepts](#core-concepts) + - [Creating a store](#creating-a-store) + - [Updating state](#updating-state) + - [Subscribing and selectors](#subscribing-and-selectors) + - [Computed values](#computed-values) + - [Combining stores](#combining-stores) + - [Microtask batching](#microtask-batching) + - [Lifecycle hooks (lazy stores)](#lifecycle-hooks-lazy-stores) + - [Destroying a store](#destroying-a-store) +- [The query layer](#the-query-layer) + - [Stale-while-revalidate](#stale-while-revalidate) + - [Request deduplication](#request-deduplication) + - [Retries and backoff](#retries-and-backoff) + - [Invalidation](#invalidation) + - [Mutations and optimistic updates](#mutations-and-optimistic-updates) + - [Garbage collection](#garbage-collection) + - [Server-side rendering](#server-side-rendering) +- [Framework adapters](#framework-adapters) + - [React](#react) + - [Vue](#vue) + - [Svelte](#svelte) + - [Solid](#solid) + - [Vanilla JavaScript](#vanilla-javascript) +- [Advanced features](#advanced-features) + - [Plugins](#plugins) + - [Middleware](#middleware) + - [Transactions](#transactions) + - [History and time travel](#history-and-time-travel) + - [Persistence](#persistence) + - [Event sourcing](#event-sourcing) + - [Store factories](#store-factories) + - [Redux DevTools](#redux-devtools) + - [Schema validation](#schema-validation) + - [Versioned serialization](#versioned-serialization) +- [Recipes](#recipes) +- [API reference](#api-reference) +- [FAQ](#faq) +- [Contributing](#contributing) +- [License](#license) + +--- + +## Why Exostate + +**One package instead of two.** Client state and server state use the same +store, the same subscription model, and the same types. No bridge code. + +**Genuinely framework-agnostic.** The core has zero framework imports. The +React, Vue, Svelte, and Solid adapters are thin β€” under 1 kB each β€” and every +feature works in plain JavaScript and on Node. + +**Zero runtime dependencies.** Nothing is pulled into your lockfile. + +**Tree-shakeable by design.** Importing `createStore` costs 1.16 kB gzipped. +The query layer only ships if you import it. + +**Immutable and type-safe.** State is `DeepReadonly` at the type level; +mutating methods return new values. No proxies, no magic, no `any`. + +**Browser-safe core.** Node-only code lives in `exostate/node`, so the main +entry never drags `node:fs` into a browser bundle. CI enforces this. + +--- + +## Feature comparison + +| Feature | Exostate | Zustand | Jotai | Nanostores | Redux Toolkit | TanStack Query | +| --- | :---: | :---: | :---: | :---: | :---: | :---: | +| Client state | βœ… | βœ… | βœ… | βœ… | βœ… | ❌ | +| Async query cache | βœ… | ❌ | partial | ❌ | RTK Query | βœ… | +| Stale-while-revalidate | βœ… | ❌ | ❌ | ❌ | βœ… | βœ… | +| Request deduplication | βœ… | ❌ | ❌ | ❌ | βœ… | βœ… | +| Optimistic updates + rollback | βœ… | manual | manual | manual | βœ… | βœ… | +| Cache garbage collection | βœ… | ❌ | ❌ | ❌ | βœ… | βœ… | +| SSR dehydrate / hydrate | βœ… | manual | manual | manual | βœ… | βœ… | +| React adapter | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | +| Vue adapter | βœ… | ❌ | ❌ | βœ… | ❌ | βœ… | +| Svelte adapter | βœ… | ❌ | ❌ | βœ… | ❌ | βœ… | +| Solid adapter | βœ… | ❌ | ❌ | βœ… | ❌ | βœ… | +| Vanilla JS | βœ… | βœ… | ❌ | βœ… | βœ… | βœ… | +| Transactions with rollback | βœ… | ❌ | ❌ | ❌ | ❌ | ❌ | +| Undo / redo history | βœ… | ❌ | ❌ | ❌ | ❌ | ❌ | +| Event sourcing / audit log | βœ… | ❌ | ❌ | ❌ | ❌ | ❌ | +| Plugin system | βœ… | middleware | ❌ | ❌ | middleware | ❌ | +| Lazy mount/unmount lifecycle | βœ… | ❌ | ❌ | βœ… | ❌ | ❌ | +| Microtask batching | βœ… | ❌ | βœ… | ❌ | ❌ | n/a | +| IndexedDB persistence | βœ… | plugin | plugin | plugin | plugin | ❌ | +| Redux DevTools | βœ… | βœ… | βœ… | ❌ | βœ… | ❌ | +| Runtime dependencies | **0** | 0 | 0 | 0 | 3+ | 0 | + +Comparison reflects each library's core package without third-party plugins, +as of August 2026. "manual" means achievable but not provided. + +--- + +## Bundle size + +Measured with `size-limit` on real esbuild-bundled, minified, gzipped output β€” +not on the un-bundled barrel file. Reproduce with `npm run size`. + +| What you import | Gzipped | +| --- | ---: | +| `createStore` only | **1.16 kB** | +| `createStore` + `computed` + `persistLocal` + `createHistory` | 1.92 kB | +| Query layer (`QueryClient` + `createMutation`) | 3.84 kB | +| React adapter (all hooks) | 913 B | +| React query hooks | 2.37 kB | +| Entire library, nothing tree-shaken | 7.72 kB | + +Because the package is side-effect free and every feature is a separate export, +you only pay for what you import β€” a counter store costs 1.16 kB whether or not +the query layer exists in the package. + +--- + +## Performance + +Run on Node 20, 100,000 iterations. Reproduce with `npm run bench:compare`. +These are micro-benchmarks β€” in real apps, render behaviour dominates. + +**Updates, no subscribers** + +| Library | Operation | Ops/sec | +| --- | --- | ---: | +| Exostate | `patch` / assign | **19,833,891** | +| Zustand | `setState` | 14,755,883 | +| Exostate | `update(reducer)` | 7,648,695 | +| Redux | `dispatch` | 5,273,404 | + +**Updates with one subscriber** + +| Library | Operation | Ops/sec | +| --- | --- | ---: | +| Exostate | `patch` / assign | **16,114,630** | +| Zustand | `setState` | 14,683,389 | +| Exostate | `update(reducer)` | 6,941,873 | +| Redux | `dispatch` | 4,982,034 | + +Read honestly: Exostate's shallow-merge path is the fastest of the four, and +its reducer path beats Redux but trails Zustand's `setState` β€” reducers do +strictly more work. Pick `patch` for hot paths and `update` when you want the +reducer discipline. + +--- + +## Installation + ```bash -npm install exostate react react-dom +npm install exostate +# or +pnpm add exostate +# or +yarn add exostate +# or +bun add exostate ``` -### Your First Store +Framework packages are **optional peer dependencies** β€” install only what you use. + +| Entry point | Import from | Requires | +| --- | --- | --- | +| Core (framework-agnostic) | `exostate` | β€” | +| React hooks | `exostate/react` | `react >= 18` | +| React query hooks | `exostate/react/query` | `react >= 18` | +| Vue composables | `exostate/vue` | `vue >= 3` | +| Svelte stores | `exostate/svelte` | `svelte >= 4` | +| Solid signals | `exostate/solid` | `solid-js >= 1` | +| Filesystem persistence | `exostate/node` | Node >= 18 | + +Requires TypeScript 5.0+ for the bundled types. Node 18+ for the runtime. + +--- + +## Quick start ```typescript -import { createStore } from 'exostate'; +import { createStore } from 'exostate' -// Define your state interface -interface CounterState { - count: number; - loading: boolean; +interface CartState { + items: Array<{ id: string; qty: number }> + coupon: string | null } -// Create a store with initial state -const counterStore = createStore({ - count: 0, - loading: false -}); +const cart = createStore({ items: [], coupon: null }) + +// Read +cart.read() // CartState +cart.snapshot() // DeepReadonly + +// Write β€” shallow merge, Zustand-style +cart.patch({ coupon: 'SUMMER25' }) -// Read current state -console.log(counterStore.read()); // { count: 0, loading: false } +// Write β€” functional +cart.patch(prev => ({ items: [...prev.items, { id: 'sku-1', qty: 1 }] })) -// Update state using a reducer -counterStore.update((state, increment: number) => ({ - ...state, - count: state.count + increment -}), 5); +// Write β€” with a reducer, for logic you want named and testable +const addItem = (prev: CartState, item: { id: string; qty: number }) => ({ + ...prev, + items: [...prev.items, item], +}) +cart.update(addItem, { id: 'sku-2', qty: 3 }) -console.log(counterStore.read().count); // 5 +// Subscribe to a slice β€” only fires when that slice changes +const unsubscribe = cart.subscribe( + s => s.items.length, + count => console.log('item count:', count) +) + +unsubscribe() ``` -### Basic Subscription +--- + +## Core concepts + +### Creating a store ```typescript -// Subscribe to count changes -const unsubscribe = counterStore.subscribe( - (state) => state.count, - (count) => console.log('Count changed:', count) -); +import { createStore } from 'exostate' -// Later, unsubscribe -unsubscribe(); +const store = createStore({ count: 0 }) ``` -## React Integration +With options: -Exostate provides first-class React hooks through the `exostate/react` subpath. +```typescript +const store = createStore( + { count: 0 }, + { + notify: 'microtask', // coalesce notifications β€” see below + unmountDelay: 1000, // grace period before onUnsubscribe fires + plugins: [logger()], // attach plugins at construction + onSubscribe: (s, listenerCount) => { /* … */ }, + onUnsubscribe: (s, listenerCount) => { /* … */ }, + } +) +``` -### Basic Usage +### Updating state -```tsx -import { createStore } from 'exostate'; -import { useStore, useSelector } from 'exostate/react'; - -const userStore = createStore({ - user: null, - preferences: { theme: 'dark' } -}); - -function UserProfile() { - // Access entire store state - const state = useStore(userStore); - - // Or use selector for granular updates - const theme = useSelector(userStore, state => state.preferences.theme); - - return ( -
-

{state.user?.name}

-
- ); -} +Every mutating method returns the new state and goes through a single commit +path, so plugins and batching can never be bypassed. + +| Method | Use for | +| --- | --- | +| `patch(partial)` | Shallow-merge an object or `prev => partial` | +| `set(next)` | Replace the whole state | +| `update(reducer, payload)` | Named, testable transitions | +| `compute(fn)` | `prev => next` without a payload | +| `batch(apply)` | Several reducers, one notification | +| `effect(fn, payload)` | Read-only side effects | + +```typescript +store.patch({ count: 5 }) +store.patch(prev => ({ count: prev.count + 1 })) +store.set({ count: 0 }) +store.update((prev, by: number) => ({ count: prev.count + by }), 10) +store.compute(prev => ({ count: prev.count * 2 })) + +// One notification for the whole group +store.batch(apply => { + apply((prev, by: number) => ({ count: prev.count + by }), 1) + apply((prev, by: number) => ({ count: prev.count * by }), 3) +}) ``` -### Multiple Stores with useStores +> `patch` performs a **shallow** merge. Nested objects are replaced, not merged +> β€” the same rule Zustand uses, chosen because it is predictable. -```tsx -import { useStores } from 'exostate/react'; +### Subscribing and selectors -function App() { - const { auth, settings, notifications } = useStores({ - auth: authStore, - settings: settingsStore, - notifications: notificationsStore - }); - - return ( -
- {auth.user ? : } -
- ); -} +```typescript +const unsubscribe = store.subscribe( + s => s.user.name, // selector β€” subscription is scoped to this + name => console.log(name), // only called when the selected value changes + { fireImmediately: true } // optional: call once with the current value +) ``` -### Optimized Selectors +Pass a custom comparator when the selector builds a new object each call: -```tsx -function ExpensiveComponent() { - // Only re-renders when specific user properties change - const userName = useSelector( - userStore, - state => state.user?.name, - (prev, next) => prev === next // Custom equality check - ); - - return
{userName}
; -} +```typescript +import { shallow, deepEqual } from 'exostate' + +store.subscribe( + s => ({ id: s.user.id, name: s.user.name }), + user => render(user), + { eq: shallow } +) +``` + +### Computed values + +`computed` caches against the store's version counter, so the selector runs at +most once per state change no matter how often you read it. + +```typescript +import { computed } from 'exostate' + +const fullName = computed(userStore, s => `${s.firstName} ${s.lastName}`) + +fullName.read() // computes +fullName.read() // cached β€” no recomputation +fullName.subscribe(name => console.log(name)) ``` -## Transactional State +### Combining stores -Exostate's transaction system provides atomic operations with rollback capabilities. +```typescript +import { combineStores } from 'exostate' -### Basic Transaction +const app = combineStores({ cart, user, ui }) + +app.read() // { cart: CartState, user: UserState, ui: UiState } +app.subscribe(all => console.log(all.cart.items.length)) +``` + +The combined view attaches to its children lazily and detaches when the last +subscriber leaves, so it never keeps idle stores alive. + +### Microtask batching + +Inspired by Valtio. With `notify: 'microtask'`, a burst of synchronous writes +produces exactly one notification: ```typescript -import { beginTransaction } from 'exostate'; +const store = createStore({ a: 0, b: 0, c: 0 }, { notify: 'microtask' }) -const store = createStore({ balance: 100, transactions: [] }); +store.subscribe(s => s, () => console.log('notified')) -function transferFunds(amount: number) { - const tx = beginTransaction(store); - - try { - // Multiple operations in transaction - tx.update(state => ({ - balance: state.balance - amount, - transactions: [...state.transactions, { amount, type: 'debit' }] - }), undefined); - - // Additional business logic... - - // Commit if everything succeeds - tx.commit(); - console.log('Transfer successful'); - - } catch (error) { - // Automatic rollback on error - tx.rollback(); - console.log('Transfer failed, state restored'); - } -} +store.patch({ a: 1 }) +store.patch({ b: 2 }) +store.patch({ c: 3 }) +// β†’ logs "notified" once, on the next microtask + +store.flush() // or deliver it synchronously right now ``` -### Nested Transactions - -```typescript -function complexOperation() { - const outerTx = beginTransaction(store); - - try { - // First operation - outerTx.update(state => ({ ...state, step: 1 }), undefined); - - // Nested transaction for specific part - const innerTx = beginTransaction(store); - try { - innerTx.update(state => ({ ...state, detail: 'nested' }), undefined); - innerTx.commit(); - } catch { - innerTx.rollback(); - throw new Error('Nested operation failed'); - } - - outerTx.commit(); - } catch { - outerTx.rollback(); +### Lifecycle hooks (lazy stores) + +Inspired by Nanostores. Open a resource when the first subscriber arrives and +release it when the last one leaves β€” so an unused store costs nothing: + +```typescript +let socket: WebSocket | null = null + +const messages = createStore<{ items: string[] }>( + { items: [] }, + { + // Debounce teardown so a route change or Suspense retry doesn't + // tear down and immediately rebuild the connection. + unmountDelay: 1000, + + onSubscribe: (store, listenerCount) => { + if (listenerCount !== 1) return + socket = new WebSocket('wss://example.com/feed') + socket.onmessage = e => { + store.set({ items: [...store.read().items, e.data as string] }) + } + }, + + onUnsubscribe: (_store, listenerCount) => { + if (listenerCount !== 0) return + socket?.close() + socket = null + }, } -} +) ``` -## Middleware & Observability +### Destroying a store + +```typescript +store.destroy() +// Clears listeners, fires plugin onDestroy hooks, cancels pending +// notifications, and sets version to -1. + +store.destroyed // true +store.read() // still works β€” reads never throw +store.set({ … }) // throws "Store is destroyed" +``` -Exostate's middleware system allows intercepting operations for logging, metrics, and side effects. +--- -### Basic Middleware +## The query layer + +Everything TanStack Query is loved for, framework-agnostic and in the same +package as your client state. ```typescript -import { withMiddleware } from 'exostate'; +import { QueryClient } from 'exostate' -const loggingMiddleware = { - before: (operation, context) => { - console.log(`Starting ${operation}`, { - version: context.version, - snapshot: context.snapshot, - payload: context.payload - }); - }, - - after: (operation, context) => { - console.log(`Completed ${operation} in ${context.durationMs}ms`, { - version: context.version, - snapshot: context.snapshot - }); - } -}; +const client = new QueryClient() + +const observer = client.watch({ + queryKey: ['user', userId], + queryFn: ({ signal }) => fetch(`/api/users/${userId}`, { signal }).then(r => r.json()), + staleTime: 30_000, + gcTime: 5 * 60_000, + retry: 3, +}) + +observer.subscribe(state => { + // state.data, state.error, state.isLoading, state.isFetching, + // state.isSuccess, state.isError, state.isStale, state.dataUpdatedAt +}) + +await observer.refetch() +observer.destroy() // release the observer; entry becomes GC-eligible +``` -const monitoredStore = withMiddleware(store, [loggingMiddleware]); +`queryKey` is hashed structurally with sorted object keys, so +`['user', { id: 1, tab: 'a' }]` and `['user', { tab: 'a', id: 1 }]` are the +same cache entry. + +### Stale-while-revalidate + +Cached data is served instantly while a refetch runs in the background, so the +UI never blanks out: + +```typescript +const observer = client.watch({ queryKey: ['posts'], queryFn: fetchPosts, staleTime: 60_000 }) + +// Once resolved and later stale: +observer.getState().data // previous data, still on screen +observer.getState().isFetching // true β€” refresh in flight +observer.getState().isLoading // false β€” we have data to show ``` -### Performance Monitoring +`isLoading` means "loading with nothing to show". `isFetching` means "a request +is in flight". Use `isLoading` for spinners and `isFetching` for subtle +refresh indicators. + +### Request deduplication + +Concurrent requests for the same key collapse into a single call: ```typescript -const metricsMiddleware = { - after: (operation, context) => { - // Send metrics to monitoring service - trackMetric({ - operation, - duration: context.durationMs, - stateSize: JSON.stringify(context.snapshot).length - }); - } -}; +await Promise.all([ + client.fetchQuery({ queryKey: ['config'], queryFn }), + client.fetchQuery({ queryKey: ['config'], queryFn }), + client.fetchQuery({ queryKey: ['config'], queryFn }), +]) +// queryFn ran exactly once +``` + +### Retries and backoff + +```typescript +client.watch({ + queryKey: ['flaky'], + queryFn, + retry: 3, // or (attempt, error) => boolean + retryDelay: attempt => Math.min(1000 * 2 ** attempt, 30_000), // the default +}) +``` + +### Invalidation + +```typescript +// Everything under the ['users', …] prefix +await client.invalidateQueries({ queryKey: ['users'] }) + +// Exactly one entry +await client.invalidateQueries({ queryKey: ['users', 7], exact: true }) + +// Everything +await client.invalidateQueries() ``` -### DevTools Integration +Invalidation marks entries stale and refetches those with active observers. +Unobserved entries refetch on next use. + +Other cache controls: ```typescript -import { devtoolsMiddleware, createMemoryConnection } from 'exostate'; +client.getQueryData(['users', 7]) +client.setQueryData(['users', 7], user => ({ ...user, name: 'Ada' })) +client.getQueryState(['users', 7]) +client.cancelQueries({ queryKey: ['users'] }) +client.removeQueries({ queryKey: ['users'] }) +await client.refetchQueries({ queryKey: ['users'] }) +await client.prefetchQuery({ queryKey: ['users', 7], queryFn }) +``` + +### Mutations and optimistic updates -const connection = createMemoryConnection(); -const devToolsStore = withMiddleware(store, [ - devtoolsMiddleware(connection, 'my-app') -]); +`onMutate` runs before the request and its return value is handed to `onError` +β€” which is exactly what you need to roll back: -// Connect to devtools UI -connection.connect(devToolsUI); +```typescript +import { createMutation } from 'exostate' + +const addTodo = createMutation({ + mutationFn: text => api.addTodo(text), + + onMutate: text => { + const previous = client.getQueryData(['todos']) + client.setQueryData(['todos'], old => [ + ...(old ?? []), + { id: 'temp', text }, + ]) + return previous // becomes the rollback context + }, + + onError: (_error, _text, previous) => { + client.setQueryData(['todos'], previous ?? []) + }, + + onSettled: () => client.invalidateQueries({ queryKey: ['todos'] }), +}) + +await addTodo.mutate('Buy milk') +addTodo.getState() // { data, error, status, isLoading, isSuccess, isError, variables } ``` -## Store Composition +### Garbage collection -Combine multiple stores into a unified state tree. +When the last observer of a query leaves, its in-flight request is cancelled +and a `gcTime` countdown starts (default 5 minutes). If nobody observes it +again in that window, the entry is disposed and its memory released. Set +`gcTime: Infinity` to keep an entry forever. -### Basic Composition +### Server-side rendering + +The query core has no `window` or `document` access β€” focus and reconnect +listeners are feature-detected β€” so it runs unchanged on the server. ```typescript -import { combineStores } from 'exostate'; +// ── Server ── +const client = new QueryClient() +await client.prefetchQuery({ queryKey: ['user', id], queryFn }) +const dehydrated = client.dehydrate() // JSON-serializable + +res.send(``) + +// ── Client ── +const client = new QueryClient() +client.hydrate(window.__STATE__) +// Data is on screen immediately. staleTime is measured from the server's +// fetch time, so no refetch waterfall on first paint. +``` -const authStore = createStore({ user: null, token: null }); -const uiStore = createStore({ theme: 'dark', sidebarOpen: false }); -const dataStore = createStore({ items: [], loading: false }); +Plain store state has its own SSR pair: -const rootStore = combineStores({ - auth: authStore, - ui: uiStore, - data: dataStore -}); +```typescript +import { dehydrate, rehydrate } from 'exostate' -// Access combined state -const state = rootStore.read(); -console.log(state.auth.user, state.ui.theme, state.data.items); +const json = dehydrate(store) // server +rehydrate(store, json) // client ``` -### React Composition +--- + +## Framework adapters + +### React ```tsx -import { useStores } from 'exostate/react'; - -function AppLayout() { - const { auth, ui, data } = useStores({ - auth: authStore, - ui: uiStore, - data: dataStore - }); - - return ( -
- - -
- ); +import { createStore } from 'exostate' +import { useStore, useSelector, useStores } from 'exostate/react' + +const counter = createStore({ count: 0, label: 'hits' }) + +function Counter() { + const count = useSelector(counter, s => s.count) + return +} + +function Whole() { + const state = useStore(counter) // whole store + return

{state.label}: {state.count}

+} + +function Multi() { + const { counter: c, user } = useStores({ counter, user: userStore }) + return

{user.name} β€” {c.count}

} ``` -### Selective Subscription +**Inline object selectors are safe.** The selector result is memoized against +the store version, so this does not trip React's +`getSnapshot should be cached to avoid an infinite loop` error: -```typescript -// Only subscribe to auth changes -rootStore.subscribe( - state => state.auth, - authState => console.log('Auth changed:', authState) -); +```tsx +const { a, b } = useSelector(store, s => ({ a: s.a, b: s.b })) ``` -## Validation & Error Handling +Add `shallow` when you also want to skip re-renders for unrelated changes: -### Schema Validation with Zod +```tsx +import { shallow } from 'exostate' -```typescript -import { z } from 'zod'; -import { fromZod } from 'exostate'; +const slice = useSelector(store, s => ({ a: s.a, b: s.b }), shallow) +``` -const UserSchema = z.object({ - id: z.string().uuid(), - email: z.string().email(), - role: z.enum(['admin', 'user', 'guest']) -}); +Query hooks: -const userValidator = fromZod(UserSchema); +```tsx +import { QueryClient } from 'exostate' +import { QueryClientProvider, useQuery, useMutation } from 'exostate/react/query' -// Validate state updates -function updateUser(newUser: unknown) { - if (userValidator.validate(newUser)) { - userStore.set(newUser); - } else { - throw new Error('Invalid user data'); - } +const client = new QueryClient() + +function App() { + return ( + + + + ) +} + +function Profile({ id }: { id: string }) { + const { data, isLoading, isError, error, refetch } = useQuery({ + queryKey: ['user', id], + queryFn: ({ signal }) => fetch(`/api/users/${id}`, { signal }).then(r => r.json()), + staleTime: 30_000, + }) + + if (isLoading) return + if (isError) return

{error?.message}

+ return

refetch()}>{data?.name}

+} + +function AddTodo() { + const { mutate, isLoading } = useMutation({ + mutationFn: text => api.addTodo(text), + onSettled: () => client.invalidateQueries({ queryKey: ['todos'] }), + }) + return } ``` -### Safe Error Handling +`mutate` fires and forgets; `mutateAsync` returns the promise. -```typescript -import { createError, isSafeError, toSafeError } from 'exostate'; +### Vue -// Create typed errors -const AppError = createError('app_error', 'Application error'); -const ValidationError = createError('validation_error', 'Validation failed'); +```vue + + + +``` + +Uses `shallowRef` (state is already immutable, so deep reactivity would be +wasted work) and `onScopeDispose`, so it cleans up inside components *and* +standalone effect scopes. + +### Svelte + +```svelte + + + +``` + +Implements Svelte's readable-store contract, so `$store` auto-subscription +works and unsubscription is automatic. + +### Solid + +```tsx +import { createStore } from 'exostate' +import { useExostore, useExoselector } from 'exostate/solid' + +const counter = createStore({ count: 0 }) + +function Counter() { + const count = useExoselector(counter, s => s.count) + return } ``` -### Error Recovery Policies +### Vanilla JavaScript + +No build step, no framework, no adapter: + +```html + +``` + +The query layer works the same way β€” it is plain JavaScript with no framework +coupling. + +--- + +## Advanced features + +### Plugins + +Plugins observe and can transform every commit: ```typescript -const errorPolicy = { - map: (code: string, error: SafeError) => { - switch (code) { - case 'network_error': - return createError('retry_later', 'Please try again later'); - case 'validation_error': - return createError('invalid_input', 'Please check your input'); - default: - return error; - } - } -}; +import { createStore, logger, freeze } from 'exostate' -// Apply policy to errors -const userFriendlyError = applyPolicy(rawError, errorPolicy); +const store = createStore({ count: 0 }) + +store.use(logger({ name: 'MyApp', collapsed: true })) +store.use(freeze()) // deep-freeze state in development to catch mutations + +const detach = store.use({ + name: 'analytics', + onInit: s => { + const timer = setInterval(() => report(s.read()), 10_000) + return () => clearInterval(timer) // cleanup on detach + }, + onBeforeUpdate: (prev, next) => { + // Return a value to replace what gets committed + return { ...next, count: Math.min(next.count, 100) } + }, + onAfterUpdate: (prev, next) => track('state_changed', { prev, next }), + onSubscribe: count => console.log('listeners:', count), + onUnsubscribe: count => console.log('listeners:', count), + onDestroy: () => flush(), +}) + +detach() ``` -## Server-Side Rendering (SSR) +### Middleware -### Dehydration/Rehydration +Operation-level instrumentation, including timings: ```typescript -// Server-side -import { dehydrate } from 'exostate'; +import { withMiddleware } from 'exostate' -function renderApp() { - const store = createStore(initialState); - // ... populate store - - const html = renderToString(); - const serializedState = dehydrate(store); - - return { - html, - state: serializedState - }; -} +const instrumented = withMiddleware(store, [ + { + before: (op, ctx) => console.log('β†’', op, ctx.version), + after: (op, ctx) => console.log('←', op, `${ctx.durationMs}ms`), + }, +]) ``` +### Transactions + +Stage several changes and commit or discard them atomically: + ```typescript -// Client-side -import { rehydrate } from 'exostate'; +import { beginTransaction } from 'exostate' -function hydrateApp() { - const store = createStore(initialState); - - // Rehydrate from server state - if (window.__INITIAL_STATE__) { - rehydrate(store, window.__INITIAL_STATE__); - } - - ReactDOM.hydrate(, container); +const tx = beginTransaction(store) +tx.apply(addItem, { id: 'a' }) +tx.apply(applyDiscount, 0.2) +tx.read() // staged value β€” the store is untouched so far + +if (isValid(tx.read())) { + tx.commit() // one notification for the whole transaction +} else { + tx.rollback() } + +tx.commit() // throws β€” a transaction is sealed after commit or rollback ``` -### Custom Serialization +### History and time travel ```typescript -import { createSerializer } from 'exostate'; +import { createHistory } from 'exostate' -const customSerializer = createSerializer(2, { - validate: (x): x is AppState => - typeof x?.version === 'number' && Array.isArray(x?.items), - - migrations: { - 1: (v1Data: any) => ({ - version: 2, - items: v1Data.products || [] - }) - } -}); +const history = createHistory(store, { limit: 50 }) +history.attach() -const serialized = dehydrate(store, customSerializer); -``` +store.patch({ count: 1 }) +store.patch({ count: 2 }) -## Comparison with Other Libraries +history.undo() // back to { count: 1 } +history.redo() // forward to { count: 2 } +history.jumpTo(0) // straight to any recorded entry +history.canUndo() // boolean +history.entries() // recorded snapshots +history.clear() +history.detach() +``` -### vs Zustand +### Persistence -| Feature | Exostate | Zustand | -|---------|----------|---------| -| **Transactions** | βœ… Full commit/rollback | ❌ No built-in support | -| **Type Safety** | βœ… Strict generics, no `any` | ⚠️ Optional, can use `any` | -| **Middleware** | βœ… Full lifecycle hooks | βœ… Basic middleware | -| **SSR** | βœ… First-class support | βœ… Good support | -| **Complexity** | 🟑 Medium (more features) | 🟒 Simple | -| **Use Case** | Complex apps, mission-critical | Simple to medium apps | +```typescript +import { persistLocal, persistIndexedDB } from 'exostate' + +// localStorage / sessionStorage / any StorageLike +const local = persistLocal(store, 'app-state', localStorage) +local.detach() + +// IndexedDB β€” async, no 5 MB cap, survives Date/Map/Set round trips +const idb = await persistIndexedDB(store, { + dbName: 'my-app', + key: 'main', + writeDebounceMs: 50, +}) +idb.detach() // flushes anything still queued +``` -### vs Redux +Filesystem persistence lives in the Node entry point: -| Feature | Exostate | Redux | -|---------|----------|-------| -| **Boilerplate** | 🟒 Minimal | πŸ”΄ High | -| **Type Safety** | βœ… Excellent | 🟑 Good (with RTK) | -| **Transactions** | βœ… Native support | ❌ Requires middleware | -| **Performance** | βœ… Optimized updates | 🟑 Good (with selectors) | -| **Learning Curve** | 🟑 Moderate | πŸ”΄ Steep | -| **Bundle Size** | 🟒 Small (~3kB) | πŸ”΄ Large (~10kB+) | +```typescript +import { persistFs } from 'exostate/node' -### Performance Benchmarks +const fsPersist = await persistFs(store, './state/app.json') +``` -Benchmarks run on Node.js (higher is better). Exostate leads in raw throughput and with subscribers when reducers use `Object.assign` for updates. +Writes are serialized through a single-slot queue, so a burst of updates +collapses to one pending write and can never tear the file. -| Library | Scenario | Ops/sec | -|---------|----------|-------:| -| **Exostate** | update (`Object.assign`) | **~19,800,000** | -| **Zustand** | setState | ~14,800,000 | -| **Exostate** | update (`{...spread}`) | ~8,000,000 | -| **Redux** | dispatch | ~5,500,000 | -| **Exostate** | sub (`Object.assign`) | ~17,400,000 | -| **Zustand** | sub | ~15,000,000 | -| **Exostate** | sub (`{...spread}`) | ~7,300,000 | -| **Redux** | sub | ~5,000,000 | +### Event sourcing -Quickly reproduce locally: -- `npm install` -- `npm run build` -- `node benchmarks/comparison.mjs` +An append-only log for audit trails and replay: -Notes: -- Using `Object.assign` in reducers minimizes allocation and consistently delivers the highest throughput. -- Exostate’s subscriber path remains predictable and fast due to a Set-based listener model with copy-on-write safety. +```typescript +import { createEventSource } from 'exostate' -### When to Choose Exostate +const events = createEventSource(store, { maxEvents: 1000 }) -- **βœ… Complex state logic** with multiple dependent updates -- **βœ… Mission-critical applications** where data consistency is vital -- **βœ… TypeScript-heavy projects** wanting maximum type safety -- **βœ… Applications needing** audit logging or operation tracking -- **βœ… Teams familiar** with backend development patterns +events.dispatch('ITEM_ADDED', { id: 1, name: 'Widget' }, (prev, payload) => ({ + ...prev, + items: [...prev.items, payload], +})) -## API Reference +events.events() // [{ type, payload, timestamp, version }] +events.eventsSince(5) +events.onEvent(e => audit(e)) +events.replay(initialState) +``` -### Core API +### Store factories -#### `createStore(initial: T): Store` -Creates a new store with initial state. +Isolated stores per widget, modal, or tenant: -#### `Store` Interface ```typescript -interface Store { - read(): T; - snapshot(): DeepReadonly; - update

(reducer: Reducer, payload: P): T; - set(next: T): T; - compute(fn: Compute): T; - batch(apply: (apply: Function) => void): T; - subscribe(selector: Selector, subscriber: Subscriber, options?: SubscribeOptions): Unsubscribe; -} +import { storeFactory, cachedStoreFactory } from 'exostate' + +const createWidget = storeFactory((id: string) => ({ id, items: [] })) +const w1 = createWidget('w1') // independent instances +const w2 = createWidget('w2') + +// Same key returns the same instance +const userStores = cachedStoreFactory((userId: string) => ({ id: userId, name: '' })) +userStores.get('u1') === userStores.get('u1') // true +userStores.delete('u1') ``` -### Transaction API +### Redux DevTools + +```typescript +import { connectReduxDevTools } from 'exostate' + +const disconnect = connectReduxDevTools(store, { name: 'My App' }) +// Time travel from the extension writes back into the store. +``` -#### `beginTransaction(store: Store): Transaction` -Starts a new transaction. +### Schema validation -#### `Transaction` Interface ```typescript -interface Transaction { - read(): T; - apply

(reducer: Reducer, payload: P): T; - compute(fn: Compute): T; - set(next: T): T; - commit(): T; - rollback(): T; -} +import { z } from 'zod' +import { fromZod, fromPredicate } from 'exostate' + +const schema = fromZod(z.object({ count: z.number() })) +const state = schema.parse(untrustedInput) + +const isUser = (x: unknown): x is User => typeof x === 'object' && x !== null && 'id' in x +const userSchema = fromPredicate(isUser) ``` -### React API +### Versioned serialization -#### `useStore(store: Store): DeepReadonly` -Hook to access entire store state. +Migrate persisted state across schema versions: -#### `useSelector(store: Store, selector: Selector, eq?: Equality): R` -Hook to access derived state with optional equality check. +```typescript +import { createSerializer } from 'exostate' -#### `useStores(stores: { [K in keyof TShape]: Store }): { [K in keyof TShape]: DeepReadonly }` -Hook to combine multiple stores. +const serializer = createSerializer(3, { + validate: (x): x is StateV3 => typeof x === 'object' && x !== null, + migrations: { + 1: (v1: any) => ({ ...v1, theme: 'light' }), // v1 β†’ v2 + 2: (v2: any) => ({ ...v2, locale: 'en' }), // v2 β†’ v3 + }, +}) + +persistLocal(store, 'app', localStorage, { + encode: serializer.encode, + decode: serializer.decode, +}) +``` -### Utility API +Decoding a payload from a *newer* version throws rather than silently +corrupting state. -#### `combineStores(stores: TShape): Combined` -Combines multiple stores into one. +--- -#### `withMiddleware(store: Store, middlewares: Middleware[]): Store` -Wraps store with middleware. +## Recipes -#### `dehydrate(store: Store, serializer?: Serializer): string` -Serializes store state for SSR. +**Error handling with typed errors** -#### `rehydrate(store: Store, raw: string, serializer?: Serializer): T` -Restores store state from serialized data. +```typescript +import { createError, toSafeError, isSafeError } from 'exostate' -### Error API +const err = createError('NOT_FOUND', 'User does not exist', { id: 42 }) +err.name // 'SafeError' +err.code // 'NOT_FOUND' +err.details // { id: 42 } -#### `createError(code: string, message: string, details?: unknown): SafeError` -Creates a typed error instance. +const safe = toSafeError(unknownThrowable, 'FETCH_FAILED') +``` -#### `isSafeError(error: unknown): error is SafeError` -Type guard for safe errors. +**Async actions on a plain store** (when you want loading flags without the +full query cache) -#### `toSafeError(error: unknown, fallbackCode?: string): SafeError` -Converts unknown errors to safe errors. +```typescript +import { asyncAction } from 'exostate' + +const load = asyncAction( + userStore, + async (_store, id: string) => ({ user: await api.getUser(id) }), + { + onStart: () => ({ loading: true, error: null }), + onError: err => ({ loading: false, error: err.message }), + retry: 3, + retryDelay: attempt => 2 ** attempt * 100, + latestOnly: true, // default β€” a slow earlier call can't clobber a newer one + } +) -## Examples +const promise = load('user-42') +promise.abort() // cancels only this invocation +``` -### Real-world Example: E-commerce Cart +**Co-locating actions with state** ```typescript -const cartStore = createStore({ - items: [], - discount: 0, - tax: 0, - total: 0 -}); +import { defineStore } from 'exostate' -function addToCart(product: Product, quantity: number) { - const tx = beginTransaction(cartStore); - - try { - // Add item - tx.update(state => ({ - ...state, - items: [...state.items, { product, quantity }] - }), undefined); - - // Recalculate totals - tx.update(calculateTotals, undefined); - - // Validate business rules - if (tx.read().items.length > 10) { - throw new Error('Cart limit exceeded'); - } - - tx.commit(); - } catch (error) { - tx.rollback(); - throw error; - } -} +const counter = defineStore((set, get) => ({ + count: 0, + increment: () => set(s => ({ ...s, count: s.count + 1 })), + reset: () => set({ count: 0 }), +})) + +counter.read().increment() ``` -### Real-world Example: User Authentication Flow - -```typescript -const authStore = createStore({ - user: null, - token: null, - loading: false, - error: null -}); - -async function login(credentials: LoginData) { - const tx = beginTransaction(authStore); - - try { - tx.set({ ...tx.read(), loading: true, error: null }); - - const response = await api.login(credentials); - - tx.set({ - user: response.user, - token: response.token, - loading: false, - error: null - }); - - tx.commit(); - return response; - } catch (error) { - tx.set({ - ...tx.read(), - loading: false, - error: toSafeError(error, 'login_failed') - }); - tx.commit(); - throw error; - } -} +--- + +## API reference + +### Core + +| Export | Description | +| --- | --- | +| `createStore(initial, options?)` | Create a store | +| `createState(initial)` | Immutable read-only state container | +| `defineStore(creator)` | Creator pattern with co-located actions | +| `storeFactory(init)` / `cachedStoreFactory(init)` | Scoped store instances | +| `combineStores(stores)` | Compose multiple stores into one view | +| `computed(store, selector)` | Version-cached derived value | +| `derive(store, selector)` | Uncached derived value | +| `shallow` / `deepEqual` | Comparators for selectors | + +### Store methods + +`read` Β· `snapshot` Β· `version` Β· `patch` Β· `set` Β· `update` Β· `compute` Β· +`batch` Β· `effect` Β· `subscribe` Β· `use` Β· `plugins` Β· `flush` Β· `destroy` Β· +`destroyed` + +### Query + +| Export | Description | +| --- | --- | +| `QueryClient` | Cache with SWR, dedup, retries, GC, SSR | +| `createMutation(options)` | Mutation with optimistic-update support | +| `hashQueryKey(key)` | Structural key hashing | + +`QueryClient` methods: `watch` Β· `fetchQuery` Β· `prefetchQuery` Β· +`getQueryData` Β· `setQueryData` Β· `getQueryState` Β· `invalidateQueries` Β· +`refetchQueries` Β· `cancelQueries` Β· `removeQueries` Β· `dehydrate` Β· +`hydrate` Β· `size` Β· `clear` + +### Persistence, history, and integrity + +`persistLocal` Β· `persistIndexedDB` Β· `persistFs` (from `exostate/node`) Β· +`createHistory` Β· `beginTransaction` Β· `createEventSource` Β· +`createSerializer` Β· `dehydrate` Β· `rehydrate` + +### Plugins and observability + +`withMiddleware` Β· `logger` Β· `freeze` Β· `registerPlugin` Β· `getPlugins` Β· +`destroyPlugins` Β· `devtoolsMiddleware` Β· `connectReduxDevTools` + +### Errors and validation + +`SafeError` Β· `createError` Β· `isSafeError` Β· `toSafeError` Β· `applyPolicy` Β· +`fromZod` Β· `fromPredicate` + +--- + +## FAQ + +**Does it work without React?** +Yes. The core has zero framework imports. Vue, Svelte, and Solid have first-class +adapters, and plain JavaScript needs no adapter at all. + +**Does the query layer work on the server?** +Yes. There is no `window`/`document` access, and `dehydrate`/`hydrate` move the +cache across the server-client boundary with fetch timestamps preserved. + +**Can I use it with Next.js / Nuxt / SvelteKit?** +Yes. Create the `QueryClient` per request on the server, prefetch, `dehydrate`, +then `hydrate` on the client. + +**Is `patch` a deep merge?** +No β€” shallow, like Zustand. Nested objects are replaced. Shallow is predictable; +deep merging surprises people about arrays. + +**Do I need `useShallow`-style wrappers in React?** +No. Inline object selectors are memoized against the store version, so they +cannot cause infinite loops. Pass `shallow` as the comparator when you also want +to skip re-renders for unrelated changes. + +**Why is `version` `-1` after `destroy()`?** +It is a sentinel marking the store as destroyed. Reads still work; writes throw. + +**Does it bring dependencies into my bundle?** +None. Exostate ships zero runtime dependencies; framework packages are optional +peers you already have. + +**Can I migrate gradually from Zustand or TanStack Query?** +Yes. They are independent β€” adopt the query layer first, or the store first, and +run both side by side during the transition. + +--- + +## Contributing + +Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, +project layout, design constraints, and the commit convention. + +```bash +git clone https://github.com/webcoderspeed/exostate.git +cd exostate +npm install +npm run validate ``` + +Please also read the [Code of Conduct](CODE_OF_CONDUCT.md). Security issues +should be reported privately β€” see [SECURITY.md](SECURITY.md). + +--- + +## License + +[MIT](LICENSE) Β© [Sanjeev Sharma](https://github.com/webcoderspeed) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8d7e76b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,40 @@ +# Security Policy + +## Supported versions + +| Version | Supported | +| ------- | --------- | +| 1.x | βœ… | + +## Reporting a vulnerability + +Please **do not** open a public issue for security vulnerabilities. + +Report them privately through +[GitHub Security Advisories](https://github.com/webcoderspeed/exostate/security/advisories/new), +or by email to . + +Include, where possible: + +- A description of the vulnerability and its impact +- Steps or a proof-of-concept to reproduce it +- The affected version(s) +- Any suggested remediation + +You can expect an initial response within 72 hours, and a fix or mitigation +plan communicated within 7 days for confirmed issues. + +## Scope notes + +Exostate is a client-and-server state container with no runtime dependencies. +Areas most relevant to security reports: + +- **Persistence adapters** (`persistLocal`, `persistIndexedDB`, `persistFs`) β€” + these serialize application state to storage. Do not place secrets in + persisted state. +- **`serialize.ts` migrations** β€” decoding untrusted payloads. +- **`devtools-redux.ts`** β€” accepts state pushed from the browser extension + during time travel. Intended for development only. +- **SSR hydration** (`rehydrate`, `QueryClient.hydrate`) β€” treats the payload + as trusted; never hydrate from user-controlled input without validating it + first (see `createSerializer` and `schema.ts`). diff --git a/eslint.config.cjs b/eslint.config.cjs index 5a0ee29..f8ee128 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -15,6 +15,23 @@ module.exports = [ parserOptions: { project: './tsconfig.json', tsconfigRootDir: __dirname + }, + globals: { + console: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + queueMicrotask: 'readonly', + AbortController: 'readonly', + AbortSignal: 'readonly', + globalThis: 'readonly', + indexedDB: 'readonly', + IDBDatabase: 'readonly', + IDBRequest: 'readonly', + window: 'readonly', + document: 'readonly', + navigator: 'readonly' } }, plugins: { diff --git a/package-lock.json b/package-lock.json index da8736c..0b2222d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,21 @@ { "name": "exostate", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "exostate", - "version": "0.1.0", + "version": "1.0.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webcoderspeed" + } + ], "license": "MIT", - "dependencies": { - "tinybench": "^6.0.0" - }, "devDependencies": { + "@size-limit/file": "^13.0.3", "@testing-library/react": "^16.0.0", "@types/node": "^25.0.3", "@types/react": "^18.3.12", @@ -23,7 +27,12 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "redux": "^5.0.1", + "size-limit": "^12.1.0", + "solid-js": "^1.9.14", + "svelte": "^5.56.8", + "tinybench": "^6.0.0", "vitest": "^4.0.16", + "vue": "^3.5.41", "zod": "^4.2.1", "zustand": "^5.0.9" }, @@ -31,7 +40,24 @@ "node": ">=18" }, "peerDependencies": { - "react": ">=18" + "react": ">=18", + "solid-js": ">=1", + "svelte": ">=4", + "vue": ">=3" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } } }, "node_modules/@acemir/cssom": { @@ -92,17 +118,42 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", @@ -113,6 +164,20 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -931,6 +996,38 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -938,6 +1035,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.54.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", @@ -1246,6 +1354,19 @@ "win32" ] }, + "node_modules/@size-limit/file": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/@size-limit/file/-/file-13.0.3.tgz", + "integrity": "sha512-PWTITIXH5p9aGIf6qq2Fruihn/b9nBQyfkyoAyb6DzFJgS1Ek9MSPJYKxKFLO8jdo0aqSgBPd3sevbS6PyBiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "size-limit": "13.0.3" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1253,6 +1374,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.12.tgz", + "integrity": "sha512-J1jNYG23QWd67UfrQSFHtjhV37r9mVi0gdc12A3MWPldOjRK35Xk+um+qACPVjgw3AleiqoyEAhok8Wm3q46NA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -1370,6 +1501,13 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.50.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.1.tgz", @@ -1714,6 +1852,140 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vue/compiler-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-core/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "dev": true, + "license": "MIT" + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1819,6 +2091,16 @@ "node": ">=12" } }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1846,6 +2128,16 @@ "balanced-match": "^1.0.0" } }, + "node_modules/bytes-iec": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bytes-iec/-/bytes-iec-3.1.1.tgz", + "integrity": "sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1883,6 +2175,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2018,6 +2320,13 @@ "node": ">=6" } }, + "node_modules/devalue": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz", + "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -2238,6 +2547,13 @@ "node": "*" } }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -2282,6 +2598,24 @@ "node": ">=0.10" } }, + "node_modules/esrap": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.1.tgz", + "integrity": "sha512-MXjVkrBAjuwIZ8xq9+a1MOOnLIwANZOx/4gtdWHfSJtXOrUh2QQK2I5mMC3urLli559jTlq2j+kSqht80uRgog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", @@ -2620,6 +2954,16 @@ "dev": true, "license": "MIT" }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2732,6 +3076,26 @@ "node": ">= 0.8.0" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2830,9 +3194,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "dev": true, "funding": [ { @@ -2848,6 +3212,16 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanospinner": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/nanospinner/-/nanospinner-1.2.2.tgz", + "integrity": "sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2977,9 +3351,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -2990,9 +3364,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "dev": true, "funding": [ { @@ -3010,7 +3384,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3232,6 +3606,29 @@ "node": ">=10" } }, + "node_modules/seroval": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.6.tgz", + "integrity": "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.6.tgz", + "integrity": "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3262,6 +3659,46 @@ "dev": true, "license": "ISC" }, + "node_modules/size-limit": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/size-limit/-/size-limit-12.1.0.tgz", + "integrity": "sha512-VnDS2fycANrJFVPQwjaD+h+hkISY7EB3LsPsYWje4lBCjQwwsZLxjwwRwVJKHrcj2ZqyG+DdXykWm9mbZklZrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes-iec": "^3.1.1", + "lilconfig": "^3.1.3", + "nanospinner": "^1.2.2", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.16" + }, + "bin": { + "size-limit": "bin.js" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "jiti": "^2.0.0" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/solid-js": { + "version": "1.9.14", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.14.tgz", + "integrity": "sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.1.0", + "seroval": "~1.5.4", + "seroval-plugins": "~1.5.4" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3312,6 +3749,44 @@ "node": ">=8" } }, + "node_modules/svelte": { + "version": "5.56.8", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", + "integrity": "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte/node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -3323,6 +3798,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.0.0.tgz", "integrity": "sha512-BWlWpVbbZXaYjRV0twGLNQO00Zj4HA/sjLOQP2IvzQqGwRGp+2kh7UU3ijyJ3ywFRogYDRbiHDMrUOfaMnN56g==", + "dev": true, "license": "MIT", "engines": { "node": ">=20.0.0" @@ -3339,14 +3815,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -3629,6 +4105,28 @@ "dev": true, "license": "MIT" }, + "node_modules/vue": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -3784,6 +4282,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/zod": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", diff --git a/package.json b/package.json index 072b14f..9d3cfda 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "exostate", "version": "1.0.0", - "description": "Next-generation, fully type-safe state management for frontend and backend.", + "description": "Type-safe state management for React, Vue, Svelte, Solid and vanilla JS β€” with a built-in async query cache (stale-while-revalidate, request deduplication, retries, SSR hydration), plugins, time-travel history, persistence and transactions. A Zustand + TanStack Query alternative in one dependency-free package.", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", @@ -14,50 +14,178 @@ "./react": { "types": "./dist/react/index.d.ts", "default": "./dist/react/index.js" - } + }, + "./react/query": { + "types": "./dist/react/query.d.ts", + "default": "./dist/react/query.js" + }, + "./node": { + "types": "./dist/node/index.d.ts", + "default": "./dist/node/index.js" + }, + "./vue": { + "types": "./dist/vue/index.d.ts", + "default": "./dist/vue/index.js" + }, + "./svelte": { + "types": "./dist/svelte/index.d.ts", + "default": "./dist/svelte/index.js" + }, + "./solid": { + "types": "./dist/solid/index.d.ts", + "default": "./dist/solid/index.js" + }, + "./package.json": "./package.json" }, "files": [ - "dist" + "dist", + "README.md", + "LICENSE", + "CHANGELOG.md" ], "scripts": { "build": "tsc -p ./tsconfig.json", - "typecheck": "tsc -p ./tsconfig.json --noEmit", + "build:watch": "tsc -p ./tsconfig.json --watch", "clean": "rm -rf dist", - "prepublishOnly": "npm run clean && npm run build && npm run typecheck", + "typecheck": "tsc -p ./tsconfig.json --noEmit", "lint": "eslint . --ext .ts --max-warnings=0", + "lint:fix": "eslint . --ext .ts --fix", + "lint:check": "eslint . --ext .ts --max-warnings=0", "test": "vitest --run --environment jsdom", - "bench": "node benchmarks/index.mjs" + "test:watch": "vitest --environment jsdom", + "test:coverage": "vitest --run --environment jsdom --coverage", + "test:ci": "vitest --run --environment jsdom --coverage", + "check": "npm run typecheck && npm run lint:check", + "ci": "npm run typecheck && npm run lint:check && npm run test:ci", + "validate": "npm run clean && npm run build && npm run typecheck && npm run lint:check && npm run test", + "size": "npm run build && node scripts/bundle-for-size.mjs && size-limit", + "bench": "node benchmarks/index.mjs", + "bench:compare": "npm run build && node benchmarks/comparison.mjs", + "prepublishOnly": "npm run validate" }, "sideEffects": false, "keywords": [ "state-management", + "state", + "store", "typescript", - "backend", - "frontend", + "typescript-state-management", + "react", + "react-state-management", + "react-store", + "react-hooks", + "usesyncexternalstore", + "vue", + "vue-state-management", + "svelte", + "svelte-store", + "solid", + "solid-js", + "vanilla-js", + "framework-agnostic", + "zustand-alternative", + "redux-alternative", + "jotai-alternative", + "nanostores-alternative", + "valtio-alternative", + "mobx-alternative", + "tanstack-query-alternative", + "react-query-alternative", + "swr-alternative", + "query", + "query-cache", + "data-fetching", + "async-state", + "server-state", + "stale-while-revalidate", + "request-deduplication", + "optimistic-updates", + "mutations", + "refetch", + "polling", + "infinite-cache", + "garbage-collection", + "ssr", + "server-side-rendering", + "hydration", + "dehydrate", + "nextjs", + "nuxt", + "sveltekit", "immutable", - "generic" + "immutability", + "selectors", + "derived-state", + "computed", + "reactive", + "reactivity", + "middleware", + "plugins", + "devtools", + "redux-devtools", + "time-travel", + "undo-redo", + "history", + "persistence", + "localstorage", + "indexeddb", + "transactions", + "event-sourcing", + "observable", + "atomic-state", + "fine-grained-reactivity", + "zero-dependencies", + "tree-shakeable", + "tiny", + "performance", + "backend", + "nodejs" ], - "author": "webcoderspeed", + "author": { + "name": "Sanjeev Sharma", + "email": "webcoderspeed@gmail.com", + "url": "https://github.com/webcoderspeed" + }, "repository": { "type": "git", - "url": "https://github.com/webcoderspeed/exostate.git" + "url": "git+https://github.com/webcoderspeed/exostate.git" }, "bugs": { "url": "https://github.com/webcoderspeed/exostate/issues" }, "homepage": "https://github.com/webcoderspeed/exostate#readme", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/webcoderspeed" - }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webcoderspeed" + } + ], "license": "MIT", "engines": { "node": ">=18" }, "peerDependencies": { - "react": ">=18" + "react": ">=18", + "solid-js": ">=1", + "svelte": ">=4", + "vue": ">=3" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "vue": { + "optional": true + }, + "svelte": { + "optional": true + }, + "solid-js": { + "optional": true + } }, "devDependencies": { + "@size-limit/file": "^13.0.3", "@testing-library/react": "^16.0.0", "@types/node": "^25.0.3", "@types/react": "^18.3.12", @@ -69,9 +197,55 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "redux": "^5.0.1", + "size-limit": "^12.1.0", + "solid-js": "^1.9.14", + "svelte": "^5.56.8", + "tinybench": "^6.0.0", "vitest": "^4.0.16", + "vue": "^3.5.41", "zod": "^4.2.1", - "zustand": "^5.0.9", - "tinybench": "^6.0.0" - } + "zustand": "^5.0.9" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "size-limit": [ + { + "name": "createStore only", + "path": ".size/store-only.js", + "limit": "2 KB", + "gzip": true + }, + { + "name": "store + computed + persist + history", + "path": ".size/store-plus.js", + "limit": "3 KB", + "gzip": true + }, + { + "name": "query layer", + "path": ".size/query.js", + "limit": "7 KB", + "gzip": true + }, + { + "name": "react adapter", + "path": ".size/react.js", + "limit": "3 KB", + "gzip": true + }, + { + "name": "react query hooks", + "path": ".size/react-query.js", + "limit": "8 KB", + "gzip": true + }, + { + "name": "entire library", + "path": ".size/everything.js", + "limit": "16 KB", + "gzip": true + } + ] } diff --git a/scripts/bundle-for-size.mjs b/scripts/bundle-for-size.mjs new file mode 100644 index 0000000..1c17cdc --- /dev/null +++ b/scripts/bundle-for-size.mjs @@ -0,0 +1,54 @@ +/** + * Produces real, tree-shaken bundles for size measurement. + * + * Measuring `dist/index.js` directly is meaningless: it is a barrel of + * re-exports, so a file-size check reports a few hundred bytes while a real + * consumer pulls in far more. These scenarios bundle the way an application + * actually would, so `npm run size` reports numbers users will really see. + */ +import { build } from 'esbuild' +import { mkdir, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' + +const OUT = '.size' + +// The generated entry lives in .size/src/, so a './dist/…' specifier would +// resolve relative to that folder. Absolute paths keep it unambiguous. +const core = path.resolve('dist/index.js') +const react = path.resolve('dist/react/index.js') +const reactQuery = path.resolve('dist/react/query.js') + +const scenarios = [ + { file: 'store-only.js', code: `export { createStore } from ${JSON.stringify(core)}` }, + { + file: 'store-plus.js', + code: `export { createStore, computed, persistLocal, shallow, createHistory } from ${JSON.stringify(core)}`, + }, + { file: 'query.js', code: `export { QueryClient, createMutation } from ${JSON.stringify(core)}` }, + { file: 'react.js', code: `export * from ${JSON.stringify(react)}` }, + { file: 'react-query.js', code: `export * from ${JSON.stringify(reactQuery)}` }, + { file: 'everything.js', code: `export * from ${JSON.stringify(core)}` }, +] + +await rm(OUT, { recursive: true, force: true }) +await mkdir(path.join(OUT, 'src'), { recursive: true }) + +for (const { file, code } of scenarios) { + const entry = path.join(OUT, 'src', file) + await writeFile(entry, code) + await build({ + entryPoints: [entry], + outfile: path.join(OUT, file), + bundle: true, + minify: true, + format: 'esm', + platform: 'browser', + target: 'es2020', + // Framework packages are peer dependencies β€” a consumer already ships + // them, so counting them here would misrepresent the adapter's cost. + external: ['react', 'vue', 'svelte', 'solid-js'], + logLevel: 'error', + }) +} + +console.log(`Built ${scenarios.length} size scenarios into ${OUT}/`) diff --git a/src/async-action.ts b/src/async-action.ts new file mode 100644 index 0000000..d4a8849 --- /dev/null +++ b/src/async-action.ts @@ -0,0 +1,81 @@ +import { Store } from './store.js' +import { SafeError, toSafeError } from './errors.js' + +export interface AsyncActionOptions { + /** Merged into the store before the action runs β€” the place to set a loading flag. */ + onStart?: () => Partial + /** Merged into the store when the action ultimately fails. */ + onError?: (error: SafeError) => Partial + /** Number of additional attempts after the first failure. */ + retry?: number + /** Fixed delay in ms, or a function of the zero-based attempt number. */ + retryDelay?: number | ((attempt: number) => number) + /** + * When true (the default), a result is discarded if a newer invocation of the + * same action has started since. Without this, two in-flight calls commit in + * completion order, so a slow early request can overwrite a fast later one. + */ + latestOnly?: boolean +} + +export interface AsyncActionHandle { + abort(): void +} + +export function asyncAction( + store: Store, + fn: (store: Store, ...args: P) => Promise>, + options?: AsyncActionOptions +): (...args: P) => Promise & AsyncActionHandle { + const latestOnly = options?.latestOnly ?? true + let invocationCounter = 0 + + const action = (...args: P) => { + // One controller per invocation. Sharing a single controller across calls + // means `promise.abort()` on one call cancels whichever call ran last. + const controller = new AbortController() + const invocation = ++invocationCounter + + const isStale = () => + controller.signal.aborted || (latestOnly && invocation !== invocationCounter) + + if (options?.onStart) { + store.set(Object.assign({}, store.read(), options.onStart())) + } + + const execute = async (attempt: number): Promise => { + try { + const result = await fn(store, ...args) + if (isStale()) return store.read() + store.set(Object.assign({}, store.read(), result)) + return store.read() + } catch (e) { + if (isStale()) return store.read() + + const safeError = toSafeError(e) + + if (options?.retry && attempt < options.retry) { + if (options.retryDelay !== undefined) { + const delay = typeof options.retryDelay === 'function' + ? options.retryDelay(attempt) + : options.retryDelay + await new Promise(resolve => setTimeout(resolve, delay)) + } + if (isStale()) return store.read() + return execute(attempt + 1) + } + + if (options?.onError) { + store.set(Object.assign({}, store.read(), options.onError(safeError))) + } + throw safeError + } + } + + const promise = execute(0) as Promise & AsyncActionHandle + promise.abort = () => { controller.abort() } + return promise + } + + return action +} diff --git a/src/combine.ts b/src/combine.ts index f7fc236..96174c5 100644 --- a/src/combine.ts +++ b/src/combine.ts @@ -1,5 +1,5 @@ -import { Equality } from "./types" -import { Store } from "./store" +import { Equality } from "./types.js" +import { Store } from "./store.js" export interface Combined> { read(): { [K in keyof TShape]: Readonly } @@ -7,6 +7,8 @@ export interface Combined> { subscriber: (s: { [K in keyof TShape]: Readonly }) => void, options?: { eq?: Equality<{ [K in keyof TShape]: Readonly }>, fireImmediately?: boolean } ): () => void + /** Detaches from all child stores and drops every subscriber. */ + destroy(): void } export function combineStores>( @@ -15,28 +17,45 @@ export function combineStores>( const keys = Object.keys(stores) as Array let current = {} as { [K in keyof TShape]: Readonly } for (const k of keys) { - const s = stores[k] - current[k] = s.snapshot() as Readonly + current[k] = stores[k].snapshot() as Readonly } - const subscribers: Array<() => void> = [] + let subscribers: Array<() => void> = [] let childUnsubs: Array<() => void> | null = null function notifyAll() { - for (const n of subscribers) n() + // Iterate a snapshot so a subscriber unsubscribing mid-notification + // can't shift the array out from under the loop. + for (const n of subscribers.slice()) n() + } + + /** + * Pulls the latest snapshot from every child store. Returns true when the + * combined value actually changed, so notifications stay change-driven. + */ + function refresh(): boolean { + let next: { [K in keyof TShape]: Readonly } | null = null + for (const k of keys) { + const snap = stores[k].snapshot() as Readonly + if (!Object.is(current[k], snap)) { + if (next === null) next = { ...current } + next[k] = snap + } + } + if (next === null) return false + current = next + return true } function attach() { if (childUnsubs) return + // Catch up on anything that changed while we were detached, otherwise the + // first post-attach notification would compare against a stale baseline. + refresh() childUnsubs = keys.map((k) => { const s = stores[k] return s.subscribe(x => x as Readonly, () => { - const next = { ...current } - next[k] = s.snapshot() as Readonly - if (!Object.is(current, next)) { - current = next - notifyAll() - } + if (refresh()) notifyAll() }) }) } @@ -49,11 +68,14 @@ export function combineStores>( return { read() { + // While detached there are no child subscriptions keeping `current` + // up to date, so pull fresh values on demand. + if (!childUnsubs) refresh() return current }, subscribe(subscriber, options) { const eq: Equality<{ [K in keyof TShape]: Readonly }> = options?.eq ?? Object.is - if (!childUnsubs) attach() + attach() let prev = current if (options?.fireImmediately) subscriber(prev) const notify = () => { @@ -64,11 +86,18 @@ export function combineStores>( } } subscribers.push(notify) + let active = true return () => { + if (!active) return + active = false const idx = subscribers.indexOf(notify) if (idx >= 0) subscribers.splice(idx, 1) if (subscribers.length === 0) detach() } + }, + destroy() { + subscribers = [] + detach() } } } diff --git a/src/computed.ts b/src/computed.ts new file mode 100644 index 0000000..da4c342 --- /dev/null +++ b/src/computed.ts @@ -0,0 +1,21 @@ +import { Selector, Subscriber, SubscribeOptions } from "./types.js" +import { Store } from "./store.js" +import { Derived } from "./derived.js" + +export function computed(store: Store, selector: Selector): Derived { + let cachedVersion = -1 + let cachedValue: R + + return { + read() { + if (store.version !== cachedVersion) { + cachedValue = selector(store.snapshot()) + cachedVersion = store.version + } + return cachedValue + }, + subscribe(subscriber: Subscriber, options?: SubscribeOptions) { + return store.subscribe(selector, subscriber, options) + }, + } +} diff --git a/src/define-store.ts b/src/define-store.ts new file mode 100644 index 0000000..3648890 --- /dev/null +++ b/src/define-store.ts @@ -0,0 +1,39 @@ +import { Store, createStore } from "./store.js" + +export type StateCreator = ( + set: (partial: Partial | ((prev: T) => T)) => T, + get: () => T +) => T; + +export function defineStore(creator: StateCreator): Store { + let store: Store | undefined; + + const get = (): T => { + if (!store) { + throw new Error("Cannot call get during store initialization"); + } + return store.read(); + }; + + const set = (partial: Partial | ((prev: T) => T)): T => { + if (!store) { + throw new Error("Cannot call set during store initialization"); + } + + const current = store.read(); + let next: T; + + if (typeof partial === "function") { + next = (partial as (prev: T) => T)(current); + } else { + next = { ...current, ...partial } as T; + } + + return store.set(next); + }; + + const initialState = creator(set, get); + store = createStore(initialState); + + return store; +} diff --git a/src/derived.ts b/src/derived.ts index a615173..b7c883b 100644 --- a/src/derived.ts +++ b/src/derived.ts @@ -1,5 +1,5 @@ -import { Selector, Subscriber, Unsubscribe, SubscribeOptions, DeepReadonly } from "./types" -import { Store } from "./store" +import { Selector, Subscriber, Unsubscribe, SubscribeOptions, DeepReadonly } from "./types.js" +import { Store } from "./store.js" export interface Derived { read(): R diff --git a/src/devtools-redux.ts b/src/devtools-redux.ts new file mode 100644 index 0000000..5dec018 --- /dev/null +++ b/src/devtools-redux.ts @@ -0,0 +1,103 @@ +import { Store } from "./store.js" +import { Unsubscribe } from "./types.js" + +/** + * Interface matching the Redux DevTools browser extension API. + * @see https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Methods.md + */ +export interface ReduxDevToolsExtension { + connect(options?: { name?: string; features?: Record }): ReduxDevToolsInstance +} + +export interface ReduxDevToolsInstance { + init(state: unknown): void + send(action: { type: string; payload?: unknown }, state: unknown): void + subscribe(listener: (message: { type: string; payload?: unknown; state?: string }) => void): (() => void) + unsubscribe(): void +} + +export interface ConnectReduxDevToolsOptions { + name?: string + enabled?: boolean +} + +/** + * Connects an Exostate store to the Redux DevTools browser extension. + * Enables time-travel debugging, state inspection, and action logging. + * + * @example + * ```ts + * const store = createStore({ count: 0 }); + * const disconnect = connectReduxDevTools(store, { name: 'Counter' }); + * // Now visible in Redux DevTools! + * ``` + */ +export function connectReduxDevTools( + store: Store, + options?: ConnectReduxDevToolsOptions +): Unsubscribe { + const enabled = options?.enabled ?? true + if (!enabled) return () => {} + + // Access the Redux DevTools extension from window + const devtoolsExtension = typeof globalThis !== "undefined" + ? (globalThis as Record)["__REDUX_DEVTOOLS_EXTENSION__"] as ReduxDevToolsExtension | undefined + : undefined + + if (!devtoolsExtension) { + return () => {} + } + + const devtools = devtoolsExtension.connect({ + name: options?.name ?? "Exostate Store", + features: { + jump: true, + skip: false, + reorder: false, + dispatch: true, + persist: false, + } + }) + + // Send initial state + devtools.init(store.read()) + + // Subscribe to state changes and send to devtools + let lastVersion = store.version + const unsubStore = store.subscribe( + (s) => s as unknown as T, + (next) => { + const currentVersion = store.version + if (currentVersion !== lastVersion) { + devtools.send( + { type: `update (v${currentVersion})` }, + next + ) + lastVersion = currentVersion + } + } + ) + + // Listen for time-travel from devtools + const unsubDevtools = devtools.subscribe((message) => { + if (message.type === "DISPATCH") { + const payload = message.payload as { type?: string } | undefined + if (payload?.type === "JUMP_TO_STATE" || payload?.type === "JUMP_TO_ACTION") { + if (message.state) { + try { + const parsed = JSON.parse(message.state) as T + store.set(parsed) + } catch { + // Invalid state from devtools, ignore + } + } + } + } + }) + + return () => { + unsubStore() + unsubDevtools?.() + devtools.unsubscribe() + } +} diff --git a/src/devtools.ts b/src/devtools.ts index 8aad644..dd5104a 100644 --- a/src/devtools.ts +++ b/src/devtools.ts @@ -1,4 +1,4 @@ -import { Middleware } from "./middleware" +import { Middleware } from "./middleware.js" export interface DevtoolsConnection { init(state: unknown): void diff --git a/src/equality.ts b/src/equality.ts new file mode 100644 index 0000000..e246f06 --- /dev/null +++ b/src/equality.ts @@ -0,0 +1,92 @@ +import { Equality } from "./types.js" + +/** + * Shallow structural equality β€” compares own enumerable keys one level deep. + * Use it when a selector builds a fresh object or array each call: + * + * ```ts + * useSelector(store, s => ({ a: s.a, b: s.b }), shallow) + * ``` + */ +export const shallow: Equality = (a, b) => { + if (Object.is(a, b)) return true + if (typeof a !== "object" || a === null || typeof b !== "object" || b === null) return false + + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b)) return false + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (!Object.is(a[i], b[i])) return false + } + return true + } + + if (a instanceof Map && b instanceof Map) { + if (a.size !== b.size) return false + for (const [k, v] of a) { + if (!b.has(k) || !Object.is(b.get(k), v)) return false + } + return true + } + + if (a instanceof Set && b instanceof Set) { + if (a.size !== b.size) return false + for (const v of a) { + if (!b.has(v)) return false + } + return true + } + + const aKeys = Object.keys(a as Record) + const bKeys = Object.keys(b as Record) + if (aKeys.length !== bKeys.length) return false + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(b, key)) return false + if (!Object.is((a as Record)[key], (b as Record)[key])) return false + } + return true +} + +/** Structural deep equality for plain data (objects, arrays, Map, Set, Date). */ +export function deepEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true + if (typeof a !== "object" || a === null || typeof b !== "object" || b === null) return false + + if (a instanceof Date || b instanceof Date) { + return a instanceof Date && b instanceof Date && a.getTime() === b.getTime() + } + + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b)) return false + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) return false + } + return true + } + + if (a instanceof Map && b instanceof Map) { + if (a.size !== b.size) return false + for (const [k, v] of a) { + if (!b.has(k) || !deepEqual(b.get(k), v)) return false + } + return true + } + + if (a instanceof Set && b instanceof Set) { + if (a.size !== b.size) return false + for (const v of a) { + if (!b.has(v)) return false + } + return true + } + + const aKeys = Object.keys(a as Record) + const bKeys = Object.keys(b as Record) + if (aKeys.length !== bKeys.length) return false + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(b, key)) return false + if (!deepEqual((a as Record)[key], (b as Record)[key])) return false + } + return true +} diff --git a/src/errors.ts b/src/errors.ts index b65650e..d3141cf 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -11,6 +11,7 @@ export class SafeError extends Error { readonly details?: unknown constructor(data: SafeErrorData) { super(data.message) + this.name = 'SafeError' this.code = data.code this.details = data.details } diff --git a/src/event-source.ts b/src/event-source.ts new file mode 100644 index 0000000..2cf0fe4 --- /dev/null +++ b/src/event-source.ts @@ -0,0 +1,129 @@ +import { Store } from "./store.js" +import { DeepReadonly, Unsubscribe } from "./types.js" + +/** + * A recorded domain event with timestamp and version. + */ +export interface DomainEvent { + readonly type: string + readonly payload: TPayload + readonly timestamp: number + readonly version: number +} + +/** + * Options for configuring event sourcing on a store. + */ +export interface EventSourceOptions { + /** Maximum number of events to retain. Oldest events are pruned. Default: 1000 */ + maxEvents?: number +} + +/** + * Event source controller attached to a store. + */ +export interface EventSource { + /** Dispatch a domain event and apply it via the reducer */ + dispatch

(type: string, payload: P, reducer: (prev: DeepReadonly, payload: P) => T): T + /** Get all recorded events */ + events(): ReadonlyArray + /** Get events since a specific version */ + eventsSince(version: number): ReadonlyArray + /** Number of recorded events */ + size(): number + /** Clear all recorded events */ + clear(): void + /** Subscribe to new events */ + onEvent(listener: (event: DomainEvent) => void): Unsubscribe + /** Replay all events from scratch (requires initial state and store) */ + replay(initialState: T): T +} + +/** + * Creates an event sourcing controller for a store. + * Records all state mutations as an append-only event log. + * + * @example + * ```ts + * const store = createStore({ items: [], total: 0 }); + * const es = createEventSource(store); + * + * es.dispatch('ITEM_ADDED', { name: 'Widget' }, (prev, payload) => ({ + * items: [...prev.items, payload], + * total: prev.total + 1, + * })); + * + * console.log(es.events()); // [{ type: 'ITEM_ADDED', payload: {...}, timestamp, version }] + * ``` + */ +export function createEventSource(store: Store, options?: EventSourceOptions): EventSource { + const maxEvents = options?.maxEvents ?? 1000 + const log: DomainEvent[] = [] + const eventListeners = new Set<(event: DomainEvent) => void>() + const reducers = new Map, payload: unknown) => T>() + + return { + dispatch

(type: string, payload: P, reducer: (prev: DeepReadonly, payload: P) => T): T { + // Store reducer for replay + if (!reducers.has(type)) { + reducers.set(type, reducer as (prev: DeepReadonly, payload: unknown) => T) + } + + // Apply the update + const result = store.update(reducer, payload) + + // Record the event + const event: DomainEvent

= { + type, + payload, + timestamp: Date.now(), + version: store.version, + } + log.push(event) + + // Prune if over limit + while (log.length > maxEvents) { + log.shift() + } + + // Notify event listeners + for (const listener of eventListeners) { + listener(event) + } + + return result + }, + + events(): ReadonlyArray { + return log + }, + + eventsSince(version: number): ReadonlyArray { + return log.filter(e => e.version > version) + }, + + size(): number { + return log.length + }, + + clear(): void { + log.length = 0 + }, + + onEvent(listener: (event: DomainEvent) => void): Unsubscribe { + eventListeners.add(listener) + return () => { eventListeners.delete(listener) } + }, + + replay(initialState: T): T { + store.set(initialState) + for (const event of log) { + const reducer = reducers.get(event.type) + if (reducer) { + store.update(reducer, event.payload) + } + } + return store.read() + } + } +} diff --git a/src/history.ts b/src/history.ts index a6c6d49..94a5b69 100644 --- a/src/history.ts +++ b/src/history.ts @@ -1,5 +1,5 @@ -import { DeepReadonly, Unsubscribe } from "./types" -import { Store } from "./store" +import { DeepReadonly, Unsubscribe } from "./types.js" +import { Store } from "./store.js" export interface History { canUndo(): boolean @@ -50,8 +50,8 @@ export function createHistory(store: Store, options?: HistoryOptions) { idx -= 1 const state = items[idx] suppress = true - store.set(state as unknown as T) - suppress = false + try { store.set(state as unknown as T) } + finally { suppress = false } return store.read() }, redo() { @@ -59,8 +59,8 @@ export function createHistory(store: Store, options?: HistoryOptions) { idx += 1 const state = items[idx] suppress = true - store.set(state as unknown as T) - suppress = false + try { store.set(state as unknown as T) } + finally { suppress = false } return store.read() }, record(state?: DeepReadonly) { @@ -85,8 +85,8 @@ export function createHistory(store: Store, options?: HistoryOptions) { idx = index const state = items[idx] suppress = true - store.set(state as unknown as T) - suppress = false + try { store.set(state as unknown as T) } + finally { suppress = false } return store.read() }, } diff --git a/src/index.ts b/src/index.ts index 5924a4d..44b5d62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export * from "./store.js" export * from "./derived.js" export * from "./history.js" export * from "./persist.js" +export * from "./persist-idb.js" export * from "./serialize.js" export * from "./transaction.js" export * from "./middleware.js" @@ -12,3 +13,16 @@ export * from "./ssr.js" export * from "./combine.js" export * from "./errors.js" export * from "./schema.js" +export * from "./computed.js" +export * from "./define-store.js" +export * from "./async-action.js" +export * from "./plugin.js" +export * from "./store-factory.js" +export * from "./event-source.js" +export * from "./devtools-redux.js" +export * from "./equality.js" +export * from "./query.js" + +// Filesystem persistence is intentionally NOT exported here β€” it lives in +// `exostate/node`, because a top-level `node:fs` import in the main entry +// breaks browser bundlers that cannot resolve node builtins. diff --git a/src/middleware.ts b/src/middleware.ts index 0b18af1..90456f5 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,7 +1,7 @@ -import { DeepReadonly, Reducer, Compute, Effect } from "./types" -import { Store } from "./store" +import { DeepReadonly, Reducer, Compute, Effect, ExostatePlugin } from "./types.js" +import { Store, StoreImpl } from "./store.js" -export type Operation = "set" | "update" | "compute" | "batch" | "effect" +export type Operation = "set" | "update" | "compute" | "batch" | "effect" | "patch" export interface MiddlewareContext { store: Store @@ -19,6 +19,13 @@ export interface Middleware { after?(op: Operation, ctx: MiddlewareAfterContext): void } +/** + * Wraps a store so every operation is announced to the given middlewares. + * + * The returned object also proxies `listeners` and `current` from the + * underlying `StoreImpl`, so code reaching for those internals sees the same + * values it would on an unwrapped store. + */ export function withMiddleware(store: Store, middlewares: ReadonlyArray>): Store { const callBefore = (op: Operation, ctx: MiddlewareContext) => { for (const m of middlewares) m.before?.(op, ctx) @@ -26,10 +33,22 @@ export function withMiddleware(store: Store, middlewares: ReadonlyArray) => { for (const m of middlewares) m.after?.(op, ctx) } - return { + const wrapped = { get version() { return store.version }, + get destroyed() { + return store.destroyed + }, + // `listeners` and `current` are not part of the `Store` contract, but + // StoreImpl exposes them and callers do reach for them. Proxy both so a + // wrapped store never reports `undefined` where a raw store would not. + get listeners() { + return (store as StoreImpl).listeners + }, + get current() { + return (store as StoreImpl).current + }, read() { return store.read() }, @@ -82,7 +101,29 @@ export function withMiddleware(store: Store, middlewares: ReadonlyArray | ((prev: DeepReadonly) => Partial)) { + const start = Date.now() + callBefore("patch", { store, version: store.version, snapshot: store.snapshot(), payload: partial }) + const out = store.patch(partial) + const end = Date.now() + callAfter("patch", { store, version: store.version, snapshot: store.snapshot(), payload: partial, durationMs: end - start }) + return out + }, + use(plugin: ExostatePlugin) { + return store.use(plugin) + }, + plugins() { + return store.plugins() + }, + flush() { + store.flush() + }, + destroy() { + store.destroy() + }, subscribe: store.subscribe.bind(store), } + + return wrapped as Store } diff --git a/src/node/index.ts b/src/node/index.ts new file mode 100644 index 0000000..22bb084 --- /dev/null +++ b/src/node/index.ts @@ -0,0 +1,72 @@ +import { promises as fs } from "node:fs" +import path from "node:path" +import type { DeepReadonly } from "../types.js" +import type { Store } from "../store.js" +import type { PersistOptions, PersistController } from "../persist.js" + +export type { PersistOptions, PersistController } from "../persist.js" + +/** + * Mirrors a store to a JSON file on disk. + * + * Lives in the `exostate/node` entry point rather than the core bundle: a + * top-level `node:fs` import in the main entry breaks browser bundlers that + * cannot resolve node builtins. + * + * Writes are serialized through a single-slot queue, so a burst of updates + * collapses to one pending write and can never interleave two `writeFile` + * calls into a torn file. + */ +export async function persistFs( + store: Store, + filePath: string, + options?: PersistOptions +): Promise { + const encode = options?.encode ?? ((s: DeepReadonly) => JSON.stringify(s)) + const decode = options?.decode ?? ((raw: string) => JSON.parse(raw) as T) + const dir = path.dirname(filePath) + let suppress = false + + try { + await fs.mkdir(dir, { recursive: true }) + } catch { void 0 } + + if (options?.loadInitial !== false) { + try { + const raw = await fs.readFile(filePath, "utf8") + const initial = decode(raw) + suppress = true + try { store.set(initial) } + finally { suppress = false } + } catch { void 0 } + } + + let writing: Promise | null = null + let pending: string | null = null + + function drain(): void { + if (writing || pending === null) return + const payload = pending + pending = null + writing = fs.writeFile(filePath, payload, "utf8") + .catch(() => void 0) + .then(() => { + writing = null + drain() + }) + } + + const unsub = store.subscribe(s => s as unknown as T, (next) => { + if (suppress) return + try { + pending = encode(next as unknown as DeepReadonly) + } catch { return } + drain() + }) + + return { + detach: () => { + unsub() + } + } +} diff --git a/src/persist-idb.ts b/src/persist-idb.ts new file mode 100644 index 0000000..8249645 --- /dev/null +++ b/src/persist-idb.ts @@ -0,0 +1,131 @@ +import { DeepReadonly } from "./types.js" +import { Store } from "./store.js" +import type { PersistController } from "./persist.js" + +export interface PersistIdbOptions { + dbName?: string + storeName?: string + key?: string + loadInitial?: boolean + /** Serialize before writing. Defaults to storing the structured value as-is. */ + encode?: (snapshot: DeepReadonly) => unknown + decode?: (raw: unknown) => T + /** Coalesce writes over this many ms. Default `50`. Use `0` to write eagerly. */ + writeDebounceMs?: number +} + +function requestToPromise(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed")) + }) +} + +function openDatabase(dbName: string, storeName: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(dbName, 1) + request.onupgradeneeded = () => { + const db = request.result + if (!db.objectStoreNames.contains(storeName)) { + db.createObjectStore(storeName) + } + } + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error ?? new Error("Failed to open IndexedDB")) + request.onblocked = () => reject(new Error("IndexedDB open blocked by another connection")) + }) +} + +/** + * Mirrors a store into IndexedDB. + * + * Preferred over `persistLocal` for large state: IndexedDB is asynchronous (so + * it never blocks the main thread) and is not bound by the ~5MB localStorage + * quota. Values are stored structured-clone style, so `Date`, `Map`, `Set`, and + * typed arrays survive a round trip without a custom serializer. + * + * @example + * ```ts + * const ctrl = await persistIndexedDB(store, { dbName: 'my-app', key: 'main' }) + * // later + * ctrl.detach() + * ``` + */ +export async function persistIndexedDB( + store: Store, + options?: PersistIdbOptions +): Promise { + const dbName = options?.dbName ?? "exostate" + const storeName = options?.storeName ?? "state" + const key = options?.key ?? "main" + const debounceMs = options?.writeDebounceMs ?? 50 + const encode = options?.encode ?? ((s: DeepReadonly) => s as unknown) + const decode = options?.decode ?? ((raw: unknown) => raw as T) + + if (typeof indexedDB === "undefined") { + throw new Error("persistIndexedDB requires an environment with IndexedDB") + } + + const db = await openDatabase(dbName, storeName) + let suppress = false + let detached = false + + if (options?.loadInitial !== false) { + try { + const tx = db.transaction(storeName, "readonly") + const raw: unknown = await requestToPromise(tx.objectStore(storeName).get(key) as IDBRequest) + if (raw !== undefined) { + const initial = decode(raw) + suppress = true + try { store.set(initial) } + finally { suppress = false } + } + } catch { void 0 } + } + + let timer: ReturnType | null = null + let pending: unknown = undefined + let hasPending = false + + function write(force = false): void { + if ((detached && !force) || !hasPending) return + const payload = pending + hasPending = false + pending = undefined + try { + const tx = db.transaction(storeName, "readwrite") + tx.objectStore(storeName).put(payload, key) + } catch { void 0 } + } + + const unsub = store.subscribe(s => s as unknown as T, (next) => { + if (suppress || detached) return + pending = encode(next as unknown as DeepReadonly) + hasPending = true + if (debounceMs <= 0) { + write() + return + } + if (timer !== null) clearTimeout(timer) + timer = setTimeout(() => { + timer = null + write() + }, debounceMs) + }) + + return { + detach: () => { + if (detached) return + detached = true + if (timer !== null) { + clearTimeout(timer) + timer = null + } + // Flush whatever was still queued so a detach never silently drops the + // most recent state. + write(true) + unsub() + db.close() + } + } +} diff --git a/src/persist.ts b/src/persist.ts index 18b4c4e..57393ea 100644 --- a/src/persist.ts +++ b/src/persist.ts @@ -1,7 +1,5 @@ -import { DeepReadonly, StorageLike } from "./types" -import { Store } from "./store" -import { promises as fs } from "node:fs" -import path from "node:path" +import { DeepReadonly, StorageLike } from "./types.js" +import { Store } from "./store.js" export interface PersistOptions { loadInitial?: boolean @@ -9,36 +7,49 @@ export interface PersistOptions { decode?: (raw: string) => T } +export interface PersistController { + detach(): void +} + +/** + * Mirrors a store into any synchronous `StorageLike` (localStorage, + * sessionStorage, or your own adapter). + * + * Filesystem persistence lives in `exostate/node` so that importing the core + * package never pulls `node:fs` into a browser bundle. + */ export function persistLocal( store: Store, key: string, storage: StorageLike, options?: PersistOptions -) { +): PersistController { const encode = options?.encode ?? ((s: DeepReadonly) => JSON.stringify(s)) const decode = options?.decode ?? ((raw: string) => JSON.parse(raw) as T) let detach: (() => void) | null = null let suppress = false - + if (options?.loadInitial !== false) { const raw = storage.getItem(key) if (raw != null) { try { const initial = decode(raw) suppress = true - store.set(initial) - suppress = false + // try/finally: if a plugin or listener throws while applying the loaded + // state, `suppress` must still be cleared or nothing is ever persisted. + try { store.set(initial) } + finally { suppress = false } } catch { void 0 } } } - + detach = store.subscribe(s => s as unknown as T, (next) => { if (suppress) return try { storage.setItem(key, encode(next as unknown as DeepReadonly)) } catch { void 0 } }) - + return { detach: () => { if (detach) { @@ -48,38 +59,3 @@ export function persistLocal( } } } - -export async function persistFs( - store: Store, - filePath: string, - options?: PersistOptions -) { - const encode = options?.encode ?? ((s: DeepReadonly) => JSON.stringify(s)) - const decode = options?.decode ?? ((raw: string) => JSON.parse(raw) as T) - const dir = path.dirname(filePath) - let suppress = false - try { - await fs.mkdir(dir, { recursive: true }) - } catch { void 0 } - - if (options?.loadInitial !== false) { - try { - const raw = await fs.readFile(filePath, "utf8") - const initial = decode(raw) - suppress = true - store.set(initial) - suppress = false - } catch { void 0 } - } - - const unsub = store.subscribe(s => s as unknown as T, (next) => { - if (suppress) return - fs.writeFile(filePath, encode(next as unknown as DeepReadonly), "utf8").catch(() => void 0) - }) - - return { - detach: () => { - unsub() - } - } -} diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 0000000..4578db5 --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,106 @@ +import { DeepReadonly, ExostatePlugin } from "./types.js" +import { Store } from "./store.js" + +export type { ExostatePlugin } from "./types.js" + +/** + * Detach handles for plugins attached through `registerPlugin`, so that + * `destroyPlugins` can tear down everything it registered. + */ +const detachRegistry = new WeakMap void>>() + +/** + * Attaches a plugin to a store. + * + * Prefer `store.use(plugin)` β€” this function exists so plugins can be attached + * from helper code that only holds a `Store` reference, and returns the same + * detach function. + */ +export function registerPlugin(store: Store, plugin: ExostatePlugin): () => void { + const detach = store.use(plugin) + const key = store as object + let handles = detachRegistry.get(key) + if (!handles) { + handles = [] + detachRegistry.set(key, handles) + } + handles.push(detach) + + return () => { + const list = detachRegistry.get(key) + if (list) { + const idx = list.indexOf(detach) + if (idx >= 0) list.splice(idx, 1) + } + detach() + } +} + +export function getPlugins(store: Store): ReadonlyArray> { + return store.plugins() +} + +/** Fires `onDestroy` for every attached plugin and detaches them all. */ +export function destroyPlugins(store: Store): void { + for (const plugin of store.plugins()) plugin.onDestroy?.() + const handles = detachRegistry.get(store as object) + if (handles) { + for (const detach of handles.slice()) detach() + detachRegistry.delete(store as object) + } +} + +// ── Built-in Plugins ──────────────────────────────────────────────────── + +export interface LoggerOptions { + name?: string + collapsed?: boolean + /** Sink for log output. Defaults to the global console. */ + console?: Pick +} + +/** Logs every committed state change. */ +export function logger(options?: LoggerOptions): ExostatePlugin { + const name = options?.name ?? "ExostateLogger" + return { + name, + onAfterUpdate(prev, next) { + const sink = options?.console ?? globalThis.console + if (!sink) return + const group = options?.collapsed ? sink.groupCollapsed : sink.group + group.call(sink, `[${name}] state updated`) + sink.log("prev:", prev) + sink.log("next:", next) + sink.groupEnd() + } + } +} + +/** + * Deep-freezes every committed state so accidental mutation throws in strict + * mode. Intended for development builds. + */ +export function freeze(): ExostatePlugin { + function deepFreeze(obj: unknown, seen: Set): unknown { + if (typeof obj !== "object" || obj === null) return obj + // Guard against cycles β€” a self-referential state would otherwise recurse + // until the stack blows. + if (seen.has(obj)) return obj + seen.add(obj) + Object.freeze(obj) + for (const val of Object.values(obj as Record)) { + if (typeof val === "object" && val !== null && !Object.isFrozen(val)) { + deepFreeze(val, seen) + } + } + return obj + } + + return { + name: "ExostateFreeze", + onBeforeUpdate(_prev: DeepReadonly, next: T) { + deepFreeze(next, new Set()) + return next + } + } +} diff --git a/src/query.ts b/src/query.ts new file mode 100644 index 0000000..f1dad93 --- /dev/null +++ b/src/query.ts @@ -0,0 +1,851 @@ +import { Store, createStore } from "./store.js" +import { Unsubscribe } from "./types.js" +import { SafeError, toSafeError } from "./errors.js" + +export type QueryKey = ReadonlyArray + +export type QueryStatus = "idle" | "loading" | "success" | "error" +export type FetchStatus = "idle" | "fetching" | "paused" + +export interface QueryState { + /** Cached data, if this query has ever resolved. */ + data: TData | undefined + error: SafeError | undefined + status: QueryStatus + /** Whether a request is in flight right now β€” independent of `status`. */ + fetchStatus: FetchStatus + /** `true` while loading with no cached data to show. */ + isLoading: boolean + /** `true` while refetching with cached data already on screen. */ + isFetching: boolean + isSuccess: boolean + isError: boolean + /** `true` when the cached data is older than `staleTime`. */ + isStale: boolean + /** Epoch ms of the last successful resolution, or 0. */ + dataUpdatedAt: number + errorUpdatedAt: number + failureCount: number +} + +export interface QueryFunctionContext { + queryKey: QueryKey + signal: AbortSignal +} + +export type QueryFunction = (context: QueryFunctionContext) => Promise + +export interface QueryOptions { + /** How long resolved data stays fresh, in ms. Default `0` (immediately stale). */ + staleTime?: number + /** How long unused data is kept after the last observer leaves. Default `5 * 60_000`. */ + gcTime?: number + /** Retries after the first failure. A function receives the zero-based attempt and the error. */ + retry?: number | ((attempt: number, error: SafeError) => boolean) + /** Backoff in ms. Default: exponential, `min(1000 * 2 ** attempt, 30_000)`. */ + retryDelay?: number | ((attempt: number) => number) + /** Refetch when the window regains focus. Default `true`. */ + refetchOnWindowFocus?: boolean + /** Refetch when the network comes back. Default `true`. */ + refetchOnReconnect?: boolean + /** Poll on an interval, in ms. Default `0` (off). */ + refetchInterval?: number + /** Skip fetching entirely while `false`. Default `true`. */ + enabled?: boolean + /** Seed the cache synchronously on first observation. */ + initialData?: TData | (() => TData) + /** Shown while loading, but never written to the cache. */ + placeholderData?: TData | (() => TData) + /** Called on every successful resolution. */ + onSuccess?: (data: TData) => void + /** Called when a fetch ultimately fails. */ + onError?: (error: SafeError) => void +} + +interface ResolvedOptions extends QueryOptions { + staleTime: number + gcTime: number + refetchOnWindowFocus: boolean + refetchOnReconnect: boolean + refetchInterval: number + enabled: boolean +} + +export interface QueryObserverOptions extends QueryOptions { + queryKey: QueryKey + queryFn: QueryFunction +} + +/** + * Serializes a query key into a stable cache identity. Object keys are sorted + * so `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` map to the same entry. + */ +export function hashQueryKey(key: QueryKey): string { + return JSON.stringify(key, (_field, value: unknown) => { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + const source = value as Record + const sorted: Record = {} + for (const k of Object.keys(source).sort()) sorted[k] = source[k] + return sorted + } + return value + }) +} + +function initialState(): QueryState { + return { + data: undefined, + error: undefined, + status: "idle", + fetchStatus: "idle", + isLoading: false, + isFetching: false, + isSuccess: false, + isError: false, + isStale: true, + dataUpdatedAt: 0, + errorUpdatedAt: 0, + failureCount: 0, + } +} + +/** True when `filter` is a prefix of `key` β€” the basis of partial invalidation. */ +function keyMatchesPrefix(key: QueryKey, filter: QueryKey): boolean { + if (filter.length > key.length) return false + for (let i = 0; i < filter.length; i++) { + if (hashQueryKey([key[i]]) !== hashQueryKey([filter[i]])) return false + } + return true +} + +class QueryEntry { + readonly store: Store> + readonly hash: string + readonly key: QueryKey + + private queryFn: QueryFunction | null = null + private options: ResolvedOptions + private observers = new Set() + private controller: AbortController | null = null + private inFlight: Promise | null = null + private gcTimer: ReturnType | null = null + private intervalTimer: ReturnType | null = null + private staleTimer: ReturnType | null = null + private disposed = false + private optionsApplied = false + + constructor( + key: QueryKey, + hash: string, + options: ResolvedOptions, + private readonly onDispose: (hash: string) => void, + private readonly now: () => number + ) { + this.key = key + this.hash = hash + this.options = options + this.store = createStore>(initialState()) + + if (options.initialData !== undefined) { + const seed = typeof options.initialData === "function" + ? (options.initialData as () => TData)() + : options.initialData + this.setData(seed) + } + } + + getOptions(): ResolvedOptions { + return this.options + } + + /** + * Merges a new observer's options in. Once a real observer exists, later + * observers can only tighten `staleTime`/`gcTime` β€” with several components + * sharing one key, the most demanding one wins. + * + * The first call replaces outright rather than merging: an entry created by + * `hydrate` or `setQueryData` carries placeholder defaults (`staleTime: 0`), + * and merging those in would make hydrated data permanently stale. + */ + applyOptions(options: ResolvedOptions, queryFn: QueryFunction) { + this.queryFn = queryFn + if (this.optionsApplied) { + this.options = { + ...this.options, + ...options, + staleTime: Math.min(this.options.staleTime, options.staleTime), + gcTime: Math.max(this.options.gcTime, options.gcTime), + } + } else { + this.options = options + this.optionsApplied = true + } + this.scheduleInterval() + this.scheduleStaleTransition() + } + + isStale(): boolean { + const { dataUpdatedAt, status } = this.store.read() + if (status !== "success") return true + return this.now() - dataUpdatedAt >= this.options.staleTime + } + + addObserver(token: object) { + this.observers.add(token) + if (this.gcTimer !== null) { + clearTimeout(this.gcTimer) + this.gcTimer = null + } + this.scheduleInterval() + } + + removeObserver(token: object) { + this.observers.delete(token) + if (this.observers.size > 0) return + + this.stopInterval() + // Nothing is watching: cancel work in flight and start the GC countdown. + this.cancel() + if (this.options.gcTime === Infinity) return + this.gcTimer = setTimeout(() => { + this.gcTimer = null + if (this.observers.size === 0) this.dispose() + }, this.options.gcTime) + } + + observerCount(): number { + return this.observers.size + } + + setData(data: TData, updatedAt?: number) { + const at = updatedAt ?? this.now() + this.store.patch({ + data, + error: undefined, + status: "success", + isSuccess: true, + isError: false, + isLoading: false, + isStale: this.options.staleTime <= 0, + dataUpdatedAt: at, + failureCount: 0, + }) + this.scheduleStaleTransition() + } + + /** + * Runs the query function, deduplicating concurrent callers: while a request + * is in flight every caller receives the same promise instead of firing a + * second network request. + */ + fetch(force = false): Promise { + if (this.disposed) return Promise.reject(new Error("Query has been garbage collected")) + if (!this.queryFn) return Promise.reject(new Error("No queryFn registered for this query")) + if (!this.options.enabled && !force) { + return Promise.resolve(this.store.read().data as TData) + } + if (this.inFlight) return this.inFlight + + const controller = new AbortController() + this.controller = controller + const hasData = this.store.read().status === "success" + + this.store.patch({ + status: hasData ? "success" : "loading", + fetchStatus: "fetching", + isFetching: true, + isLoading: !hasData, + }) + + const attemptFetch = async (attempt: number): Promise => { + try { + const data = await this.queryFn!({ queryKey: this.key, signal: controller.signal }) + if (controller.signal.aborted) throw new Error("aborted") + return data + } catch (raw) { + if (controller.signal.aborted) throw raw + const error = toSafeError(raw) + if (this.shouldRetry(attempt, error)) { + this.store.patch({ failureCount: attempt + 1 }) + await new Promise(resolve => setTimeout(resolve, this.retryDelay(attempt))) + if (controller.signal.aborted) throw raw + return attemptFetch(attempt + 1) + } + throw error + } + } + + const run = attemptFetch(0) + .then((data) => { + if (controller.signal.aborted || this.disposed) return data + this.setData(data) + this.store.patch({ fetchStatus: "idle", isFetching: false }) + this.options.onSuccess?.(data) + return data + }) + .catch((raw: unknown) => { + if (controller.signal.aborted || this.disposed) { + // A cancelled fetch must not clobber good cached data with an error. + this.store.patch({ fetchStatus: "idle", isFetching: false, isLoading: false }) + throw toSafeError(raw) + } + const error = toSafeError(raw) + this.store.patch({ + error, + status: "error", + fetchStatus: "idle", + isError: true, + isFetching: false, + isLoading: false, + isSuccess: this.store.read().data !== undefined, + errorUpdatedAt: this.now(), + }) + this.options.onError?.(error) + throw error + }) + .finally(() => { + if (this.controller === controller) this.controller = null + this.inFlight = null + }) + + this.inFlight = run + return run + } + + private shouldRetry(attempt: number, error: SafeError): boolean { + const retry = this.options.retry + if (retry === undefined) return attempt < 3 + if (typeof retry === "function") return retry(attempt, error) + return attempt < retry + } + + private retryDelay(attempt: number): number { + const delay = this.options.retryDelay + if (delay === undefined) return Math.min(1000 * 2 ** attempt, 30_000) + return typeof delay === "function" ? delay(attempt) : delay + } + + /** + * Flips `isStale` when `staleTime` elapses so subscribers re-render into the + * stale state without anyone having to poll. + */ + private scheduleStaleTransition() { + if (this.staleTimer !== null) { + clearTimeout(this.staleTimer) + this.staleTimer = null + } + const { staleTime } = this.options + if (staleTime <= 0 || staleTime === Infinity) { + if (staleTime <= 0 && !this.store.read().isStale) this.store.patch({ isStale: true }) + return + } + const elapsed = this.now() - this.store.read().dataUpdatedAt + const remaining = staleTime - elapsed + if (remaining <= 0) { + if (!this.store.read().isStale) this.store.patch({ isStale: true }) + return + } + this.staleTimer = setTimeout(() => { + this.staleTimer = null + if (!this.disposed) this.store.patch({ isStale: true }) + }, remaining) + } + + private scheduleInterval() { + this.stopInterval() + const interval = this.options.refetchInterval + if (!interval || interval <= 0 || this.observers.size === 0) return + this.intervalTimer = setInterval(() => { + void this.fetch().catch(() => void 0) + }, interval) + } + + private stopInterval() { + if (this.intervalTimer !== null) { + clearInterval(this.intervalTimer) + this.intervalTimer = null + } + } + + invalidate(): void { + this.store.patch({ isStale: true }) + } + + cancel(): void { + if (this.controller) { + this.controller.abort() + this.controller = null + } + this.inFlight = null + if (this.store.read().isFetching) { + this.store.patch({ fetchStatus: "idle", isFetching: false, isLoading: false }) + } + } + + dispose(): void { + if (this.disposed) return + this.disposed = true + this.cancel() + this.stopInterval() + if (this.gcTimer !== null) { clearTimeout(this.gcTimer); this.gcTimer = null } + if (this.staleTimer !== null) { clearTimeout(this.staleTimer); this.staleTimer = null } + this.store.destroy() + this.onDispose(this.hash) + } +} + +export interface QueryFilters { + /** Matches every query whose key starts with these elements. */ + queryKey?: QueryKey + /** Require an exact key match rather than a prefix match. */ + exact?: boolean +} + +/** A single cached query captured by `QueryClient.dehydrate()`. */ +export interface DehydratedQuery { + queryKey: QueryKey + data: unknown + dataUpdatedAt: number +} + +/** JSON-serializable cache snapshot for server-side rendering. */ +export interface DehydratedState { + queries: DehydratedQuery[] +} + +export interface QueryClientOptions { + defaultOptions?: QueryOptions + /** Injectable clock, for deterministic tests. */ + now?: () => number +} + +export interface QueryObserver { + /** Live state store for this query β€” subscribe to it directly if you like. */ + readonly store: Store> + getState(): QueryState + subscribe(listener: (state: QueryState) => void): Unsubscribe + /** Force a fetch, ignoring freshness. */ + refetch(): Promise + /** Stop observing. Releases the entry toward garbage collection. */ + destroy(): void +} + +/** + * Caches asynchronous results by key with stale-while-revalidate semantics: + * cached data is served instantly while a background refetch runs, concurrent + * requests for the same key are deduplicated into a single call, failures are + * retried with exponential backoff, and unobserved entries are garbage + * collected. + * + * @example + * ```ts + * const client = new QueryClient() + * const observer = client.watch({ + * queryKey: ['user', id], + * queryFn: ({ signal }) => fetch(`/api/users/${id}`, { signal }).then(r => r.json()), + * staleTime: 30_000, + * }) + * observer.subscribe(s => render(s)) + * ``` + */ +export class QueryClient { + private entries = new Map>() + private readonly defaults: QueryOptions + private readonly now: () => number + private focusUnsub: Unsubscribe | null = null + private onlineUnsub: Unsubscribe | null = null + + constructor(options?: QueryClientOptions) { + this.defaults = options?.defaultOptions ?? {} + this.now = options?.now ?? (() => Date.now()) + this.bindBrowserEvents() + } + + private resolveOptions(options: QueryOptions): ResolvedOptions { + const merged = { ...this.defaults, ...options } as QueryOptions + return { + ...merged, + staleTime: merged.staleTime ?? 0, + gcTime: merged.gcTime ?? 5 * 60_000, + refetchOnWindowFocus: merged.refetchOnWindowFocus ?? true, + refetchOnReconnect: merged.refetchOnReconnect ?? true, + refetchInterval: merged.refetchInterval ?? 0, + enabled: merged.enabled ?? true, + } + } + + private getOrCreate( + key: QueryKey, + options: ResolvedOptions + ): QueryEntry { + const hash = hashQueryKey(key) + const existing = this.entries.get(hash) + if (existing) return existing as unknown as QueryEntry + const entry = new QueryEntry(key, hash, options, (h) => { this.entries.delete(h) }, this.now) + this.entries.set(hash, entry as unknown as QueryEntry) + return entry + } + + /** + * Starts observing a query. Fetches immediately when the cached value is + * missing or stale; otherwise serves the cache and revalidates in the + * background. + */ + watch(options: QueryObserverOptions): QueryObserver { + const resolved = this.resolveOptions(options) + const entry = this.getOrCreate(options.queryKey, resolved) + entry.applyOptions(resolved, options.queryFn) + + const token = {} + entry.addObserver(token) + + if (resolved.enabled && entry.isStale()) { + void entry.fetch().catch(() => void 0) + } + + let destroyed = false + return { + store: entry.store, + getState: () => { + const state = entry.store.read() + if (state.data === undefined && resolved.placeholderData !== undefined) { + const placeholder = typeof resolved.placeholderData === "function" + ? (resolved.placeholderData as () => TData)() + : resolved.placeholderData + return { ...state, data: placeholder } + } + return state + }, + subscribe: (listener) => entry.store.subscribe(s => s as unknown as QueryState, listener), + refetch: () => entry.fetch(true), + destroy: () => { + if (destroyed) return + destroyed = true + entry.removeObserver(token) + }, + } + } + + /** + * Resolves a query once: returns cached data when fresh, otherwise fetches. + * Concurrent calls for the same key share a single request. + */ + async fetchQuery(options: QueryObserverOptions): Promise { + const resolved = this.resolveOptions(options) + const entry = this.getOrCreate(options.queryKey, resolved) + entry.applyOptions(resolved, options.queryFn) + if (!entry.isStale()) return entry.store.read().data as TData + return entry.fetch(true) + } + + /** Warms the cache without subscribing. Never rejects. */ + async prefetchQuery(options: QueryObserverOptions): Promise { + try { + await this.fetchQuery(options) + } catch { void 0 } + } + + /** Reads cached data for a key without triggering a fetch. */ + getQueryData(key: QueryKey): TData | undefined { + const entry = this.entries.get(hashQueryKey(key)) + return entry ? (entry.store.read().data as TData | undefined) : undefined + } + + /** Writes cached data directly β€” the basis of optimistic updates. */ + setQueryData(key: QueryKey, updater: TData | ((prev: TData | undefined) => TData)): TData { + const resolved = this.resolveOptions({}) + const entry = this.getOrCreate(key, resolved) + const prev = entry.store.read().data + const next = typeof updater === "function" + ? (updater as (p: TData | undefined) => TData)(prev) + : updater + entry.setData(next) + return next + } + + getQueryState(key: QueryKey): QueryState | undefined { + const entry = this.entries.get(hashQueryKey(key)) + return entry ? (entry.store.read() as QueryState) : undefined + } + + private matching(filters?: QueryFilters): QueryEntry[] { + const all = [...this.entries.values()] + if (!filters?.queryKey) return all + const filterKey = filters.queryKey + if (filters.exact) { + const hash = hashQueryKey(filterKey) + return all.filter(e => e.hash === hash) + } + return all.filter(e => keyMatchesPrefix(e.key, filterKey)) + } + + /** + * Marks matching queries stale and refetches those with active observers. + * With no filter, invalidates everything. + */ + async invalidateQueries(filters?: QueryFilters): Promise { + const targets = this.matching(filters) + const refetches: Array> = [] + for (const entry of targets) { + entry.invalidate() + if (entry.observerCount() > 0) { + refetches.push(entry.fetch(true).catch(() => void 0)) + } + } + await Promise.all(refetches) + } + + /** Aborts in-flight requests for matching queries. */ + cancelQueries(filters?: QueryFilters): void { + for (const entry of this.matching(filters)) entry.cancel() + } + + /** Drops matching entries from the cache entirely. */ + removeQueries(filters?: QueryFilters): void { + for (const entry of this.matching(filters)) entry.dispose() + } + + /** Refetches matching queries regardless of observers. */ + async refetchQueries(filters?: QueryFilters): Promise { + await Promise.all(this.matching(filters).map(e => e.fetch(true).catch(() => void 0))) + } + + /** Number of cached entries β€” useful for asserting GC behaviour in tests. */ + size(): number { + return this.entries.size + } + + /** + * Snapshots every successfully-resolved query into a JSON-serializable + * payload for server-side rendering. + * + * Fetch on the server, embed the result in the HTML, and `hydrate` it on the + * client so the first paint has data and no refetch waterfall occurs. + * + * @example + * ```ts + * // server + * const client = new QueryClient() + * await client.prefetchQuery({ queryKey: ['user', id], queryFn }) + * const state = client.dehydrate() + * // β†’ embed JSON.stringify(state) in the response + * + * // client + * const client = new QueryClient() + * client.hydrate(state) + * ``` + */ + dehydrate(): DehydratedState { + const queries: DehydratedQuery[] = [] + for (const entry of this.entries.values()) { + const state = entry.store.read() + if (state.status !== "success" || state.data === undefined) continue + queries.push({ + queryKey: entry.key, + data: state.data, + dataUpdatedAt: state.dataUpdatedAt, + }) + } + return { queries } + } + + /** + * Restores queries produced by `dehydrate`. + * + * `dataUpdatedAt` is preserved, so `staleTime` is measured from when the + * server actually fetched the data rather than from hydration time. + */ + hydrate(state: DehydratedState): void { + for (const query of state.queries) { + const resolved = this.resolveOptions({}) + const entry = this.getOrCreate(query.queryKey, resolved) + entry.setData(query.data, query.dataUpdatedAt) + } + } + + /** + * Wires window `focus` and `online` events to revalidation. Skipped outside + * the browser so the client works unchanged on the server. + */ + private bindBrowserEvents() { + const target = globalThis as unknown as { + addEventListener?: (type: string, fn: () => void) => void + removeEventListener?: (type: string, fn: () => void) => void + } + if (typeof target.addEventListener !== "function") return + + const onFocus = () => { + for (const entry of this.entries.values()) { + if (entry.getOptions().refetchOnWindowFocus && entry.observerCount() > 0 && entry.isStale()) { + void entry.fetch(true).catch(() => void 0) + } + } + } + const onOnline = () => { + for (const entry of this.entries.values()) { + if (entry.getOptions().refetchOnReconnect && entry.observerCount() > 0 && entry.isStale()) { + void entry.fetch(true).catch(() => void 0) + } + } + } + + target.addEventListener("focus", onFocus) + target.addEventListener("online", onOnline) + this.focusUnsub = () => target.removeEventListener?.("focus", onFocus) + this.onlineUnsub = () => target.removeEventListener?.("online", onOnline) + } + + /** Tears down every entry and detaches global listeners. */ + clear(): void { + for (const entry of [...this.entries.values()]) entry.dispose() + this.entries.clear() + this.focusUnsub?.() + this.onlineUnsub?.() + this.focusUnsub = null + this.onlineUnsub = null + } +} + +// ── Mutations ─────────────────────────────────────────────────────────── + +export interface MutationState { + data: TData | undefined + error: SafeError | undefined + status: "idle" | "loading" | "success" | "error" + isLoading: boolean + isSuccess: boolean + isError: boolean + variables: TVariables | undefined +} + +export interface MutationOptions { + mutationFn: (variables: TVariables) => Promise + /** + * Runs before the request. Return a context value (for example the previous + * cache snapshot) and it is handed back to `onError` for rollback. + */ + onMutate?: (variables: TVariables) => TContext | Promise + onSuccess?: (data: TData, variables: TVariables, context: TContext | undefined) => void | Promise + onError?: (error: SafeError, variables: TVariables, context: TContext | undefined) => void | Promise + onSettled?: ( + data: TData | undefined, + error: SafeError | undefined, + variables: TVariables, + context: TContext | undefined + ) => void | Promise + retry?: number + retryDelay?: number | ((attempt: number) => number) +} + +export interface Mutation { + readonly store: Store> + getState(): MutationState + subscribe(listener: (state: MutationState) => void): Unsubscribe + mutate(variables: TVariables): Promise + reset(): void +} + +/** + * Creates a mutation with built-in optimistic-update support. + * + * `onMutate` runs first and its return value is passed to `onError`, which is + * the hook for rolling back an optimistic cache write when the request fails. + * + * @example + * ```ts + * const addTodo = createMutation({ + * mutationFn: (text: string) => api.add(text), + * onMutate: (text) => { + * const prev = client.getQueryData(['todos']) + * client.setQueryData(['todos'], (old: Todo[] = []) => [...old, { text }]) + * return prev + * }, + * onError: (_err, _text, prev) => client.setQueryData(['todos'], prev), + * onSettled: () => client.invalidateQueries({ queryKey: ['todos'] }), + * }) + * ``` + */ +export function createMutation( + options: MutationOptions +): Mutation { + const store = createStore>({ + data: undefined, + error: undefined, + status: "idle", + isLoading: false, + isSuccess: false, + isError: false, + variables: undefined, + }) + + const retryDelay = (attempt: number): number => { + const delay = options.retryDelay + if (delay === undefined) return Math.min(1000 * 2 ** attempt, 30_000) + return typeof delay === "function" ? delay(attempt) : delay + } + + async function run(variables: TVariables): Promise { + store.patch({ + status: "loading", + isLoading: true, + isSuccess: false, + isError: false, + error: undefined, + variables, + }) + + let context: TContext | undefined + try { + context = await options.onMutate?.(variables) + } catch (raw) { + const error = toSafeError(raw) + store.patch({ status: "error", isLoading: false, isError: true, error }) + throw error + } + + const attempt = async (n: number): Promise => { + try { + return await options.mutationFn(variables) + } catch (raw) { + const error = toSafeError(raw) + if (options.retry !== undefined && n < options.retry) { + await new Promise(resolve => setTimeout(resolve, retryDelay(n))) + return attempt(n + 1) + } + throw error + } + } + + try { + const data = await attempt(0) + store.patch({ data, status: "success", isLoading: false, isSuccess: true, isError: false }) + await options.onSuccess?.(data, variables, context) + await options.onSettled?.(data, undefined, variables, context) + return data + } catch (raw) { + const error = toSafeError(raw) + store.patch({ error, status: "error", isLoading: false, isSuccess: false, isError: true }) + await options.onError?.(error, variables, context) + await options.onSettled?.(undefined, error, variables, context) + throw error + } + } + + return { + store, + getState: () => store.read(), + subscribe: (listener) => store.subscribe(s => s as unknown as MutationState, listener), + mutate: run, + reset: () => { + store.set({ + data: undefined, + error: undefined, + status: "idle", + isLoading: false, + isSuccess: false, + isError: false, + variables: undefined, + }) + }, + } +} diff --git a/src/react/index.ts b/src/react/index.ts index 80bd64f..50e073c 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -1,26 +1,82 @@ -import { useRef, useSyncExternalStore } from "react" -import type { Store } from "../store" -import type { Selector, Equality, DeepReadonly } from "../types" -import { combineStores } from "../combine" +import { useRef, useCallback, useSyncExternalStore } from "react" +import type { Store } from "../store.js" +import type { Selector, Equality, DeepReadonly } from "../types.js" +import { combineStores } from "../combine.js" export function useStore(store: Store): DeepReadonly { - return useSyncExternalStore( + const subscribe = useCallback( (onChange: () => void) => store.subscribe>((s) => s, () => onChange()), - () => store.snapshot(), - () => store.snapshot() + [store] ) + const getSnapshot = useCallback(() => store.snapshot(), [store]) + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) } +interface SelectorCache { + version: number + value: R + filled: boolean +} + +/** + * Subscribes to a slice of a store. + * + * The selector result is memoized against the store's version, so a selector + * that builds a fresh object each call (`s => ({ a: s.a })`) returns a stable + * reference between renders instead of tripping React's + * "getSnapshot should be cached" infinite loop. Selectors must therefore be + * pure functions of state. + * + * Pass `shallow` from `exostate` as `eq` when you want the subscription itself + * to compare by value rather than by reference. + */ export function useSelector( store: Store, selector: Selector, eq?: Equality ): R { - return useSyncExternalStore( - (onChange: () => void) => store.subscribe(selector, () => onChange(), { eq }), - () => selector(store.snapshot()), - () => selector(store.snapshot()) + // Latest-ref so an inline selector/comparator does not change the identity of + // `subscribe`, which would make React tear down and re-add the listener on + // every single render. + const selectorRef = useRef(selector) + const eqRef = useRef(eq) + selectorRef.current = selector + eqRef.current = eq + + const cacheRef = useRef>({ version: -1, value: undefined as R, filled: false }) + + const getSnapshot = useCallback(() => { + const cache = cacheRef.current + const version = store.version + if (cache.filled && cache.version === version) return cache.value + + const next = selectorRef.current(store.snapshot()) + if (cache.filled) { + const equal = eqRef.current ? eqRef.current(cache.value, next) : Object.is(cache.value, next) + if (equal) { + // Value is unchanged β€” keep the previous reference so React sees a + // stable snapshot even though the selector allocated a new object. + cache.version = version + return cache.value + } + } + cache.filled = true + cache.version = version + cache.value = next + return next + }, [store]) + + const subscribe = useCallback( + (onChange: () => void) => + store.subscribe( + (s) => selectorRef.current(s), + () => onChange(), + { eq: (a, b) => (eqRef.current ? eqRef.current(a, b) : Object.is(a, b)) } + ), + [store] ) + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) } export function useStores>( @@ -31,11 +87,15 @@ export function useStores>( ref.current = combineStores(stores) } const c = ref.current - return useSyncExternalStore( + const subscribe = useCallback( (onChange: () => void) => c.subscribe(() => onChange()), + [c] + ) + const getSnapshot = useCallback( () => c.read() as { [K in keyof TShape]: DeepReadonly }, - () => c.read() as { [K in keyof TShape]: DeepReadonly } + [c] ) + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) } export function useCombined>( @@ -54,19 +114,53 @@ export function useStoresSelector, R>( ref.current = combineStores(stores) } const c = ref.current - return useSyncExternalStore( + + const selectorRef = useRef(selector) + const eqRef = useRef(eq) + selectorRef.current = selector + eqRef.current = eq + + const cacheRef = useRef<{ source: unknown; value: R; filled: boolean }>({ + source: undefined, + value: undefined as R, + filled: false, + }) + + const getSnapshot = useCallback(() => { + const cache = cacheRef.current + const source = c.read() + // The combined object identity changes only when a child store changes, + // so it works the same way a version counter does for a single store. + if (cache.filled && Object.is(cache.source, source)) return cache.value + + const next = selectorRef.current(source as { [K in keyof TShape]: DeepReadonly }) + if (cache.filled) { + const equal = eqRef.current ? eqRef.current(cache.value, next) : Object.is(cache.value, next) + if (equal) { + cache.source = source + return cache.value + } + } + cache.filled = true + cache.source = source + cache.value = next + return next + }, [c]) + + const subscribe = useCallback( (onChange: () => void) => { - let prev = selector(c.read() as { [K in keyof TShape]: DeepReadonly }) + let prev = selectorRef.current(c.read() as { [K in keyof TShape]: DeepReadonly }) return c.subscribe(() => { - const next = selector(c.read() as { [K in keyof TShape]: DeepReadonly }) - const equal = eq ? eq(prev, next) : Object.is(prev, next) + const next = selectorRef.current(c.read() as { [K in keyof TShape]: DeepReadonly }) + const equal = eqRef.current ? eqRef.current(prev, next) : Object.is(prev, next) if (!equal) { prev = next onChange() } }) }, - () => selector(c.read() as { [K in keyof TShape]: DeepReadonly }), - () => selector(c.read() as { [K in keyof TShape]: DeepReadonly }) + [c] ) + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) } diff --git a/src/react/query.ts b/src/react/query.ts new file mode 100644 index 0000000..139f32e --- /dev/null +++ b/src/react/query.ts @@ -0,0 +1,155 @@ +import { + createContext, + createElement, + useCallback, + useContext, + useEffect, + useRef, + useState, + useSyncExternalStore, + type ReactNode, +} from "react" +import { + QueryClient, + type QueryKey, + type QueryObserver, + type QueryObserverOptions, + type QueryState, + type Mutation, + type MutationOptions, + type MutationState, + createMutation, + hashQueryKey, +} from "../query.js" + +const QueryClientContext = createContext(null) + +export interface QueryClientProviderProps { + client: QueryClient + children?: ReactNode +} + +export function QueryClientProvider({ client, children }: QueryClientProviderProps) { + return createElement(QueryClientContext.Provider, { value: client }, children) +} + +export function useQueryClient(): QueryClient { + const client = useContext(QueryClientContext) + if (!client) { + throw new Error("No QueryClient found. Wrap your app in .") + } + return client +} + +export interface UseQueryResult extends QueryState { + refetch: () => Promise +} + +/** + * Subscribes a component to a cached query. + * + * The observer is rebuilt only when the serialized query key changes, so + * inline `queryFn` closures and options objects do not cause resubscribe + * churn on every render. + */ +export function useQuery(options: QueryObserverOptions): UseQueryResult { + const client = useQueryClient() + const keyHash = hashQueryKey(options.queryKey) + + // Latest-ref: the observer reads the current queryFn/options at fetch time + // rather than capturing the first render's closure. + const optionsRef = useRef(options) + optionsRef.current = options + + const observerRef = useRef<{ hash: string; observer: QueryObserver } | null>(null) + if (!observerRef.current || observerRef.current.hash !== keyHash) { + observerRef.current?.observer.destroy() + observerRef.current = { + hash: keyHash, + observer: client.watch({ + ...options, + queryFn: (ctx) => optionsRef.current.queryFn(ctx), + }), + } + } + const observer = observerRef.current.observer + + useEffect(() => { + return () => { + // Only tear down the observer this effect owns; a key change already + // replaced and destroyed the previous one during render. + if (observerRef.current?.observer === observer) { + observer.destroy() + observerRef.current = null + } + } + }, [observer]) + + const subscribe = useCallback( + (onChange: () => void) => observer.subscribe(() => onChange()), + [observer] + ) + const getSnapshot = useCallback(() => observer.store.read(), [observer]) + useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + + const state = observer.getState() + const refetch = useCallback(() => observer.refetch(), [observer]) + return { ...state, refetch } +} + +export interface UseMutationResult extends MutationState { + mutate: (variables: TVariables) => void + mutateAsync: (variables: TVariables) => Promise + reset: () => void +} + +/** + * Runs a mutation and tracks its loading/success/error state. + * + * `mutate` fires and forgets (rejections are swallowed so an unhandled promise + * never escapes into the console); `mutateAsync` returns the promise for + * callers that want to await or catch it. + */ +export function useMutation( + options: MutationOptions +): UseMutationResult { + const optionsRef = useRef(options) + optionsRef.current = options + + const [mutation] = useState>(() => + createMutation({ + mutationFn: (vars) => optionsRef.current.mutationFn(vars), + onMutate: (vars) => optionsRef.current.onMutate?.(vars) as TContext, + onSuccess: (data, vars, ctx) => optionsRef.current.onSuccess?.(data, vars, ctx), + onError: (error, vars, ctx) => optionsRef.current.onError?.(error, vars, ctx), + onSettled: (data, error, vars, ctx) => optionsRef.current.onSettled?.(data, error, vars, ctx), + get retry() { return optionsRef.current.retry }, + get retryDelay() { return optionsRef.current.retryDelay }, + }) + ) + + const subscribe = useCallback( + (onChange: () => void) => mutation.subscribe(() => onChange()), + [mutation] + ) + const getSnapshot = useCallback(() => mutation.store.read(), [mutation]) + const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + + const mutateAsync = useCallback((variables: TVariables) => mutation.mutate(variables), [mutation]) + const mutate = useCallback( + (variables: TVariables) => { void mutation.mutate(variables).catch(() => void 0) }, + [mutation] + ) + const reset = useCallback(() => mutation.reset(), [mutation]) + + return { ...state, mutate, mutateAsync, reset } +} + +/** Convenience wrapper around `client.invalidateQueries`. */ +export function useInvalidateQueries(): (queryKey?: QueryKey) => Promise { + const client = useQueryClient() + return useCallback( + (queryKey?: QueryKey) => client.invalidateQueries(queryKey ? { queryKey } : undefined), + [client] + ) +} diff --git a/src/serialize.ts b/src/serialize.ts index 434b56c..a27577c 100644 --- a/src/serialize.ts +++ b/src/serialize.ts @@ -1,4 +1,4 @@ -import { DeepReadonly } from "./types" +import { DeepReadonly } from "./types.js" export interface Serializer { version: number diff --git a/src/solid/index.ts b/src/solid/index.ts new file mode 100644 index 0000000..a465341 --- /dev/null +++ b/src/solid/index.ts @@ -0,0 +1,31 @@ +import { createSignal, onCleanup, type Accessor } from 'solid-js' +import type { Store } from '../store.js' +import type { Selector, DeepReadonly } from '../types.js' + +/** + * Binds a whole store to a Solid signal. + * + * The setter is called with a thunk (`() => val`) because Solid treats a bare + * function argument as an updater β€” without it, a state object that happens to + * be callable would be invoked instead of stored. + */ +export function useExostore(store: Store): Accessor> { + const [state, setState] = createSignal>(store.snapshot()) + const unsubscribe = store.subscribe>( + (s) => s, + (val) => setState(() => val) + ) + onCleanup(() => unsubscribe()) + return state +} + +/** Binds a selected slice of a store to a Solid signal. */ +export function useExoselector(store: Store, selector: Selector): Accessor { + const [state, setState] = createSignal(selector(store.snapshot())) + const unsubscribe = store.subscribe( + selector, + (val) => setState(() => val) + ) + onCleanup(() => unsubscribe()) + return state +} diff --git a/src/ssr.ts b/src/ssr.ts index 9e6d59a..9ae479f 100644 --- a/src/ssr.ts +++ b/src/ssr.ts @@ -1,6 +1,6 @@ -import { Store } from "./store" -import { DeepReadonly } from "./types" -import { Serializer } from "./serialize" +import { Store } from "./store.js" +import { DeepReadonly } from "./types.js" +import { Serializer } from "./serialize.js" export function dehydrate(store: Store, serializer?: Serializer): string { const snap = store.snapshot() as DeepReadonly diff --git a/src/state.ts b/src/state.ts index a341b17..e726d52 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,4 +1,4 @@ -import { DeepReadonly } from "./types" +import { DeepReadonly } from "./types.js" export interface State { readonly version: number diff --git a/src/store-factory.ts b/src/store-factory.ts new file mode 100644 index 0000000..f1a7ba0 --- /dev/null +++ b/src/store-factory.ts @@ -0,0 +1,83 @@ +import { Store, createStore } from "./store.js" + +/** + * Creates a store factory that produces isolated store instances. + * Useful for scoped UI components (widgets, modals, multi-tenant). + * + * @example + * ```ts + * const createWidgetStore = storeFactory((id: string) => ({ + * id, + * items: [] as string[], + * loading: false, + * })); + * + * const widget1 = createWidgetStore('w1'); + * const widget2 = createWidgetStore('w2'); + * // Each gets its own isolated store + * ``` + */ +export function storeFactory( + initializer: (...args: A) => T +): (...args: A) => Store { + return (...args: A) => { + const initial = initializer(...args) + return createStore(initial) + } +} + +/** + * Creates a cached store factory that returns the same store instance + * for the same key. Useful for entity-scoped stores. + * + * @example + * ```ts + * const getUserStore = cachedStoreFactory((userId: string) => ({ + * id: userId, + * name: '', + * loading: false, + * })); + * + * const store1 = getUserStore('user-1'); + * const store2 = getUserStore('user-1'); + * console.log(store1 === store2); // true β€” same instance + * ``` + */ +export function cachedStoreFactory( + initializer: (key: string) => T +): { + get(key: string): Store + has(key: string): boolean + delete(key: string): boolean + clear(): void + keys(): IterableIterator + size: number +} { + const cache = new Map>() + + return { + get(key: string): Store { + let store = cache.get(key) + if (!store) { + store = createStore(initializer(key)) + cache.set(key, store) + } + return store + }, + has(key: string): boolean { + return cache.has(key) + }, + delete(key: string): boolean { + return cache.delete(key) + }, + clear(): void { + cache.clear() + }, + keys(): IterableIterator { + return cache.keys() + }, + get size(): number { + return cache.size + } + } +} diff --git a/src/store.ts b/src/store.ts index c02d703..862a662 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,5 +1,18 @@ -import { Reducer, Selector, Subscriber, Unsubscribe, Equality, SubscribeOptions, DeepReadonly, Compute, Effect } from "./types" -import { State } from "./state" +import { + Reducer, + Selector, + Subscriber, + Unsubscribe, + Equality, + SubscribeOptions, + DeepReadonly, + Compute, + Effect, + ExostatePlugin, + StoreOptions, + NotifyMode, +} from "./types.js" +import { State } from "./state.js" export interface Store extends State { update

(reducer: Reducer, payload: P): T @@ -8,13 +21,52 @@ export interface Store extends State { compute(fn: Compute): T batch(apply: (apply:

(reducer: Reducer, payload: P) => void) => void): T effect

(fn: Effect, payload: P): void | Promise + patch(partial: Partial | ((prev: DeepReadonly) => Partial)): T + /** Attach a plugin. Returns a function that detaches it. */ + use(plugin: ExostatePlugin): Unsubscribe + /** Plugins currently attached, in attach order. */ + plugins(): ReadonlyArray> + /** Deliver any notification queued by `notify: "microtask"` immediately. */ + flush(): void + destroy(): void + readonly destroyed: boolean +} + +interface PluginRegistration { + plugin: ExostatePlugin + cleanup?: () => void } export class StoreImpl implements Store { version = 0 listeners = new Set<() => void>() + destroyed = false + + private readonly options: StoreOptions + private readonly mode: NotifyMode + private registrations: Array> = [] + + // Hot-path hook caches. Kept as plain arrays so a store with no plugins + // pays only a `.length` check per mutation. + private beforeHooks: Array<(prev: DeepReadonly, next: T) => T | void> = [] + private afterHooks: Array<(prev: DeepReadonly, next: T) => void> = [] + private subHooks: Array<(count: number) => void> = [] + private unsubHooks: Array<(count: number) => void> = [] + + private notifyScheduled = false + private unmountTimer: ReturnType | null = null + + constructor(public current: T, options?: StoreOptions) { + this.options = options ?? {} + this.mode = this.options.notify ?? "sync" + if (this.options.plugins) { + for (const p of this.options.plugins) this.use(p) + } + } - constructor(public current: T) {} + private checkDestroyed() { + if (this.destroyed) throw new Error('Store is destroyed') + } read() { return this.current @@ -24,39 +76,63 @@ export class StoreImpl implements Store { return this.current as DeepReadonly } - update

(reducer: Reducer, payload: P) { - const next = reducer(this.current as DeepReadonly, payload) - this.current = next + /** + * Single write path: runs the plugin pipeline, commits, bumps the version, + * then schedules notification. Every mutating method funnels through here so + * plugins and batching can never be bypassed. + */ + private commit(next: T): T { + let value = next + if (this.beforeHooks.length > 0) { + const prev = this.current as DeepReadonly + for (const hook of this.beforeHooks) { + const replaced = hook(prev, value) + if (replaced !== undefined) value = replaced + } + } + + const prev = this.current as DeepReadonly + this.current = value this.version += 1 - if (this.listeners.size > 0) this.notifyListeners() + + if (this.afterHooks.length > 0) { + for (const hook of this.afterHooks) hook(prev, value) + } + if (this.listeners.size > 0) this.scheduleNotify() return this.current } + update

(reducer: Reducer, payload: P) { + this.checkDestroyed() + return this.commit(reducer(this.current as DeepReadonly, payload)) + } + set(next: T) { - this.current = next - this.version += 1 - if (this.listeners.size > 0) this.notifyListeners() - return this.current + this.checkDestroyed() + return this.commit(next) } compute(fn: Compute) { - const next = fn(this.current as DeepReadonly) - this.current = next - this.version += 1 - if (this.listeners.size > 0) this.notifyListeners() - return this.current + this.checkDestroyed() + return this.commit(fn(this.current as DeepReadonly)) } batch(apply: (apply:

(reducer: Reducer, payload: P) => void) => void) { + this.checkDestroyed() let next = this.current const applier =

(reducer: Reducer, payload: P) => { next = reducer(next as DeepReadonly, payload) } apply(applier) - this.current = next - this.version += 1 - if (this.listeners.size > 0) this.notifyListeners() - return this.current + return this.commit(next) + } + + patch(partial: Partial | ((prev: DeepReadonly) => Partial)) { + this.checkDestroyed() + const p = typeof partial === 'function' + ? (partial as (prev: DeepReadonly) => Partial)(this.current as DeepReadonly) + : partial + return this.commit(Object.assign({}, this.current, p)) } effect

(fn: Effect, payload: P) { @@ -64,10 +140,11 @@ export class StoreImpl implements Store { } subscribe(selector: Selector, subscriber: Subscriber, options?: SubscribeOptions) { + this.checkDestroyed() const eq: Equality = options?.eq || Object.is let prev = selector(this.current as DeepReadonly) if (options?.fireImmediately) subscriber(prev) - + // Optimize: flatten notify logic to reduce closure/stack depth const notify = () => { const next = selector(this.current as DeepReadonly) @@ -76,29 +153,145 @@ export class StoreImpl implements Store { subscriber(next) } } - - // Copy-on-write add (Set) + + // Copy-on-write add (Set) β€” an in-flight notification loop iterates a + // snapshot, so subscribing during notification can't corrupt it. const nextListeners = new Set(this.listeners) nextListeners.add(notify) this.listeners = nextListeners - + this.handleSubscribe() + + let active = true const unsubscribe: Unsubscribe = () => { + // Idempotent: a double unsubscribe must not fire lifecycle hooks twice. + if (!active) return + active = false if (this.listeners.has(notify)) { // Copy-on-write remove (Set) const next = new Set(this.listeners) next.delete(notify) this.listeners = next } + this.handleUnsubscribe() } - + return unsubscribe } + private scheduleNotify() { + if (this.mode === "sync") { + this.notifyListeners() + return + } + if (this.notifyScheduled) return + this.notifyScheduled = true + queueMicrotask(() => { + if (!this.notifyScheduled) return + this.notifyScheduled = false + if (this.destroyed) return + this.notifyListeners() + }) + } + + flush() { + if (!this.notifyScheduled) return + this.notifyScheduled = false + if (this.destroyed) return + this.notifyListeners() + } + private notifyListeners() { for (const notify of this.listeners) notify() } + + private handleSubscribe() { + // A new subscriber cancels a pending idle teardown. + if (this.unmountTimer !== null) { + clearTimeout(this.unmountTimer) + this.unmountTimer = null + } + if (this.subHooks.length === 0 && !this.options.onSubscribe) return + const count = this.listeners.size + this.options.onSubscribe?.(this, count) + for (const hook of this.subHooks) hook(count) + } + + private handleUnsubscribe() { + if (this.unsubHooks.length === 0 && !this.options.onUnsubscribe) return + const emit = () => { + const count = this.listeners.size + this.options.onUnsubscribe?.(this, count) + for (const hook of this.unsubHooks) hook(count) + } + const delay = this.options.unmountDelay ?? 0 + if (this.listeners.size === 0 && delay > 0) { + if (this.unmountTimer !== null) clearTimeout(this.unmountTimer) + this.unmountTimer = setTimeout(() => { + this.unmountTimer = null + // Re-check: a subscriber may have arrived during the grace period. + if (this.listeners.size === 0) emit() + }, delay) + return + } + emit() + } + + use(plugin: ExostatePlugin): Unsubscribe { + const cleanup = plugin.onInit?.(this) + const registration: PluginRegistration = { + plugin, + cleanup: typeof cleanup === "function" ? cleanup : undefined, + } + this.registrations.push(registration) + this.rebuildHooks() + + let detached = false + return () => { + if (detached) return + detached = true + const idx = this.registrations.indexOf(registration) + if (idx >= 0) this.registrations.splice(idx, 1) + this.rebuildHooks() + registration.cleanup?.() + } + } + + plugins(): ReadonlyArray> { + return this.registrations.map(r => r.plugin) + } + + private rebuildHooks() { + this.beforeHooks = [] + this.afterHooks = [] + this.subHooks = [] + this.unsubHooks = [] + for (const { plugin } of this.registrations) { + if (plugin.onBeforeUpdate) this.beforeHooks.push(plugin.onBeforeUpdate.bind(plugin)) + if (plugin.onAfterUpdate) this.afterHooks.push(plugin.onAfterUpdate.bind(plugin)) + if (plugin.onSubscribe) this.subHooks.push(plugin.onSubscribe.bind(plugin)) + if (plugin.onUnsubscribe) this.unsubHooks.push(plugin.onUnsubscribe.bind(plugin)) + } + } + + destroy() { + if (this.destroyed) return + this.destroyed = true + if (this.unmountTimer !== null) { + clearTimeout(this.unmountTimer) + this.unmountTimer = null + } + this.notifyScheduled = false + this.listeners = new Set() + for (const registration of this.registrations) { + registration.plugin.onDestroy?.() + registration.cleanup?.() + } + this.registrations = [] + this.rebuildHooks() + this.version = -1 + } } -export function createStore(initial: T): Store { - return new StoreImpl(initial) +export function createStore(initial: T, options?: StoreOptions): Store { + return new StoreImpl(initial, options) } diff --git a/src/svelte/index.ts b/src/svelte/index.ts new file mode 100644 index 0000000..674ac19 --- /dev/null +++ b/src/svelte/index.ts @@ -0,0 +1,42 @@ +import type { Store } from '../store.js' +import type { Selector, DeepReadonly, Unsubscribe } from '../types.js' + +/** The readable half of the Svelte store contract. */ +export interface SvelteReadable { + subscribe(run: (value: V) => void): Unsubscribe +} + +/** + * Adapts a store to Svelte's readable-store contract, so it works with the + * `$store` auto-subscription syntax. + * + * Svelte requires `subscribe` to invoke the callback synchronously with the + * current value before returning. + * + * @example + * ```svelte + * + *

{$count.value}

+ * ``` + */ +export function exostore(store: Store): SvelteReadable> { + return { + subscribe(run: (value: DeepReadonly) => void): Unsubscribe { + run(store.snapshot()) + return store.subscribe>((s) => s, run) + } + } +} + +/** Adapts a selected slice of a store to Svelte's readable-store contract. */ +export function exoselector(store: Store, selector: Selector): SvelteReadable { + return { + subscribe(run: (value: R) => void): Unsubscribe { + run(selector(store.snapshot())) + return store.subscribe(selector, run) + } + } +} diff --git a/src/transaction.ts b/src/transaction.ts index ae3048a..a48c21b 100644 --- a/src/transaction.ts +++ b/src/transaction.ts @@ -1,5 +1,5 @@ -import { DeepReadonly, Reducer, Compute } from "./types" -import { Store } from "./store" +import { DeepReadonly, Reducer, Compute } from "./types.js" +import { Store } from "./store.js" export interface Transaction { read(): T @@ -13,27 +13,40 @@ export interface Transaction { export function beginTransaction(store: Store): Transaction { const initial = store.snapshot() as unknown as T let next = initial + let sealed = false + + function assertOpen() { + if (sealed) throw new Error("Transaction is already sealed (committed or rolled back)") + } + return { read() { return next }, apply

(reducer: Reducer, payload: P) { + assertOpen() next = reducer(next as DeepReadonly, payload) return next }, compute(fn: Compute) { + assertOpen() next = fn(next as DeepReadonly) return next }, set(s: T) { + assertOpen() next = s return next }, commit() { + assertOpen() + sealed = true store.set(next) return store.read() }, rollback() { + assertOpen() + sealed = true next = initial return next } diff --git a/src/types.ts b/src/types.ts index ea00540..c2ca7db 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,3 +27,66 @@ export interface StorageLike { setItem(key: string, value: string): void removeItem(key: string): void } + +/** + * How a store delivers change notifications. + * - `sync` (default): listeners run synchronously inside the mutation call. + * - `microtask`: notifications are coalesced and flushed once per microtask, + * so N synchronous mutations produce a single notification. + */ +export type NotifyMode = "sync" | "microtask" + +/** + * A plugin observes and can transform a store's lifecycle. + * Plugins are attached with `store.use(plugin)`. + */ +export interface ExostatePlugin { + name: string + /** Runs when the plugin is attached. Return a function to clean up on detach. */ + onInit?(store: PluginHost): void | (() => void) + /** Runs before the next state is committed. Return a value to replace it. */ + onBeforeUpdate?(prev: DeepReadonly, next: T): T | void + /** Runs after the state is committed, before listeners are notified. */ + onAfterUpdate?(prev: DeepReadonly, next: T): void + /** Runs after a listener is added, with the resulting listener count. */ + onSubscribe?(listenerCount: number): void + /** Runs after a listener is removed, with the resulting listener count. */ + onUnsubscribe?(listenerCount: number): void + /** Runs when the plugin is torn down (store destroyed or plugins destroyed). */ + onDestroy?(): void +} + +/** + * Minimal store surface a plugin receives in `onInit`. + * Declared structurally to avoid a circular import between store and types. + */ +export interface PluginHost { + readonly version: number + read(): T + snapshot(): DeepReadonly + set(next: T): T +} + +export interface StoreOptions { + /** Notification strategy. Default `sync`. */ + notify?: NotifyMode + /** Plugins attached at construction time. */ + plugins?: ReadonlyArray> + /** + * Called after a listener is added, with the resulting listener count. + * A count of 1 means the store just became "active" β€” the place to open + * sockets, start timers, or begin polling. + */ + onSubscribe?: (store: PluginHost, listenerCount: number) => void + /** + * Called after a listener is removed, with the resulting listener count. + * A count of 0 means the store just went idle β€” the place to release resources. + */ + onUnsubscribe?: (store: PluginHost, listenerCount: number) => void + /** + * Delay in ms before firing `onUnsubscribe` once the listener count hits 0. + * Prevents teardown/setup churn when a component unmounts and immediately + * remounts (route transitions, Suspense retries). Default `0`. + */ + unmountDelay?: number +} diff --git a/src/vue/index.ts b/src/vue/index.ts new file mode 100644 index 0000000..0ac749a --- /dev/null +++ b/src/vue/index.ts @@ -0,0 +1,36 @@ +import { shallowRef, readonly, onScopeDispose, type Ref, type DeepReadonly as VueDeepReadonly } from 'vue' +import type { Store } from '../store.js' +import type { Selector, DeepReadonly } from '../types.js' + +/** + * Binds a whole store to a Vue ref. + * + * Uses `shallowRef` because Exostate state is already immutable β€” deep + * reactivity would walk and proxy the entire tree on every commit for no gain. + * + * Cleanup is registered with `onScopeDispose`, so this works inside components + * *and* inside any standalone effect scope, unlike `onUnmounted`. + */ +export function useExostore(store: Store): VueDeepReadonly>> { + const state = shallowRef(store.snapshot()) as Ref> + const unsubscribe = store.subscribe>( + (s) => s, + (val) => { state.value = val } + ) + onScopeDispose(() => unsubscribe()) + return readonly(state) +} + +/** Binds a selected slice of a store to a Vue ref. */ +export function useExoselector( + store: Store, + selector: Selector +): VueDeepReadonly> { + const state = shallowRef(selector(store.snapshot())) as Ref + const unsubscribe = store.subscribe( + selector, + (val) => { state.value = val } + ) + onScopeDispose(() => unsubscribe()) + return readonly(state) +} diff --git a/tests/async-action.test.ts b/tests/async-action.test.ts new file mode 100644 index 0000000..2c45925 --- /dev/null +++ b/tests/async-action.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest' +import { createStore } from '../src/store' +import { asyncAction } from '../src/async-action' + +describe('asyncAction', () => { + it('should succeed and update store', async () => { + const store = createStore({ loading: false, data: '' }) + const action = asyncAction(store, async (s, val: string) => { + s.set(Object.assign({}, s.read(), { loading: true })) + await new Promise(r => setTimeout(r, 10)) + return { loading: false, data: val } + }) + + const p = action('hello') + expect(store.read().loading).toBe(true) + await p + expect(store.read()).toEqual({ loading: false, data: 'hello' }) + }) + + it('should handle error with onError', async () => { + const store = createStore({ loading: false, error: '' }) + const action = asyncAction(store, async (s) => { + s.set(Object.assign({}, s.read(), { loading: true })) + throw new Error('fail') + }, { + onError: (err) => ({ loading: false, error: err.message }) + }) + + await expect(action()).rejects.toThrow('fail') + expect(store.read()).toEqual({ loading: false, error: 'fail' }) + }) + + it('should retry', async () => { + const store = createStore({ count: 0 }) + let attempts = 0 + const action = asyncAction(store, async () => { + attempts++ + if (attempts < 3) throw new Error('fail') + return { count: attempts } + }, { + retry: 3, + retryDelay: 5 + }) + + await action() + expect(attempts).toBe(3) + expect(store.read()).toEqual({ count: 3 }) + }) + + it('should abort', async () => { + const store = createStore({ data: '' }) + const action = asyncAction(store, async () => { + await new Promise(r => setTimeout(r, 50)) + return { data: 'done' } + }) + + const p = action() + p.abort() + + // In our implementation, abortion doesn't necessarily reject the promise immediately if it's waiting + // But it will return early without resolving. Wait a bit to ensure it doesn't update store. + await new Promise(r => setTimeout(r, 100)) + expect(store.read()).toEqual({ data: '' }) + }) +}) diff --git a/tests/computed.test.ts b/tests/computed.test.ts new file mode 100644 index 0000000..635193d --- /dev/null +++ b/tests/computed.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from "vitest" +import { createStore } from "../src/store.js" +import { computed } from "../src/computed.js" + +describe("computed", () => { + it("caches value when read multiple times without store change", () => { + const store = createStore({ a: 1, b: 2 }) + const selector = vi.fn((state: { a: number, b: number }) => state.a + state.b) + const derived = computed(store, selector) + + expect(derived.read()).toBe(3) + expect(derived.read()).toBe(3) + expect(selector).toHaveBeenCalledTimes(1) + }) + + it("recomputes when store changes", () => { + const store = createStore({ a: 1, b: 2 }) + const selector = vi.fn((state: { a: number, b: number }) => state.a + state.b) + const derived = computed(store, selector) + + expect(derived.read()).toBe(3) + + store.set({ a: 2, b: 2 }) + + expect(derived.read()).toBe(4) + expect(selector).toHaveBeenCalledTimes(2) + }) + + it("subscribe works and fires on changes", () => { + const store = createStore({ a: 1, b: 2 }) + const derived = computed(store, state => state.a) + + const subscriber = vi.fn() + const unsubscribe = derived.subscribe(subscriber) + + store.set({ a: 2, b: 2 }) + expect(subscriber).toHaveBeenCalledWith(2) + + unsubscribe() + store.set({ a: 3, b: 2 }) + expect(subscriber).toHaveBeenCalledTimes(1) // Doesn't fire after unsubscribe + }) +}) diff --git a/tests/define-store.test.ts b/tests/define-store.test.ts new file mode 100644 index 0000000..7bc8dfa --- /dev/null +++ b/tests/define-store.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi } from "vitest" +import { defineStore } from "../src/define-store.js" + +interface CounterState { + count: number + increment: () => void + reset: () => void + setCount: (n: number) => void +} + +describe("defineStore", () => { + it("creates store with initial state and actions", () => { + const store = defineStore((set) => ({ + count: 0, + increment: () => set((state) => ({ ...state, count: state.count + 1 })), + reset: () => set({ count: 0 }), + setCount: (n) => set({ count: n }) + })) + + expect(store.read().count).toBe(0) + }) + + it("actions can read and write state", () => { + const store = defineStore((set) => ({ + count: 0, + increment: () => set((state) => ({ ...state, count: state.count + 1 })), + reset: () => set({ count: 0 }), + setCount: (n) => set({ count: n }) + })) + + store.read().increment() + expect(store.read().count).toBe(1) + + store.read().increment() + expect(store.read().count).toBe(2) + + store.read().reset() + expect(store.read().count).toBe(0) + + store.read().setCount(5) + expect(store.read().count).toBe(5) + }) + + it("subscribers fire when actions change state", () => { + const store = defineStore((set) => ({ + count: 0, + increment: () => set((state) => ({ ...state, count: state.count + 1 })), + reset: () => set({ count: 0 }), + setCount: (n) => set({ count: n }) + })) + + const subscriber = vi.fn() + store.subscribe((state) => state.count, subscriber) + + store.read().increment() + expect(subscriber).toHaveBeenCalledWith(1) + }) + + it("get() returns current state inside actions", () => { + interface GetState { + val: string + check: () => string + } + + const store = defineStore((set, get) => ({ + val: "initial", + check: () => get().val + })) + + expect(store.read().check()).toBe("initial") + + store.set({ ...store.read(), val: "changed" }) + + expect(store.read().check()).toBe("changed") + }) +}) diff --git a/tests/destroy.test.ts b/tests/destroy.test.ts new file mode 100644 index 0000000..92df43a --- /dev/null +++ b/tests/destroy.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { createStore, type StoreImpl } from "../src/store" + +describe('Store.destroy()', () => { + it('clears listeners and sets sentinel version', () => { + const store = createStore({ count: 0 }) + store.subscribe(s => s.count, () => {}) + expect((store as unknown as StoreImpl<{ count: number }>).listeners.size).toBe(1) + + store.destroy() + + expect((store as unknown as StoreImpl<{ count: number }>).listeners.size).toBe(0) + expect(store.version).toBe(-1) + }) + + it('throws on mutation methods when destroyed', () => { + const store = createStore({ count: 0 }) + store.destroy() + + const err = 'Store is destroyed' + + expect(() => store.set({ count: 1 })).toThrowError(err) + expect(() => store.update((s, p) => ({ count: s.count + p }), 1)).toThrowError(err) + expect(() => store.compute(s => ({ count: s.count + 1 }))).toThrowError(err) + expect(() => store.batch(apply => apply((s, p) => ({ count: s.count + p }), 1))).toThrowError(err) + expect(() => store.patch({ count: 1 })).toThrowError(err) + }) + + it('throws on subscribe when destroyed', () => { + const store = createStore({ count: 0 }) + store.destroy() + expect(() => store.subscribe(s => s.count, () => {})).toThrowError('Store is destroyed') + }) + + it('allows read and snapshot after destruction', () => { + const store = createStore({ count: 42 }) + store.destroy() + + expect(store.read()).toEqual({ count: 42 }) + expect(store.snapshot()).toEqual({ count: 42 }) + }) +}) diff --git a/tests/event-source.test.ts b/tests/event-source.test.ts new file mode 100644 index 0000000..abc2253 --- /dev/null +++ b/tests/event-source.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from "vitest" +import { createStore } from "../src" +import { createEventSource } from "../src/event-source" +import { DeepReadonly } from "../src/types" + +type CartState = { items: string[]; total: number } + +describe("event sourcing", () => { + it("dispatches events and applies reducers", () => { + const store = createStore({ items: [], total: 0 }) + const es = createEventSource(store) + + es.dispatch("ITEM_ADDED", "Widget", (prev: DeepReadonly, payload: string) => ({ + items: [...prev.items, payload], + total: prev.total + 1, + })) + + expect(store.read().items).toEqual(["Widget"]) + expect(store.read().total).toBe(1) + expect(es.size()).toBe(1) + }) + + it("records event metadata", () => { + const store = createStore({ items: [], total: 0 }) + const es = createEventSource(store) + + es.dispatch("ITEM_ADDED", "A", (prev: DeepReadonly, p: string) => ({ + items: [...prev.items, p], + total: prev.total + 1, + })) + + const events = es.events() + expect(events).toHaveLength(1) + expect(events[0].type).toBe("ITEM_ADDED") + expect(events[0].payload).toBe("A") + expect(typeof events[0].timestamp).toBe("number") + expect(events[0].version).toBeGreaterThan(0) + }) + + it("eventsSince filters by version", () => { + const store = createStore({ items: [], total: 0 }) + const es = createEventSource(store) + const addReducer = (prev: DeepReadonly, p: string) => ({ + items: [...prev.items, p], + total: prev.total + 1, + }) + + es.dispatch("ADD", "A", addReducer) + const v1 = store.version + es.dispatch("ADD", "B", addReducer) + es.dispatch("ADD", "C", addReducer) + + const since = es.eventsSince(v1) + expect(since).toHaveLength(2) + expect(since[0].payload).toBe("B") + }) + + it("onEvent fires for each dispatch", () => { + const store = createStore({ items: [], total: 0 }) + const es = createEventSource(store) + const received: string[] = [] + + es.onEvent(e => received.push(e.type)) + + es.dispatch("A", null, (prev) => prev as CartState) + es.dispatch("B", null, (prev) => prev as CartState) + + expect(received).toEqual(["A", "B"]) + }) + + it("onEvent unsubscribe works", () => { + const store = createStore({ items: [], total: 0 }) + const es = createEventSource(store) + const received: string[] = [] + + const unsub = es.onEvent(e => received.push(e.type)) + es.dispatch("A", null, (prev) => prev as CartState) + unsub() + es.dispatch("B", null, (prev) => prev as CartState) + + expect(received).toEqual(["A"]) + }) + + it("clear empties the event log", () => { + const store = createStore({ items: [], total: 0 }) + const es = createEventSource(store) + + es.dispatch("A", null, (prev) => prev as CartState) + es.dispatch("B", null, (prev) => prev as CartState) + expect(es.size()).toBe(2) + + es.clear() + expect(es.size()).toBe(0) + expect(es.events()).toHaveLength(0) + }) + + it("prunes events when exceeding maxEvents", () => { + const store = createStore({ items: [], total: 0 }) + const es = createEventSource(store, { maxEvents: 3 }) + + for (let i = 0; i < 5; i++) { + es.dispatch("E", i, (prev) => prev as CartState) + } + + expect(es.size()).toBe(3) + expect(es.events()[0].payload).toBe(2) + }) + + it("replay reconstructs state from events", () => { + const store = createStore({ items: [], total: 0 }) + const es = createEventSource(store) + const addReducer = (prev: DeepReadonly, p: string) => ({ + items: [...prev.items, p], + total: prev.total + 1, + }) + + es.dispatch("ADD", "A", addReducer) + es.dispatch("ADD", "B", addReducer) + es.dispatch("ADD", "C", addReducer) + + // Mess up the state + store.set({ items: [], total: 999 }) + expect(store.read().total).toBe(999) + + // Replay should reconstruct + es.replay({ items: [], total: 0 }) + expect(store.read().items).toEqual(["A", "B", "C"]) + expect(store.read().total).toBe(3) + }) +}) diff --git a/tests/patch.test.ts b/tests/patch.test.ts new file mode 100644 index 0000000..8044e41 --- /dev/null +++ b/tests/patch.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi } from 'vitest' +import { createStore } from '../src/store' + +describe('Store.patch()', () => { + it('merges object partial with current state', () => { + const store = createStore({ count: 0, text: 'hello' }) + store.patch({ count: 5 }) + expect(store.read()).toEqual({ count: 5, text: 'hello' }) + }) + + it('works with function partial', () => { + const store = createStore({ count: 10, text: 'hello' }) + store.patch(prev => ({ count: prev.count + 5 })) + expect(store.read()).toEqual({ count: 15, text: 'hello' }) + }) + + it('increments version', () => { + const store = createStore({ a: 1 }) + expect(store.version).toBe(0) + store.patch({ a: 2 }) + expect(store.version).toBe(1) + }) + + it('notifies subscribers only when selected value changes', () => { + const store = createStore({ a: 1, b: 2 }) + const subscriber = vi.fn() + + store.subscribe(state => state.a, subscriber) + + // Changing b shouldn't trigger subscriber for a + store.patch({ b: 3 }) + expect(subscriber).not.toHaveBeenCalled() + + // Changing a should trigger subscriber + store.patch({ a: 5 }) + expect(subscriber).toHaveBeenCalledTimes(1) + expect(subscriber).toHaveBeenCalledWith(5) + }) + + it('preserves unmentioned properties', () => { + const store = createStore({ a: 1, b: { c: 2 }, d: [1, 2] }) + const originalB = store.read().b + const originalD = store.read().d + + store.patch({ a: 10 }) + + expect(store.read().b).toBe(originalB) + expect(store.read().d).toBe(originalD) + }) +}) diff --git a/tests/persist.fs.test.ts b/tests/persist.fs.test.ts index b7f3aca..c92869b 100644 --- a/tests/persist.fs.test.ts +++ b/tests/persist.fs.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest" import { createStore } from "../src" -import { persistFs } from "../src" +import { persistFs } from "../src/node/index.js" import { promises as fs } from "node:fs" import os from "node:os" import path from "node:path" diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts new file mode 100644 index 0000000..1480ab1 --- /dev/null +++ b/tests/plugin.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest" +import { createStore } from "../src" +import { registerPlugin, getPlugins, destroyPlugins, logger, freeze } from "../src/plugin" + +type S = { count: number; label: string } + +describe("plugin system", () => { + it("registers a plugin and fires onInit", () => { + const store = createStore({ count: 0, label: "a" }) + let initCalled = false + const plugin = { + name: "test-plugin", + onInit() { initCalled = true } + } + registerPlugin(store, plugin) + expect(initCalled).toBe(true) + }) + + it("getPlugins returns registered plugins", () => { + const store = createStore({ count: 0, label: "a" }) + const p1 = { name: "p1" } + const p2 = { name: "p2" } + registerPlugin(store, p1) + registerPlugin(store, p2) + const plugins = getPlugins(store) + expect(plugins).toHaveLength(2) + expect(plugins[0].name).toBe("p1") + expect(plugins[1].name).toBe("p2") + }) + + it("unregister removes plugin and calls cleanup", () => { + const store = createStore({ count: 0, label: "a" }) + let cleaned = false + const plugin = { + name: "cleanup-test", + onInit() { return () => { cleaned = true } } + } + const unregister = registerPlugin(store, plugin) + expect(getPlugins(store)).toHaveLength(1) + unregister() + expect(getPlugins(store)).toHaveLength(0) + expect(cleaned).toBe(true) + }) + + it("destroyPlugins calls onDestroy and cleanup for all", () => { + const store = createStore({ count: 0, label: "a" }) + let destroyed1 = false + let destroyed2 = false + registerPlugin(store, { name: "p1", onDestroy() { destroyed1 = true } }) + registerPlugin(store, { name: "p2", onDestroy() { destroyed2 = true } }) + destroyPlugins(store) + expect(destroyed1).toBe(true) + expect(destroyed2).toBe(true) + expect(getPlugins(store)).toHaveLength(0) + }) + + it("empty store has no plugins", () => { + const store = createStore({ count: 0, label: "a" }) + expect(getPlugins(store)).toHaveLength(0) + }) +}) + +describe("built-in plugins", () => { + it("logger plugin has correct name", () => { + const plugin = logger({ name: "MyLogger" }) + expect(plugin.name).toBe("MyLogger") + }) + + it("freeze plugin freezes state", () => { + const plugin = freeze() + expect(plugin.name).toBe("ExostateFreeze") + const frozen = plugin.onBeforeUpdate!( + { count: 0, label: "a" }, + { count: 1, label: "b" } + ) as S + expect(frozen).toEqual({ count: 1, label: "b" }) + expect(Object.isFrozen(frozen)).toBe(true) + }) +}) diff --git a/tests/public-api.test.ts b/tests/public-api.test.ts new file mode 100644 index 0000000..32799b7 --- /dev/null +++ b/tests/public-api.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect } from "vitest" +import * as exostate from "../src/index.js" +import * as reactAdapter from "../src/react/index.js" +import * as reactQuery from "../src/react/query.js" +import * as svelteAdapter from "../src/svelte/index.js" + +/** + * Guards the documented surface. Every name here is promised by the README's + * API reference, so removing or renaming one is a breaking change that should + * fail loudly rather than silently rot the docs. + */ +const CORE_EXPORTS = [ + // core + "createStore", "StoreImpl", "createState", "defineStore", + "storeFactory", "cachedStoreFactory", "combineStores", + "computed", "derive", "shallow", "deepEqual", + // query + "QueryClient", "createMutation", "hashQueryKey", + // persistence / integrity + "persistLocal", "persistIndexedDB", "createHistory", "beginTransaction", + "createEventSource", "createSerializer", "dehydrate", "rehydrate", + // plugins / observability + "withMiddleware", "logger", "freeze", "registerPlugin", "getPlugins", + "destroyPlugins", "devtoolsMiddleware", "connectReduxDevTools", + // errors / validation + "SafeError", "createError", "isSafeError", "toSafeError", "applyPolicy", + "fromZod", "fromPredicate", + // async + "asyncAction", +] as const + +const STORE_METHODS = [ + "read", "snapshot", "patch", "set", "update", "compute", "batch", + "effect", "subscribe", "use", "plugins", "flush", "destroy", +] as const + +const QUERY_CLIENT_METHODS = [ + "watch", "fetchQuery", "prefetchQuery", "getQueryData", "setQueryData", + "getQueryState", "invalidateQueries", "refetchQueries", "cancelQueries", + "removeQueries", "dehydrate", "hydrate", "size", "clear", +] as const + +describe("public API surface", () => { + it.each(CORE_EXPORTS)("exports %s from the core entry", (name) => { + expect(exostate[name as keyof typeof exostate]).toBeDefined() + }) + + it.each(STORE_METHODS)("store exposes %s", (method) => { + const store = exostate.createStore({ n: 0 }) + expect(typeof store[method as keyof typeof store]).toBe("function") + }) + + it("store exposes version and destroyed", () => { + const store = exostate.createStore({ n: 0 }) + expect(typeof store.version).toBe("number") + expect(typeof store.destroyed).toBe("boolean") + }) + + it.each(QUERY_CLIENT_METHODS)("QueryClient exposes %s", (method) => { + const client = new exostate.QueryClient() + expect(typeof client[method as keyof exostate.QueryClient]).toBe("function") + client.clear() + }) + + it("does not leak node-only filesystem persistence into the core entry", () => { + // persistFs must stay behind `exostate/node`, or bundlers pull in node:fs. + expect("persistFs" in exostate).toBe(false) + }) + + it("exports the documented React hooks", () => { + for (const hook of ["useStore", "useSelector", "useStores", "useCombined", "useStoresSelector"]) { + expect(typeof reactAdapter[hook as keyof typeof reactAdapter]).toBe("function") + } + }) + + it("exports the documented React query hooks", () => { + for (const name of ["QueryClientProvider", "useQuery", "useMutation", "useQueryClient", "useInvalidateQueries"]) { + expect(typeof reactQuery[name as keyof typeof reactQuery]).toBe("function") + } + }) + + it("exports the documented Svelte adapters", () => { + expect(typeof svelteAdapter.exostore).toBe("function") + expect(typeof svelteAdapter.exoselector).toBe("function") + }) +}) + +describe("README examples still work", () => { + it("quick start: patch, update and scoped subscribe", () => { + interface CartState { + items: Array<{ id: string; qty: number }> + coupon: string | null + } + const cart = exostate.createStore({ items: [], coupon: null }) + + cart.patch({ coupon: "SUMMER25" }) + cart.patch(prev => ({ items: [...prev.items, { id: "sku-1", qty: 1 }] })) + + const addItem = (prev: CartState, item: { id: string; qty: number }) => ({ + ...prev, + items: [...prev.items, item], + }) + cart.update(addItem, { id: "sku-2", qty: 3 }) + + const seen: number[] = [] + const unsubscribe = cart.subscribe(s => s.items.length, n => seen.push(n)) + cart.patch(prev => ({ items: [...prev.items, { id: "sku-3", qty: 1 }] })) + unsubscribe() + + expect(cart.read().coupon).toBe("SUMMER25") + expect(cart.read().items).toHaveLength(3) + expect(seen).toEqual([3]) + }) + + it("batch produces a single notification", () => { + const store = exostate.createStore({ count: 1 }) + let calls = 0 + store.subscribe(s => s.count, () => { calls++ }) + + store.batch(apply => { + apply((prev, by: number) => ({ count: prev.count + by }), 1) + apply((prev, by: number) => ({ count: prev.count * by }), 3) + }) + + expect(store.read().count).toBe(6) + expect(calls).toBe(1) + }) + + it("transaction stages changes and seals after commit", () => { + const store = exostate.createStore({ total: 0 }) + const tx = exostate.beginTransaction(store) + tx.apply((prev, n: number) => ({ total: prev.total + n }), 10) + + expect(store.read().total).toBe(0) // untouched while staged + tx.commit() + expect(store.read().total).toBe(10) + expect(() => tx.commit()).toThrow() + }) + + it("history undo/redo/jumpTo", () => { + const store = exostate.createStore({ count: 0 }) + const history = exostate.createHistory(store, { limit: 50 }) + history.attach() + + store.patch({ count: 1 }) + store.patch({ count: 2 }) + + history.undo() + expect(store.read().count).toBe(1) + history.redo() + expect(store.read().count).toBe(2) + history.jumpTo(0) + expect(store.read().count).toBe(0) + + history.detach() + }) + + it("defineStore co-locates actions with state", () => { + const counter = exostate.defineStore<{ + count: number + increment: () => void + reset: () => void + }>((set) => ({ + count: 0, + increment: () => set(s => ({ ...s, count: s.count + 1 })), + reset: () => set({ count: 0 }), + })) + + counter.read().increment() + counter.read().increment() + expect(counter.read().count).toBe(2) + counter.read().reset() + expect(counter.read().count).toBe(0) + }) + + it("cachedStoreFactory returns one instance per key", () => { + const stores = exostate.cachedStoreFactory((userId: string) => ({ id: userId, name: "" })) + expect(stores.get("u1")).toBe(stores.get("u1")) + expect(stores.get("u1")).not.toBe(stores.get("u2")) + stores.delete("u1") + expect(stores.has("u1")).toBe(false) + }) + + it("createError produces a named SafeError", () => { + const err = exostate.createError("NOT_FOUND", "User does not exist", { id: 42 }) + expect(err.name).toBe("SafeError") + expect(err.code).toBe("NOT_FOUND") + expect(err.details).toEqual({ id: 42 }) + expect(exostate.isSafeError(err)).toBe(true) + }) + + it("createSerializer migrates across versions and rejects future ones", () => { + interface V3 { count: number; theme: string; locale: string } + const serializer = exostate.createSerializer(3, { + validate: (x): x is V3 => typeof x === "object" && x !== null, + migrations: { + 1: (v1) => ({ ...(v1 as object), theme: "light" }), + 2: (v2) => ({ ...(v2 as object), locale: "en" }), + }, + }) + + const fromV1 = serializer.decode(JSON.stringify({ v: 1, data: { count: 5 } })) + expect(fromV1).toEqual({ count: 5, theme: "light", locale: "en" }) + + expect(() => serializer.decode(JSON.stringify({ v: 9, data: {} }))).toThrow() + }) +}) diff --git a/tests/query.test.ts b/tests/query.test.ts new file mode 100644 index 0000000..20bfedf --- /dev/null +++ b/tests/query.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, vi } from "vitest" +import { QueryClient, createMutation, hashQueryKey } from "../src/query.js" + +const tick = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms)) + +describe("hashQueryKey", () => { + it("is stable regardless of object key order", () => { + expect(hashQueryKey(["u", { a: 1, b: 2 }])).toBe(hashQueryKey(["u", { b: 2, a: 1 }])) + }) + + it("distinguishes different keys", () => { + expect(hashQueryKey(["u", 1])).not.toBe(hashQueryKey(["u", 2])) + }) +}) + +describe("QueryClient", () => { + it("fetches and caches data", async () => { + const client = new QueryClient() + const queryFn = vi.fn().mockResolvedValue({ name: "ada" }) + + const data = await client.fetchQuery({ queryKey: ["user", 1], queryFn }) + expect(data).toEqual({ name: "ada" }) + expect(client.getQueryData(["user", 1])).toEqual({ name: "ada" }) + client.clear() + }) + + it("deduplicates concurrent requests for the same key", async () => { + const client = new QueryClient() + let calls = 0 + const queryFn = async () => { + calls++ + await tick(20) + return calls + } + + const [a, b, c] = await Promise.all([ + client.fetchQuery({ queryKey: ["dedupe"], queryFn }), + client.fetchQuery({ queryKey: ["dedupe"], queryFn }), + client.fetchQuery({ queryKey: ["dedupe"], queryFn }), + ]) + + expect(calls).toBe(1) + expect([a, b, c]).toEqual([1, 1, 1]) + client.clear() + }) + + it("serves fresh cache without refetching within staleTime", async () => { + const client = new QueryClient() + const queryFn = vi.fn().mockResolvedValue("v1") + + await client.fetchQuery({ queryKey: ["fresh"], queryFn, staleTime: 10_000 }) + await client.fetchQuery({ queryKey: ["fresh"], queryFn, staleTime: 10_000 }) + + expect(queryFn).toHaveBeenCalledTimes(1) + client.clear() + }) + + it("refetches once data goes stale", async () => { + let clock = 1000 + const client = new QueryClient({ now: () => clock }) + const queryFn = vi.fn().mockResolvedValue("v") + + await client.fetchQuery({ queryKey: ["stale"], queryFn, staleTime: 100 }) + clock += 500 + await client.fetchQuery({ queryKey: ["stale"], queryFn, staleTime: 100 }) + + expect(queryFn).toHaveBeenCalledTimes(2) + client.clear() + }) + + it("retries with backoff and eventually succeeds", async () => { + const client = new QueryClient() + let attempts = 0 + const queryFn = async () => { + attempts++ + if (attempts < 3) throw new Error("boom") + return "ok" + } + + const data = await client.fetchQuery({ + queryKey: ["retry"], + queryFn, + retry: 3, + retryDelay: 1, + }) + + expect(data).toBe("ok") + expect(attempts).toBe(3) + client.clear() + }) + + it("surfaces an error state after retries are exhausted", async () => { + const client = new QueryClient() + const queryFn = async () => { throw new Error("always fails") } + + await expect( + client.fetchQuery({ queryKey: ["fail"], queryFn, retry: 0 }) + ).rejects.toThrow("always fails") + + const state = client.getQueryState(["fail"]) + expect(state?.isError).toBe(true) + expect(state?.status).toBe("error") + expect(state?.error?.message).toBe("always fails") + client.clear() + }) + + it("serves stale data while revalidating in the background", async () => { + let clock = 0 + const client = new QueryClient({ now: () => clock }) + let version = 0 + const queryFn = async () => { + version++ + await tick(10) + return `v${version}` + } + + const observer = client.watch({ queryKey: ["swr"], queryFn, staleTime: 50 }) + await tick(30) + expect(observer.getState().data).toBe("v1") + + clock += 1000 // now stale + const refetching = observer.refetch() + // Cached data stays visible while the new request is in flight. + expect(observer.getState().data).toBe("v1") + expect(observer.getState().isFetching).toBe(true) + + await refetching + expect(observer.getState().data).toBe("v2") + expect(observer.getState().isFetching).toBe(false) + + observer.destroy() + client.clear() + }) + + it("invalidates by key prefix and refetches observed queries", async () => { + const client = new QueryClient() + const userFn = vi.fn().mockResolvedValue("user") + const postFn = vi.fn().mockResolvedValue("post") + + const users = client.watch({ queryKey: ["users", 1], queryFn: userFn, staleTime: 10_000 }) + const posts = client.watch({ queryKey: ["posts", 1], queryFn: postFn, staleTime: 10_000 }) + await tick(5) + expect(userFn).toHaveBeenCalledTimes(1) + expect(postFn).toHaveBeenCalledTimes(1) + + await client.invalidateQueries({ queryKey: ["users"] }) + + expect(userFn).toHaveBeenCalledTimes(2) + expect(postFn).toHaveBeenCalledTimes(1) // untouched by the prefix filter + + users.destroy() + posts.destroy() + client.clear() + }) + + it("garbage collects entries once the last observer leaves", async () => { + const client = new QueryClient() + const observer = client.watch({ + queryKey: ["gc"], + queryFn: async () => "x", + gcTime: 20, + }) + await tick(5) + expect(client.size()).toBe(1) + + observer.destroy() + expect(client.size()).toBe(1) // still within the gc window + + await tick(40) + expect(client.size()).toBe(0) + client.clear() + }) + + it("setQueryData writes the cache directly", () => { + const client = new QueryClient() + client.setQueryData(["todos"], [{ id: 1 }]) + expect(client.getQueryData(["todos"])).toEqual([{ id: 1 }]) + + client.setQueryData>(["todos"], prev => [...(prev ?? []), { id: 2 }]) + expect(client.getQueryData(["todos"])).toEqual([{ id: 1 }, { id: 2 }]) + client.clear() + }) + + it("does not fetch while disabled", async () => { + const client = new QueryClient() + const queryFn = vi.fn().mockResolvedValue("x") + const observer = client.watch({ queryKey: ["off"], queryFn, enabled: false }) + await tick(10) + expect(queryFn).not.toHaveBeenCalled() + observer.destroy() + client.clear() + }) + + it("falls back to placeholderData before the first resolution", async () => { + const client = new QueryClient() + const observer = client.watch({ + queryKey: ["placeholder"], + queryFn: async () => { await tick(20); return "real" }, + placeholderData: "placeholder", + }) + expect(observer.getState().data).toBe("placeholder") + await tick(40) + expect(observer.getState().data).toBe("real") + observer.destroy() + client.clear() + }) + + it("seeds the cache from initialData", () => { + const client = new QueryClient() + const observer = client.watch({ + queryKey: ["seeded"], + queryFn: async () => "fetched", + initialData: "seed", + staleTime: 10_000, + }) + expect(observer.getState().data).toBe("seed") + observer.destroy() + client.clear() + }) +}) + +describe("QueryClient SSR", () => { + it("round-trips through dehydrate/hydrate without refetching", async () => { + const server = new QueryClient() + await server.prefetchQuery({ queryKey: ["user", 7], queryFn: async () => ({ id: 7 }) }) + + // Must survive the JSON boundary between server and client. + const wire = JSON.parse(JSON.stringify(server.dehydrate())) as ReturnType + expect(wire.queries).toHaveLength(1) + + const client = new QueryClient() + client.hydrate(wire) + expect(client.getQueryData(["user", 7])).toEqual({ id: 7 }) + + const queryFn = vi.fn().mockResolvedValue({ id: 7 }) + const data = await client.fetchQuery({ queryKey: ["user", 7], queryFn, staleTime: 60_000 }) + expect(data).toEqual({ id: 7 }) + expect(queryFn).not.toHaveBeenCalled() // hydrated data was still fresh + + server.clear() + client.clear() + }) + + it("omits failed queries from the dehydrated payload", async () => { + const client = new QueryClient() + await client.prefetchQuery({ + queryKey: ["broken"], + queryFn: async () => { throw new Error("nope") }, + retry: 0, + }) + expect(client.dehydrate().queries).toHaveLength(0) + client.clear() + }) +}) + +describe("createMutation", () => { + it("tracks loading then success", async () => { + const mutation = createMutation({ mutationFn: async (n: number) => n * 2 }) + expect(mutation.getState().status).toBe("idle") + + const promise = mutation.mutate(21) + expect(mutation.getState().isLoading).toBe(true) + + await expect(promise).resolves.toBe(42) + expect(mutation.getState().data).toBe(42) + expect(mutation.getState().isSuccess).toBe(true) + }) + + it("rolls back an optimistic update through onMutate context", async () => { + const client = new QueryClient() + client.setQueryData(["todos"], ["a"]) + + const mutation = createMutation({ + mutationFn: async () => { throw new Error("server rejected") }, + onMutate: (text) => { + const previous = client.getQueryData(["todos"]) + client.setQueryData(["todos"], old => [...(old ?? []), text]) + return previous + }, + onError: (_error, _vars, previous) => { + client.setQueryData(["todos"], previous ?? []) + }, + }) + + await expect(mutation.mutate("b")).rejects.toThrow("server rejected") + expect(client.getQueryData(["todos"])).toEqual(["a"]) // rolled back + expect(mutation.getState().isError).toBe(true) + client.clear() + }) + + it("reset returns to idle", async () => { + const mutation = createMutation({ mutationFn: async () => "x" }) + await mutation.mutate(undefined as void) + expect(mutation.getState().isSuccess).toBe(true) + mutation.reset() + expect(mutation.getState().status).toBe("idle") + expect(mutation.getState().data).toBeUndefined() + }) +}) diff --git a/tests/react.query.test.ts b/tests/react.query.test.ts new file mode 100644 index 0000000..687436a --- /dev/null +++ b/tests/react.query.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, afterEach } from "vitest" +import { render, screen, waitFor, act, cleanup } from "@testing-library/react" +import React from "react" +import { QueryClient } from "../src/query.js" +import { QueryClientProvider, useQuery, useMutation } from "../src/react/query.js" + +afterEach(cleanup) + +const withClient = (client: QueryClient, node: React.ReactElement) => + React.createElement(QueryClientProvider, { client }, node) + +describe("useQuery", () => { + it("moves from loading to data", async () => { + const client = new QueryClient() + + function View() { + const { data, isLoading } = useQuery({ + queryKey: ["greeting"], + queryFn: async () => "hello", + }) + return React.createElement("span", { "data-testid": "out" }, isLoading ? "loading" : data) + } + + render(withClient(client, React.createElement(View))) + expect(screen.getByTestId("out").textContent).toBe("loading") + + await waitFor(() => { + expect(screen.getByTestId("out").textContent).toBe("hello") + }) + client.clear() + }) + + it("shares one request between two components using the same key", async () => { + const client = new QueryClient() + const queryFn = vi.fn().mockResolvedValue("shared") + + function View({ id }: { id: string }) { + const { data } = useQuery({ queryKey: ["shared"], queryFn, staleTime: 10_000 }) + return React.createElement("span", { "data-testid": id }, data ?? "") + } + + render(withClient(client, React.createElement( + "div", + null, + React.createElement(View, { id: "a", key: "a" }), + React.createElement(View, { id: "b", key: "b" }) + ))) + + await waitFor(() => { + expect(screen.getByTestId("a").textContent).toBe("shared") + expect(screen.getByTestId("b").textContent).toBe("shared") + }) + expect(queryFn).toHaveBeenCalledTimes(1) + client.clear() + }) + + it("renders the error state when the query fails", async () => { + const client = new QueryClient() + + function View() { + const { isError, error } = useQuery({ + queryKey: ["bad"], + queryFn: async () => { throw new Error("kaput") }, + retry: 0, + }) + return React.createElement("span", { "data-testid": "out" }, isError ? error?.message : "…") + } + + render(withClient(client, React.createElement(View))) + await waitFor(() => { + expect(screen.getByTestId("out").textContent).toBe("kaput") + }) + client.clear() + }) + + it("throws a helpful error when no provider is present", () => { + function View() { + useQuery({ queryKey: ["x"], queryFn: async () => "x" }) + return null + } + const spy = vi.spyOn(console, "error").mockImplementation(() => {}) + expect(() => render(React.createElement(View))).toThrow(/No QueryClient found/) + spy.mockRestore() + }) +}) + +describe("useMutation", () => { + it("tracks loading and success through mutateAsync", async () => { + const client = new QueryClient() + let handle: { mutateAsync: (v: number) => Promise } | null = null + + function View() { + const m = useMutation({ mutationFn: async (n) => n * 2 }) + handle = m + return React.createElement( + "span", + { "data-testid": "out" }, + m.isLoading ? "saving" : String(m.data ?? "idle") + ) + } + + render(withClient(client, React.createElement(View))) + expect(screen.getByTestId("out").textContent).toBe("idle") + + await act(async () => { await handle!.mutateAsync(21) }) + await waitFor(() => { + expect(screen.getByTestId("out").textContent).toBe("42") + }) + client.clear() + }) +}) diff --git a/tests/react.selector.stability.test.ts b/tests/react.selector.stability.test.ts new file mode 100644 index 0000000..0320ac5 --- /dev/null +++ b/tests/react.selector.stability.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, afterEach } from "vitest" +import { render, screen, act, cleanup } from "@testing-library/react" +import React from "react" +import { createStore, shallow } from "../src/index.js" +import { useSelector } from "../src/react/index.js" + +afterEach(cleanup) + +const span = (testid: string, text: string) => + React.createElement("span", { "data-testid": testid }, text) + +describe("useSelector stability", () => { + it("survives an inline selector that allocates a new object each call", () => { + const store = createStore({ a: 1, b: 2, unrelated: 0 }) + let renders = 0 + + function View() { + renders++ + // The classic footgun: a fresh object every call. With an uncached + // getSnapshot this trips React's "getSnapshot should be cached to avoid + // an infinite loop" error. + const slice = useSelector(store, s => ({ a: s.a, b: s.b })) + return span("out", `${slice.a}-${slice.b}`) + } + + render(React.createElement(View)) + expect(screen.getByTestId("out").textContent).toBe("1-2") + + const rendersAfterMount = renders + act(() => { store.patch({ unrelated: 99 }) }) + + // The guarantee is that render count stays bounded. Under the default + // `Object.is` comparator the freshly allocated slice still counts as a + // change, so exactly one re-render is expected β€” not the unbounded cascade + // an uncached getSnapshot would produce. + expect(renders).toBe(rendersAfterMount + 1) + expect(screen.getByTestId("out").textContent).toBe("1-2") + }) + + it("skips the re-render entirely when compared with shallow", () => { + const store = createStore({ a: 1, b: 2, unrelated: 0 }) + let renders = 0 + + function View() { + renders++ + const slice = useSelector( + store, + s => ({ a: s.a, b: s.b }), + shallow as (x: { a: number; b: number }, y: { a: number; b: number }) => boolean + ) + return span("out", `${slice.a}-${slice.b}`) + } + + render(React.createElement(View)) + const rendersAfterMount = renders + + act(() => { store.patch({ unrelated: 99 }) }) + + expect(renders).toBe(rendersAfterMount) + expect(screen.getByTestId("out").textContent).toBe("1-2") + }) + + it("re-renders when the selected slice actually changes", () => { + const store = createStore({ a: 1, b: 2 }) + + function View() { + const slice = useSelector(store, s => ({ a: s.a }), shallow as (x: { a: number }, y: { a: number }) => boolean) + return span("out", String(slice.a)) + } + + render(React.createElement(View)) + expect(screen.getByTestId("out").textContent).toBe("1") + + act(() => { store.patch({ a: 5 }) }) + expect(screen.getByTestId("out").textContent).toBe("5") + }) + + it("does not resubscribe on every render when the selector is inline", () => { + const store = createStore({ n: 0, other: 0 }) + const subscribeSpy = vi.spyOn(store, "subscribe") + + function View() { + const n = useSelector(store, s => s.n) + return span("out", String(n)) + } + + render(React.createElement(View)) + const afterMount = subscribeSpy.mock.calls.length + + act(() => { store.patch({ n: 1 }) }) + act(() => { store.patch({ n: 2 }) }) + + // A new selector identity per render must not tear down the subscription. + expect(subscribeSpy.mock.calls.length).toBe(afterMount) + expect(screen.getByTestId("out").textContent).toBe("2") + subscribeSpy.mockRestore() + }) + + it("keeps a stable reference for an equal slice across store versions", () => { + const store = createStore({ a: 1, tick: 0 }) + const seen: Array<{ a: number }> = [] + + function View() { + const slice = useSelector(store, s => ({ a: s.a }), shallow as (x: { a: number }, y: { a: number }) => boolean) + seen.push(slice) + return span("out", String(slice.a)) + } + + render(React.createElement(View)) + act(() => { store.patch({ tick: 1 }) }) + act(() => { store.patch({ tick: 2 }) }) + + // Every render observed the same object identity, so memo/effect deps + // downstream stay stable. + for (const s of seen) expect(s).toBe(seen[0]) + }) +}) diff --git a/tests/regressions.test.ts b/tests/regressions.test.ts new file mode 100644 index 0000000..66746c8 --- /dev/null +++ b/tests/regressions.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect, vi } from "vitest" +import { createStore, combineStores, withMiddleware, shallow, deepEqual } from "../src/index.js" +import { asyncAction } from "../src/async-action.js" +import type { StoreImpl } from "../src/store.js" + +const tick = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms)) + +describe("combineStores: read without an active subscription", () => { + it("returns fresh child state when never subscribed", () => { + const a = createStore({ n: 1 }) + const combined = combineStores({ a }) + + a.set({ n: 2 }) + + // Previously `current` was frozen at construction time because nothing was + // attached to the child stores. + expect(combined.read()).toEqual({ a: { n: 2 } }) + }) + + it("catches up on changes that happened while detached", () => { + const a = createStore({ n: 1 }) + const combined = combineStores({ a }) + + const unsub = combined.subscribe(() => {}) + a.set({ n: 2 }) + unsub() // last subscriber leaves -> detaches from child stores + + a.set({ n: 3 }) // changes while nobody is attached + + const seen: Array<{ a: { n: number } }> = [] + combined.subscribe(s => { seen.push(s as { a: { n: number } }) }, { fireImmediately: true }) + + expect(seen[0]).toEqual({ a: { n: 3 } }) + }) + + it("does not notify when a child commits an identical snapshot", () => { + const a = createStore({ n: 1 }) + const combined = combineStores({ a }) + let calls = 0 + combined.subscribe(() => { calls++ }) + + const same = a.read() + a.set(same as { n: number }) // same reference committed again + + expect(calls).toBe(0) + }) +}) + +describe("asyncAction: concurrent invocations", () => { + it("aborting one invocation does not cancel another", async () => { + const store = createStore({ value: "" }) + const action = asyncAction(store, async (_s, value: string, delay: number) => { + await tick(delay) + return { value } + }) + + const first = action("first", 50) + const second = action("second", 10) + first.abort() // must only cancel `first` + + await second + await tick(80) + + expect(store.read().value).toBe("second") + }) + + it("discards a slow earlier result in favour of the latest invocation", async () => { + const store = createStore({ value: "" }) + const action = asyncAction(store, async (_s, value: string, delay: number) => { + await tick(delay) + return { value } + }) + + const slow = action("slow", 60) + const fast = action("fast", 5) + + await fast + await slow + // Without latestOnly, the slow request would land last and clobber "fast". + expect(store.read().value).toBe("fast") + }) + + it("onStart applies before the async work runs", async () => { + const store = createStore({ loading: false, value: "" }) + const action = asyncAction(store, async () => { + await tick(10) + return { value: "done", loading: false } + }, { onStart: () => ({ loading: true }) }) + + const promise = action() + expect(store.read().loading).toBe(true) + await promise + expect(store.read()).toEqual({ loading: false, value: "done" }) + }) +}) + +describe("store: plugin pipeline is actually wired", () => { + it("fires onBeforeUpdate and onAfterUpdate on every mutation", () => { + const store = createStore({ n: 0 }) + const before = vi.fn() + const after = vi.fn() + store.use({ name: "spy", onBeforeUpdate: before, onAfterUpdate: after }) + + store.set({ n: 1 }) + store.patch({ n: 2 }) + store.update((s, p: number) => ({ n: s.n + p }), 1) + store.compute(s => ({ n: s.n + 1 })) + store.batch(apply => apply((s, p: number) => ({ n: s.n + p }), 1)) + + expect(before).toHaveBeenCalledTimes(5) + expect(after).toHaveBeenCalledTimes(5) + }) + + it("lets onBeforeUpdate transform the committed value", () => { + const store = createStore({ n: 0 }) + store.use({ + name: "clamp", + onBeforeUpdate: (_prev, next) => ({ n: Math.min(next.n, 10) }) + }) + + store.set({ n: 999 }) + expect(store.read()).toEqual({ n: 10 }) + }) + + it("detaching a plugin stops its hooks", () => { + const store = createStore({ n: 0 }) + const after = vi.fn() + const detach = store.use({ name: "spy", onAfterUpdate: after }) + + store.set({ n: 1 }) + detach() + store.set({ n: 2 }) + + expect(after).toHaveBeenCalledTimes(1) + }) + + it("destroy fires onDestroy for attached plugins", () => { + const store = createStore({ n: 0 }) + const onDestroy = vi.fn() + store.use({ name: "p", onDestroy }) + store.destroy() + expect(onDestroy).toHaveBeenCalledTimes(1) + }) +}) + +describe("store: lifecycle hooks", () => { + it("reports listener counts on subscribe and unsubscribe", () => { + const events: string[] = [] + const store = createStore({ n: 0 }, { + onSubscribe: (_s, count) => { events.push(`sub:${count}`) }, + onUnsubscribe: (_s, count) => { events.push(`unsub:${count}`) }, + }) + + const a = store.subscribe(s => s.n, () => {}) + const b = store.subscribe(s => s.n, () => {}) + a() + b() + + expect(events).toEqual(["sub:1", "sub:2", "unsub:1", "unsub:0"]) + }) + + it("is idempotent when unsubscribe is called twice", () => { + const onUnsubscribe = vi.fn() + const store = createStore({ n: 0 }, { onUnsubscribe }) + const unsub = store.subscribe(s => s.n, () => {}) + unsub() + unsub() + expect(onUnsubscribe).toHaveBeenCalledTimes(1) + }) + + it("debounces teardown by unmountDelay so a quick remount keeps resources", async () => { + const onUnsubscribe = vi.fn() + const store = createStore({ n: 0 }, { onUnsubscribe, unmountDelay: 30 }) + + const first = store.subscribe(s => s.n, () => {}) + first() + // Remount inside the grace window β€” teardown must be cancelled. + const second = store.subscribe(s => s.n, () => {}) + await tick(50) + expect(onUnsubscribe).not.toHaveBeenCalled() + + second() + await tick(50) + expect(onUnsubscribe).toHaveBeenCalledTimes(1) + }) +}) + +describe("store: microtask batching", () => { + it("collapses synchronous mutations into one notification", async () => { + const store = createStore({ n: 0 }, { notify: "microtask" }) + let calls = 0 + store.subscribe(s => s.n, () => { calls++ }) + + store.patch({ n: 1 }) + store.patch({ n: 2 }) + store.patch({ n: 3 }) + + expect(calls).toBe(0) // nothing delivered yet + await Promise.resolve() + expect(calls).toBe(1) + expect(store.read().n).toBe(3) + }) + + it("sync mode still notifies once per mutation", () => { + const store = createStore({ n: 0 }) + let calls = 0 + store.subscribe(s => s.n, () => { calls++ }) + store.patch({ n: 1 }) + store.patch({ n: 2 }) + expect(calls).toBe(2) + }) + + it("flush delivers a queued notification immediately", () => { + const store = createStore({ n: 0 }, { notify: "microtask" }) + let calls = 0 + store.subscribe(s => s.n, () => { calls++ }) + store.patch({ n: 1 }) + store.flush() + expect(calls).toBe(1) + }) +}) + +describe("withMiddleware: passthrough completeness", () => { + it("proxies destroyed, listeners and current", () => { + const base = createStore({ n: 0 }) + const wrapped = withMiddleware(base, []) + + expect(wrapped.destroyed).toBe(false) + expect((wrapped as unknown as StoreImpl<{ n: number }>).current).toEqual({ n: 0 }) + + wrapped.subscribe(s => s.n, () => {}) + expect((wrapped as unknown as StoreImpl<{ n: number }>).listeners.size).toBe(1) + + wrapped.destroy() + expect(wrapped.destroyed).toBe(true) + }) + + it("proxies the plugin API onto the wrapped store", () => { + const base = createStore({ n: 0 }) + const wrapped = withMiddleware(base, []) + const after = vi.fn() + wrapped.use({ name: "p", onAfterUpdate: after }) + + wrapped.set({ n: 1 }) + expect(after).toHaveBeenCalledTimes(1) + expect(wrapped.plugins()).toHaveLength(1) + }) +}) + +describe("equality helpers", () => { + it("shallow compares one level deep", () => { + expect(shallow({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true) + expect(shallow({ a: 1 }, { a: 2 })).toBe(false) + expect(shallow({ a: { n: 1 } }, { a: { n: 1 } })).toBe(false) // nested refs differ + expect(shallow([1, 2], [1, 2])).toBe(true) + expect(shallow([1, 2], [1, 2, 3])).toBe(false) + expect(shallow({ a: 1 }, { a: 1, b: 2 })).toBe(false) + }) + + it("deepEqual recurses", () => { + expect(deepEqual({ a: { n: [1, 2] } }, { a: { n: [1, 2] } })).toBe(true) + expect(deepEqual({ a: { n: [1, 2] } }, { a: { n: [1, 3] } })).toBe(false) + expect(deepEqual(new Date(5), new Date(5))).toBe(true) + expect(deepEqual(new Map([["a", 1]]), new Map([["a", 1]]))).toBe(true) + expect(deepEqual(new Set([1, 2]), new Set([2, 1]))).toBe(true) + }) +}) diff --git a/tests/store-factory.test.ts b/tests/store-factory.test.ts new file mode 100644 index 0000000..5e2ab90 --- /dev/null +++ b/tests/store-factory.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest" +import { storeFactory, cachedStoreFactory } from "../src/store-factory" + + +describe("storeFactory", () => { + it("creates isolated store instances", () => { + const createWidgetStore = storeFactory((id: string) => ({ + id, + items: [] as string[], + loading: false, + })) + + const s1 = createWidgetStore("w1") + const s2 = createWidgetStore("w2") + + expect(s1.read().id).toBe("w1") + expect(s2.read().id).toBe("w2") + + s1.set({ ...s1.read(), items: ["a"] }) + expect(s1.read().items).toEqual(["a"]) + expect(s2.read().items).toEqual([]) + }) + + it("each call creates a new store", () => { + const create = storeFactory(() => ({ count: 0 })) + const a = create() + const b = create() + expect(a).not.toBe(b) + }) +}) + +describe("cachedStoreFactory", () => { + it("returns same store for same key", () => { + const factory = cachedStoreFactory((id: string) => ({ + id, + name: "", + })) + + const s1 = factory.get("user-1") + const s2 = factory.get("user-1") + expect(s1).toBe(s2) + }) + + it("returns different stores for different keys", () => { + const factory = cachedStoreFactory((id: string) => ({ id })) + const s1 = factory.get("a") + const s2 = factory.get("b") + expect(s1).not.toBe(s2) + expect(s1.read().id).toBe("a") + expect(s2.read().id).toBe("b") + }) + + it("has/delete/clear/size work correctly", () => { + const factory = cachedStoreFactory((id: string) => ({ id })) + factory.get("a") + factory.get("b") + + expect(factory.has("a")).toBe(true) + expect(factory.has("c")).toBe(false) + expect(factory.size).toBe(2) + + factory.delete("a") + expect(factory.has("a")).toBe(false) + expect(factory.size).toBe(1) + + factory.clear() + expect(factory.size).toBe(0) + }) + + it("keys returns all stored keys", () => { + const factory = cachedStoreFactory((id: string) => ({ id })) + factory.get("x") + factory.get("y") + factory.get("z") + + const keys = [...factory.keys()] + expect(keys).toEqual(["x", "y", "z"]) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 9a52afa..b97b6cb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,9 @@ { "compilerOptions": { "target": "ES2020", - "module": "ES2020", - "moduleResolution": "node", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "NodeNext", + "moduleResolution": "NodeNext", "strict": true, "noImplicitAny": true, "noUncheckedIndexedAccess": true, @@ -13,6 +14,7 @@ "esModuleInterop": true, "declaration": true, "sourceMap": false, + "jsx": "react-jsx", "rootDir": "./src", "outDir": "./dist" }, From a8bc0c4a18ca9986e292807411ecf56d530d0a9a Mon Sep 17 00:00:00 2001 From: webcoderspeed Date: Thu, 6 Aug 2026 01:14:31 +0530 Subject: [PATCH 2/4] fix(build): declare devDependencies the build actually needs CI installs with `npm ci`, which only resolves what package.json declares. Verified by running every CI step against a clean lockfile install. - typescript was never declared, so `npm run build` and `npm run typecheck` worked locally only via a hoisted install and would fail in CI. - @eslint/js is required by eslint.config.cjs but was undeclared. Pinned to ^9 to match the installed eslint major (v10 demands eslint 10). - @vitest/coverage-v8 is needed by the test:coverage / test:ci scripts. - esbuild is imported directly by scripts/bundle-for-size.mjs; it previously resolved only as a transitive dependency of vitest. - Aligned size-limit and @size-limit/file, which had drifted to mismatched majors (12 vs 13). Pinned to ^12: v13 requires fs/promises.glob, which needs Node 22 and contradicts the package's Node 18 baseline. --- package-lock.json | 1171 ++++++++++++++++++++++++++++++--------------- package.json | 6 +- 2 files changed, 782 insertions(+), 395 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0b2222d..080c461 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,12 +15,15 @@ ], "license": "MIT", "devDependencies": { - "@size-limit/file": "^13.0.3", + "@eslint/js": "^9.39.5", + "@size-limit/file": "^12.1.0", "@testing-library/react": "^16.0.0", "@types/node": "^25.0.3", "@types/react": "^18.3.12", "@typescript-eslint/eslint-plugin": "^8.50.1", "@typescript-eslint/parser": "^8.50.1", + "@vitest/coverage-v8": "^4.1.10", + "esbuild": "^0.28.1", "eslint": "^9.39.2", "fast-check": "^4.5.2", "jsdom": "^27.3.0", @@ -31,6 +34,7 @@ "solid-js": "^1.9.14", "svelte": "^5.56.8", "tinybench": "^6.0.0", + "typescript": "^5.9.3", "vitest": "^4.0.16", "vue": "^3.5.41", "zod": "^4.2.1", @@ -178,6 +182,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -314,9 +328,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -331,9 +345,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -348,9 +362,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -365,9 +379,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -382,9 +396,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -399,9 +413,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -416,9 +430,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -433,9 +447,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -450,9 +464,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -467,9 +481,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -484,9 +498,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -501,9 +515,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -518,9 +532,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -535,9 +549,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -552,9 +566,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -569,9 +583,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -586,9 +600,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -603,9 +617,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -620,9 +634,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -637,9 +651,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -654,9 +668,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -671,9 +685,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -688,9 +702,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -705,9 +719,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -722,9 +736,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -739,9 +753,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -908,9 +922,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { @@ -1046,24 +1060,20 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", - "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", - "cpu": [ - "arm" - ], + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "funding": { + "url": "https://github.com/sponsors/Boshen" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", - "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", "cpu": [ "arm64" ], @@ -1072,12 +1082,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", - "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", "cpu": [ "arm64" ], @@ -1086,12 +1099,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", - "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", "cpu": [ "x64" ], @@ -1100,26 +1116,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", - "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", - "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", "cpu": [ "x64" ], @@ -1128,26 +1133,15 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", - "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", - "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", "cpu": [ "arm" ], @@ -1156,12 +1150,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", - "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", "cpu": [ "arm64" ], @@ -1170,12 +1167,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", - "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", "cpu": [ "arm64" ], @@ -1184,26 +1184,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", - "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", - "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", "cpu": [ "ppc64" ], @@ -1212,40 +1201,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", - "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", - "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", - "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", "cpu": [ "s390x" ], @@ -1254,12 +1218,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", - "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", "cpu": [ "x64" ], @@ -1268,12 +1235,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", - "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", "cpu": [ "x64" ], @@ -1282,12 +1252,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", - "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", "cpu": [ "arm64" ], @@ -1296,12 +1269,15 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", - "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", "cpu": [ "arm64" ], @@ -1310,26 +1286,15 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", - "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", - "cpu": [ - "ia32" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", - "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", "cpu": [ "x64" ], @@ -1338,33 +1303,29 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", - "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", - "cpu": [ - "x64" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, "node_modules/@size-limit/file": { - "version": "13.0.3", - "resolved": "https://registry.npmjs.org/@size-limit/file/-/file-13.0.3.tgz", - "integrity": "sha512-PWTITIXH5p9aGIf6qq2Fruihn/b9nBQyfkyoAyb6DzFJgS1Ek9MSPJYKxKFLO8jdo0aqSgBPd3sevbS6PyBiJw==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@size-limit/file/-/file-12.1.0.tgz", + "integrity": "sha512-eGwDcIufnNnvJRzv3liDOn6MAOGgmOTUdpeGQ2KuRTlgIgO54AJH1ilvktlJc6PIjNfwpYY0dOGyap1QgM1swQ==", "dev": true, "license": "MIT", "engines": { "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "size-limit": "13.0.3" + "size-limit": "12.1.0" } }, "node_modules/@standard-schema/spec": { @@ -1741,32 +1702,63 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", - "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.16", - "@vitest/utils": "4.0.16", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", - "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.16", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1775,7 +1767,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -1787,26 +1779,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", - "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", - "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.16", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -1814,13 +1806,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", - "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.16", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1829,9 +1822,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", - "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -1839,14 +1832,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", - "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.16", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2091,6 +2085,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2212,6 +2225,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2320,6 +2340,16 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devalue": { "version": "5.9.0", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz", @@ -2349,16 +2379,16 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "dev": true, "license": "MIT" }, "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2369,32 +2399,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "@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" } }, "node_modules/escape-string-regexp": { @@ -2500,6 +2530,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2846,6 +2889,13 @@ "node": ">=18" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -2971,6 +3021,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3076,6 +3165,267 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -3163,6 +3513,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mdn-data": { "version": "2.12.2", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", @@ -3521,46 +3899,37 @@ "node": ">=4" } }, - "node_modules/rollup": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", - "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.54.0", - "@rollup/rollup-android-arm64": "4.54.0", - "@rollup/rollup-darwin-arm64": "4.54.0", - "@rollup/rollup-darwin-x64": "4.54.0", - "@rollup/rollup-freebsd-arm64": "4.54.0", - "@rollup/rollup-freebsd-x64": "4.54.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", - "@rollup/rollup-linux-arm-musleabihf": "4.54.0", - "@rollup/rollup-linux-arm64-gnu": "4.54.0", - "@rollup/rollup-linux-arm64-musl": "4.54.0", - "@rollup/rollup-linux-loong64-gnu": "4.54.0", - "@rollup/rollup-linux-ppc64-gnu": "4.54.0", - "@rollup/rollup-linux-riscv64-gnu": "4.54.0", - "@rollup/rollup-linux-riscv64-musl": "4.54.0", - "@rollup/rollup-linux-s390x-gnu": "4.54.0", - "@rollup/rollup-linux-x64-gnu": "4.54.0", - "@rollup/rollup-linux-x64-musl": "4.54.0", - "@rollup/rollup-openharmony-arm64": "4.54.0", - "@rollup/rollup-win32-arm64-msvc": "4.54.0", - "@rollup/rollup-win32-ia32-msvc": "4.54.0", - "@rollup/rollup-win32-x64-gnu": "4.54.0", - "@rollup/rollup-win32-x64-msvc": "4.54.0", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" } }, "node_modules/safer-buffer": { @@ -3717,9 +4086,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -3832,9 +4201,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -3919,7 +4288,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3946,18 +4314,17 @@ } }, "node_modules/vite": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", - "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -3973,9 +4340,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -3988,13 +4356,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -4021,31 +4392,31 @@ } }, "node_modules/vitest": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", - "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.16", - "@vitest/mocker": "4.0.16", - "@vitest/pretty-format": "4.0.16", - "@vitest/runner": "4.0.16", - "@vitest/snapshot": "4.0.16", - "@vitest/spy": "4.0.16", - "@vitest/utils": "4.0.16", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "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": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -4061,12 +4432,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.16", - "@vitest/browser-preview": "4.0.16", - "@vitest/browser-webdriverio": "4.0.16", - "@vitest/ui": "4.0.16", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -4087,6 +4461,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -4095,6 +4475,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, diff --git a/package.json b/package.json index 9d3cfda..657e124 100644 --- a/package.json +++ b/package.json @@ -185,12 +185,15 @@ } }, "devDependencies": { - "@size-limit/file": "^13.0.3", + "@eslint/js": "^9.39.5", + "@size-limit/file": "^12.1.0", "@testing-library/react": "^16.0.0", "@types/node": "^25.0.3", "@types/react": "^18.3.12", "@typescript-eslint/eslint-plugin": "^8.50.1", "@typescript-eslint/parser": "^8.50.1", + "@vitest/coverage-v8": "^4.1.10", + "esbuild": "^0.28.1", "eslint": "^9.39.2", "fast-check": "^4.5.2", "jsdom": "^27.3.0", @@ -201,6 +204,7 @@ "solid-js": "^1.9.14", "svelte": "^5.56.8", "tinybench": "^6.0.0", + "typescript": "^5.9.3", "vitest": "^4.0.16", "vue": "^3.5.41", "zod": "^4.2.1", From a72d3cd9ea3b1e1a6f085d2840ddb08e4419b3ac Mon Sep 17 00:00:00 2001 From: webcoderspeed Date: Thu, 6 Aug 2026 01:25:53 +0530 Subject: [PATCH 3/4] ci: run unit tests on Node 20/22 and verify Node 18 against the built package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Node 18 test job failed: vitest 4 declares engines `^20 || ^22 || >=24`, and its rolldown dependency imports `styleText` from node:util, which only exists in Node 20.12+. The unit suite genuinely cannot run on Node 18. Dropping Node 18 from the matrix alone would have silently weakened the `engines.node: >=18` claim in package.json, so instead: - Unit tests now run on Node 20 and 22. - A new `node18-compat` job downloads the dist artifact and exercises the built package on Node 18 with no dependencies installed β€” covering the store, computed, combineStores, the query layer (AbortController), microtask batching (queueMicrotask), and the exostate/node entry. The published output uses nothing newer than ES2020, so this passes; if someone later reaches for a newer runtime API, the job fails and the engines field has to be raised deliberately. - Release now also gates on node18-compat. Docs corrected: development requires Node 20+, while the published package still supports Node 18. --- .github/workflows/ci.yml | 65 ++++++++++++++++++++++++++++++++++++++-- CONTRIBUTING.md | 9 +++++- README.md | 4 ++- 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 070fe74..e6e998b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,10 @@ jobs: - name: Type check run: npm run typecheck + # Vitest 4 declares engines ^20 || ^22 || >=24 (its rolldown dependency + # imports `styleText` from node:util, added in Node 20.12), so the unit + # suite cannot run on Node 18. The package itself still supports Node 18 β€” + # that is verified against the built artifact in the `node18-compat` job. test: name: Test (Node ${{ matrix.node-version }}) runs-on: ubuntu-latest @@ -43,7 +47,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: ['18', '20', '22'] + node-version: ['20', '22'] steps: - name: Checkout @@ -129,10 +133,67 @@ jobs: path: dist/ retention-days: 3 + # package.json advertises engines.node >= 18, so that claim needs a real + # test. The dev toolchain cannot run on Node 18, but the *published* output + # can β€” so exercise the built artifact directly, with no dependencies + # installed at all. + node18-compat: + name: Node 18 runtime compatibility + runs-on: ubuntu-latest + needs: build + steps: + - name: Setup Node.js 18 + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Download built package + uses: actions/download-artifact@v4 + with: + name: dist + path: dist + + - name: Exercise the built package on Node 18 + run: | + node --input-type=module -e " + import { createStore, QueryClient, combineStores, computed, shallow } from './dist/index.js'; + + const store = createStore({ n: 0, label: 'a' }); + store.patch({ n: 5 }); + store.update((s, by) => ({ ...s, n: s.n + by }), 3); + if (store.read().n !== 8) throw new Error('store broken on Node 18'); + + const doubled = computed(store, s => s.n * 2); + if (doubled.read() !== 16) throw new Error('computed broken on Node 18'); + + const combined = combineStores({ store }); + if (combined.read().store.n !== 8) throw new Error('combine broken on Node 18'); + + if (!shallow({ a: 1 }, { a: 1 })) throw new Error('shallow broken on Node 18'); + + // Exercises AbortController + queueMicrotask, the newest runtime + // APIs the package touches. + const client = new QueryClient(); + const data = await client.fetchQuery({ queryKey: ['x'], queryFn: async () => 42 }); + if (data !== 42) throw new Error('query broken on Node 18'); + client.clear(); + + const batched = createStore({ n: 0 }, { notify: 'microtask' }); + let notifications = 0; + batched.subscribe(s => s.n, () => { notifications++; }); + batched.patch({ n: 1 }); + batched.patch({ n: 2 }); + await Promise.resolve(); + if (notifications !== 1) throw new Error('microtask batching broken on Node 18'); + + await import('./dist/node/index.js'); + console.log('Node 18 compatibility verified'); + " + release: name: Semantic Release runs-on: ubuntu-latest - needs: [test, build] + needs: [test, build, node18-compat] if: (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') && github.event_name == 'push' permissions: contents: write # push release tag + chore(release) commit diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb3fcc1..4136a67 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,14 @@ npm install npm run validate # build + typecheck + lint + tests ``` -Node 18 or newer is required. +**Node 20 or newer is required for development.** Vitest 4 declares +`engines: ^20 || ^22 || >=24`, so the test suite cannot run on Node 18. + +The *published package* still supports Node 18 (`engines.node: >=18`) β€” its +source uses nothing newer than ES2020. CI proves this in the +`node18-compat` job, which runs the built artifact on Node 18 with no +dependencies installed. If you add a runtime API newer than ES2020, that job +will fail; either drop the API or raise `engines.node` deliberately. ## Project layout diff --git a/README.md b/README.md index 86df715..6e70468 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,9 @@ Framework packages are **optional peer dependencies** β€” install only what you | Solid signals | `exostate/solid` | `solid-js >= 1` | | Filesystem persistence | `exostate/node` | Node >= 18 | -Requires TypeScript 5.0+ for the bundled types. Node 18+ for the runtime. +Requires TypeScript 5.0+ for the bundled types. Runs on Node 18+ β€” verified in +CI against the built package on Node 18 itself. (Contributing to the repo needs +Node 20+, since the test toolchain does.) --- From a20ba317a41a43cc4879227b932778c79634972c Mon Sep 17 00:00:00 2001 From: webcoderspeed Date: Thu, 6 Aug 2026 01:34:49 +0530 Subject: [PATCH 4/4] ci: verify Node 18 support against the installed tarball, not raw dist files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The node18-compat job failed with "Named export 'QueryClient' not found β€” the requested module is a CommonJS module". That was a flaw in the test, not the package: the job downloaded only dist/, so there was no package.json and therefore no "type": "module", and Node parsed the ESM output as CommonJS. Rather than just copying package.json alongside dist/, the job now tests what a consumer actually gets: - The build job packs the publishable tarball and uploads it as an artifact. - node18-compat installs that tarball into a clean project with "type": "module" and imports by bare specifier. This is a stronger check than the original β€” it exercises the exports map, package resolution, and "type": "module" handling, not just file contents. It also now asserts the exostate/node subpath resolves. Verified end to end locally: npm pack, install into a fresh project, and run the exact compat script against the installed package. Also fixes `npm pack --pack-destination ./pack`, which fails when the target directory does not already exist. --- .github/workflows/ci.yml | 43 ++++++++++++++++++++++++++++++++-------- .gitignore | 1 + 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6e998b..a1f1255 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,10 +133,23 @@ jobs: path: dist/ retention-days: 3 + # --pack-destination does not create the directory itself. + - name: Pack the publishable tarball + run: mkdir -p pack && npm pack --pack-destination ./pack + + - name: Upload package tarball + uses: actions/upload-artifact@v4 + with: + name: package-tarball + path: pack/*.tgz + retention-days: 3 + # package.json advertises engines.node >= 18, so that claim needs a real # test. The dev toolchain cannot run on Node 18, but the *published* output - # can β€” so exercise the built artifact directly, with no dependencies - # installed at all. + # can β€” so install the packed tarball into a clean project and import it by + # bare specifier. That exercises the exports map and the "type": "module" + # resolution exactly as a consumer would, rather than poking at dist/ files + # directly (which Node parses as CommonJS without the package manifest). node18-compat: name: Node 18 runtime compatibility runs-on: ubuntu-latest @@ -147,16 +160,27 @@ jobs: with: node-version: '18' - - name: Download built package + - name: Download package tarball uses: actions/download-artifact@v4 with: - name: dist - path: dist + name: package-tarball + path: pack - - name: Exercise the built package on Node 18 + - name: Install the tarball into a clean consumer project + run: | + mkdir -p /tmp/consumer + cd /tmp/consumer + npm init -y > /dev/null + npm pkg set type=module + npm install "$GITHUB_WORKSPACE"/pack/*.tgz + echo "Installed:" + node -e "console.log(require('exostate/package.json').version)" + + - name: Exercise the installed package on Node 18 + working-directory: /tmp/consumer run: | node --input-type=module -e " - import { createStore, QueryClient, combineStores, computed, shallow } from './dist/index.js'; + import { createStore, QueryClient, combineStores, computed, shallow } from 'exostate'; const store = createStore({ n: 0, label: 'a' }); store.patch({ n: 5 }); @@ -186,7 +210,10 @@ jobs: await Promise.resolve(); if (notifications !== 1) throw new Error('microtask batching broken on Node 18'); - await import('./dist/node/index.js'); + // Subpath exports must resolve too. + const { persistFs } = await import('exostate/node'); + if (typeof persistFs !== 'function') throw new Error('exostate/node broken on Node 18'); + console.log('Node 18 compatibility verified'); " diff --git a/.gitignore b/.gitignore index 3b37a13..60b3e15 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ coverage/ .size/ release/ coverage/ +pack/