Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,22 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- uses: actions/cache@v4
with:
path: .turbo/cache
key: ${{ runner.os }}-node22-turbo-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-node22-turbo-${{ hashFiles('pnpm-lock.yaml') }}-
- run: pnpm check
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage
path: coverage/
path: |
coverage/
packages/react/coverage/
19 changes: 16 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
# Node 24 ships npm 11.5.1+, required for OIDC trusted publishing.
Expand All @@ -24,8 +22,23 @@ jobs:
registry-url: "https://registry.npmjs.org"
- run: pnpm install --frozen-lockfile
- run: pnpm check
- name: Verify release versions match the tag
run: |
node --input-type=module -e '
import fs from "node:fs";
const core = JSON.parse(fs.readFileSync("package.json", "utf8"));
const react = JSON.parse(fs.readFileSync("packages/react/package.json", "utf8"));
if (core.version !== react.version || process.env.GITHUB_REF_NAME !== `v${core.version}`) {
throw new Error("Release tag and package versions must match");
}
'
# pnpm resolves workspace dependencies to registry versions when packing.
- run: pnpm pack --out effect-query.tgz
- run: pnpm pack --out ../../effect-query-react.tgz
working-directory: packages/react
# npm CLI (not pnpm) performs the OIDC token exchange for publishing.
# Prereleases publish under their own dist-tag so `latest` stays clean.
- run: |
TAG=$(node -p "/-/.test(require('./package.json').version) ? require('./package.json').version.split('-')[1].split('.')[0] : 'latest'")
npm publish --provenance --tag "$TAG" --no-git-checks
npm publish ./effect-query.tgz --provenance --tag "$TAG"
npm publish ./effect-query-react.tgz --provenance --tag "$TAG"
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules
dist
coverage
coverage
.turbo
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,16 @@ Preserve the boundaries described in the spec: typed errors and requirements sho

- `pnpm install` — install dependencies.
- `pnpm check` — typecheck, lint, test (with enforced coverage thresholds), and build.
- `pnpm check --force` — rerun all Turborepo tasks without reading cached results.
- `pnpm exec turbo run build` — build core and the React adapter in dependency order.
- `pnpm test:watch` — rerun tests on change (no coverage).
- `pnpm build` — emit `dist/` via `tsconfig.build.json`.

pnpm manages the workspace; Turborepo coordinates `pnpm check` across core at
the root and `packages/react`. Core commands are registered root tasks in
`turbo.json`; adapter build/typecheck/test depend on the core build. Keep task
inputs and cached outputs accurate when adding files or configuration.

During documentation-only changes, use `git diff --check` to catch whitespace errors and review the rendered Markdown.

## Coding Style & Naming Conventions
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.0-beta.0] - Unreleased

### Added

- Optional `@wthw7/effect-query-react` package: runtime-bound `useQuery` and
`useMutation`, fiber cancellation on cleanup, typed Cause/Exit outcomes, and
concurrent mutation tracking. Hooks expose fetch snapshots, not cache subscriptions.
- Separate React behavior and compile-time tests, included in `pnpm check` and CI.
- Workspace packaging and release support for both packages; core remains React-free.
- Turborepo task orchestration and local/CI caching for builds, coverage tests,
typechecking, and linting, with pnpm retained as the workspace package manager.
- Phase 1 core additions since alpha: custom hashers and `KeyHashError`, prefetch,
cancel, and focus/reconnect notifications.

## [0.1.0-alpha.0] - 2026-09-04

First public alpha, published as `@wthw7/effect-query`.
Expand Down
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# effect-query

[![version](https://img.shields.io/badge/version-0.1.0--alpha.0-blue)](https://www.npmjs.com/package/@wthw7/effect-query)
[![version](https://img.shields.io/badge/version-0.3.0--beta.0-blue)](https://www.npmjs.com/package/@wthw7/effect-query)
[![CI](https://github.com/matheuseabra/effect-query/actions/workflows/ci.yml/badge.svg)](https://github.com/matheuseabra/effect-query/actions/workflows/ci.yml)
[![license](https://img.shields.io/github/license/matheuseabra/effect-query.svg)](./LICENSE)

Expand All @@ -24,6 +24,8 @@ parallel runtime.
## Features

- UI-agnostic core — works in any Effect program; UI bindings stay out of core
- Optional [React adapter](./packages/react/README.md) with runtime-bound query
and mutation hooks, typed outcomes, and unmount cancellation
- Structural readonly-tuple keys shared by reads, writes, and invalidation
- Fresh/stale caching with stale-while-revalidate and background refresh
- Single-flight in-flight deduplication across concurrent fetches
Expand Down Expand Up @@ -233,8 +235,23 @@ pnpm install
pnpm check
```

`pnpm check` runs typecheck, lint, tests with enforced coverage thresholds,
and build. Please keep 100% line coverage, name tests after observable
`pnpm check` uses Turborepo to run typecheck, lint, coverage tests, and build
for core and the React adapter. pnpm manages workspace dependencies; Turbo
orders tasks and caches successful results in `.turbo/cache`. The adapter's
build, typecheck, and tests wait for core's build. Shared TypeScript and lint
configuration changes invalidate the cache, and build/coverage artifacts are
restored on cache hits. CI retains the task cache with GitHub Actions caching;
no remote-cache account is required.

Core stays at the repository root, with its commands registered as Turbo root
tasks in `turbo.json`; the adapter lives in `packages/react`. `pnpm build`,
`pnpm test`, `pnpm lint`, and `pnpm typecheck` at the root still target core.
Use `pnpm exec turbo run build` to build both packages, or
`pnpm exec turbo run test --filter=@wthw7/effect-query-react` for adapter tests
with core built first. `pnpm check --force` reruns all tasks without cache hits.
Watch mode remains available through `pnpm test:watch` for core.

Please keep 100% line coverage, name tests after observable
behavior, and use concise imperative commit subjects (`feat:`, `fix:`,
`test:`, `docs:`). For larger changes, open an issue first so the design can
be discussed before code.
Expand Down
42 changes: 41 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Failures are never cached as data. A failed revalidation leaves the previous suc
- `notifyFocus` / `notifyReconnect` revalidate opted-in entries in background
daemons; queries needing services beyond `QueryService` fail silently there
without touching cached data.
- React bindings, persistence, pagination, SSR hydration, and devtools remain
- Persistence, pagination, SSR hydration, and devtools remain
out of scope (see `docs/roadmap.md`).

## Interruption semantics per operation
Expand All @@ -56,4 +56,44 @@ Failures are never cached as data. A failed revalidation leaves the previous suc

## Verification surface

### React adapter

pnpm links the workspace packages, while Turborepo orchestrates `pnpm check`.
Core remains the root package: its build, lint, test, and typecheck commands are
registered explicitly as `//#...` root tasks. Adapter build, typecheck, and test
tasks depend on `//#build`, because core's exported runtime and declarations
come from `dist/`. Root tasks have scoped inputs so adapter-only changes do not
invalidate core results. Shared TypeScript/lint configs participate in global
cache hashes; successful builds and coverage reports are cached as outputs.
The explicit `.turbo/cache` location keeps worktree caches isolated, and CI
persists that directory between runs. No publishing command is cached.

`packages/react` is a separate workspace package with a one-way dependency on
core and React/Effect peer dependencies. `createQueryHooks` captures a supplied
`Runtime<QueryService | R>` once; it does not own runtime disposal. This keeps
injected application requirements checked at the hook boundary without erasing
them through a React context. The application disposes its managed runtime after
unmounting the React tree.

Each query hook forks a core fetch after commit. A hashed key plus the stable
definition determines request identity. Cleanup marks the observer inactive and
interrupts its fiber. A new request awaits the previous fiber's finalizers before
starting, so React StrictMode replay cannot join the request it just cancelled.
Results carry their request identity to prevent displaying old-key data between
render and effect setup. Causes preserve both typed errors and defects.

Mutation hooks track concurrent fibers per definition and settle promises with
typed `Exit` values. Cleanup disables that definition's callbacks and interrupts
its outstanding fibers; late completions do not update inactive React state.

The adapter delegates cache behavior and request ownership to core. In particular,
cancelling the winning hook interrupts surviving joiners, and detached stale
revalidation outlives hook cleanup. There is no reactive cache subscription in
this phase: hooks display fetch snapshots, so background cache changes do not
automatically rerender consumers. Core cache semantics and coverage gates are
unchanged. React behavior tests run separately in jsdom, with fake timers for
mount, key change, cleanup, StrictMode, errors, and concurrent mutations.

### Core

The tests exercise the public seam rather than private maps: fresh caching, in-flight deduplication, key isolation, custom hashers and typed hash failures, retries and exhaustion, manual cache operations, stale-while-revalidate, cache-time garbage collection, failure visibility, winner/joiner interruption, cancellation and slot reuse, prefetch warming, focus/reconnect triggers, and mutation success and failure handling. `pnpm test` enforces 100% line/function coverage thresholds (see `vitest.config.ts`).
15 changes: 10 additions & 5 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,24 @@ publish `0.2.0-beta.0` and verify the tarball as in Phase 0.

Goal: prove the core is truly UI-agnostic with a first adapter.

- [ ] New package (e.g. `effect-query-react`) depending on core, never the
- [x] New package (`@wthw7/effect-query-react`) depending on core, never the
reverse. React stays out of core `package.json`.
- [ ] `useQuery(definition, ...args)` returning `{ data, error, isPending }`
- [x] `useQuery(definition, ...args)` returning `{ data, error, isPending }`
driven by Effect fibers, with unmount cancellation.
- [ ] `useMutation(definition)` returning `{ mutate, isPending }`.
- [ ] Adapter test suite with fake timers covering mount, key change,
- [x] `useMutation(definition)` returning `{ mutate, isPending }`.
- [x] Adapter test suite with fake timers covering mount, key change,
unmount-cancel, and error states.
- [ ] README section with framework example; core README links to it.
- [x] README section with framework example; core README links to it.

Quality gate: adapter tests green in CI, core coverage thresholds untouched,
example app (or test harness) demonstrates mount → fetch → unmount-cancel,
publish `0.3.0-beta.0` for both packages.

Implementation is ready for review. Both manifests and the release workflow are
prepared for `0.3.0-beta.0`; publishing and registry verification remain pending.
The new adapter needs npm Trusted Publishing configuration before release. See
its [README](../packages/react/README.md) for setup and fetch-snapshot limitations.

## Phase 3 — Advanced cache

Goal: close the remaining v0.1 out-of-scope items, one at a time, each
Expand Down
26 changes: 23 additions & 3 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,16 +95,36 @@ const updateUser = Mutation.make({
- **Deduping**: Identical in-flight keys share the same fiber
- **Stale-while-revalidate**: Returns cached data immediately, refetches in background when stale
- **Typed everything**: Success, error, and requirements flow through Effect
- **No React binding required**: Core is pure Effect; a React adapter is future work
- **No React binding required**: Core is pure Effect; React hooks live in a separate optional package

## React Adapter (future)
## React Adapter

```ts
const { useQuery, useMutation } = createQueryHooks(await managed.runtime())
const { data, error, isPending } = useQuery(userQuery, "123")
const { mutate } = useMutation(updateUser)
```

The adapter is not included in v0.1. A future package may build on Effect fibers and React concurrent features while keeping the core package React-free.
`@wthw7/effect-query-react` binds hooks to an application-owned Effect runtime
with `createQueryHooks`. The runtime must provide `QueryService` and the
definitions' requirements. React remains absent from the core package manifest.

`useQuery` starts a fetch fiber after commit and interrupts it on key/definition
changes or unmount. Identity is the stable definition plus its hashed key. It
returns `data: A | undefined`, `error: Cause<E | KeyHashError> | undefined`, and
`isPending: boolean`; a changed identity clears the displayed result immediately.
Core winner/joiner interruption and detached background revalidation semantics
remain unchanged. Results are fetch snapshots: mounted hooks do not subscribe
to cache writes, invalidation, or background revalidation completion.

`useMutation` returns `mutate(...args): Promise<Exit<A, E | E2>>` and `isPending`.
Pending covers all concurrent calls through completion of `onSuccess`. Unmount
or definition changes interrupt outstanding mutation fibers. Retained callbacks
cannot start work after cleanup. Outcomes preserve typed errors and defects
without unhandled promise rejections.

See the [adapter README](../packages/react/README.md) for a complete example,
runtime ownership, cancellation details, and current limitations.

## Out of Scope (v0.1)

Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wthw7/effect-query",
"version": "0.1.0-alpha.0",
"version": "0.3.0-beta.0",
"description": "Typed, interruptible query caching for Effect",
"license": "MIT",
"author": "Matheus Seabra",
Expand All @@ -17,6 +17,7 @@
"node": ">=18"
},
"type": "module",
"packageManager": "pnpm@10.30.1",
"sideEffects": false,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand All @@ -40,7 +41,7 @@
"test": "vitest run --coverage",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"check": "pnpm typecheck && pnpm lint && pnpm test && pnpm build",
"check": "turbo run typecheck lint test build",
"prepublishOnly": "pnpm check"
},
"dependencies": {
Expand All @@ -51,6 +52,7 @@
"@vitest/coverage-v8": "^5.0.0",
"oxlint": "^1.81.0",
"tsx": "^4.23.13",
"turbo": "^2.10.12",
"typescript": "^7.0.2",
"vitest": "^5.0.0"
}
Expand Down
21 changes: 21 additions & 0 deletions packages/react/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Matheus Seabra

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.
Loading
Loading