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..a1f1255
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,261 @@
+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
+
+ # 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
+ needs: quality
+ strategy:
+ fail-fast: false
+ matrix:
+ node-version: ['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
+
+ # --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 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
+ needs: build
+ steps:
+ - name: Setup Node.js 18
+ uses: actions/setup-node@v4
+ with:
+ node-version: '18'
+
+ - name: Download package tarball
+ uses: actions/download-artifact@v4
+ with:
+ name: package-tarball
+ path: pack
+
+ - 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 'exostate';
+
+ 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');
+
+ // 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');
+ "
+
+ release:
+ name: Semantic Release
+ runs-on: ubuntu-latest
+ 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
+ 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..60b3e15 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,8 @@ coverage/
.vscode/
.idea/
+
+.size/
+release/
+coverage/
+pack/
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..4136a67
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,162 @@
+# 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 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
+
+```
+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..6e70468 100644
--- a/README.md
+++ b/README.md
@@ -1,670 +1,1160 @@
-# 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:
+
+
+
+
+
+
+
+
+
-### 1. **Strict Type Safety**
-- Zero tolerance for `any` types
-- Full TypeScript generics throughout
-- Compile-time guarantees for runtime safety
+
-### 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. 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.)
+
+---
+
+## 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 (
-