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
9 changes: 7 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@ jobs:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
# Node 24 ships npm 11.5.1+, required for OIDC trusted publishing.
node-version: 24
cache: pnpm
registry-url: "https://registry.npmjs.org"
- run: pnpm install --frozen-lockfile
- run: pnpm check
- run: pnpm publish --no-git-checks
# 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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

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

First public alpha, published as `@matheuseabra/effect-query`.
First public alpha, published as `@wthw7/effect-query`.
UI-agnostic query cache for Effect.

### Added
Expand Down
52 changes: 47 additions & 5 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/@matheuseabra/effect-query)
[![version](https://img.shields.io/badge/version-0.1.0--alpha.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 Down Expand Up @@ -32,6 +32,9 @@ parallel runtime.
- Interruptible fetches: cancelling the winner cancels the request, cancelling
a joiner only cancels its own wait
- Manual `getData` / `setData` / `invalidate` over the same key space
- Custom key hashers with typed `KeyHashError` instead of throws
- Fire-and-forget `prefetch` and per-key `cancel`
- Focus/reconnect revalidation triggers for opted-in queries
- Mutations with `QueryService`-backed `onSuccess` hooks
- Lazy `cacheTime` garbage collection with no background timers
- Strict TypeScript with 100% line test coverage
Expand All @@ -40,7 +43,7 @@ parallel runtime.

```ts
import { Effect } from "effect"
import { Query, queryLayer } from "@matheuseabra/effect-query"
import { Query, queryLayer } from "@wthw7/effect-query"

const user = Query.make({
key: (id: string) => ["user", id] as const,
Expand All @@ -56,7 +59,7 @@ const program = Query.fetch(user, "123").pipe(
## Install

```sh
bun add @matheuseabra/effect-query
bun add @wthw7/effect-query
```

Requires Bun (or Node 18 and later) and `effect` 3 as a runtime dependency
Expand All @@ -68,7 +71,7 @@ Requires Bun (or Node 18 and later) and `effect` 3 as a runtime dependency

```ts
import { Effect } from "effect"
import { Query, queryLayer } from "@matheuseabra/effect-query"
import { Query, queryLayer } from "@wthw7/effect-query"

const userQuery = Query.make({
key: (id: string) => ["user", id] as const,
Expand Down Expand Up @@ -143,7 +146,7 @@ const program = Effect.gen(function* () {
### Mutations that refresh queries

```ts
import { Mutation } from "@matheuseabra/effect-query"
import { Mutation } from "@wthw7/effect-query"

const updateUser = Mutation.make({
execute: (id: string, name: string) =>
Expand All @@ -162,6 +165,45 @@ The `onSuccess` hook runs with access to the `QueryService`, so a mutation can
invalidate or seed exactly the keys it affects. If the mutation fails, the
hook does not run and the typed error propagates.

### Custom key hashers

```ts
const userQuery = Query.make({
key: (id: number, stamp: number) => ["user", id, stamp] as const,
hash: (key) => `user:${key[1]}`,
execute: (id: number) => Effect.succeed({ id }),
staleTime: "1 minute"
})
```

`fetch` hashes with `hash` when provided (`JSON.stringify` otherwise), so
keys that differ only in volatile parts share one entry. Raw-key operations
(`getData`, `setData`, `invalidate`, `cancel`) always use the default hasher.
Keys that cannot be hashed fail typed with `KeyHashError`.

### Prefetch, cancel, and focus triggers

```ts
const program = Effect.gen(function* () {
yield* Query.prefetch(postsQuery)
yield* Query.notifyFocus()
yield* Query.cancel(["posts"])
}).pipe(Effect.provide(queryLayer()))
```

`prefetch` warms the cache without awaiting data. `cancel` interrupts the
in-flight request for a key, if any. `notifyFocus` / `notifyReconnect`
revalidate entries whose queries opt in via `refetchOnFocus` /
`refetchOnReconnect`:

```ts
const postsQuery = Query.make({
key: () => ["posts"] as const,
execute: () => Effect.promise(() => fetch("/api/posts").then((res) => res.json())),
refetchOnFocus: true
})
```

### Typed errors

```ts
Expand Down
30 changes: 25 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Effect Query is a small, UI-agnostic cache module. Its external seam is the `Que

### QueryService implementation

`queryLayer()` creates an isolated in-memory `Map` keyed by a JSON representation of the readonly key tuple. Each entry stores the last successful value, its timestamp, timing policy, and the current in-flight joiner. Deduplication is an explicit Deferred-based single flight: the first caller (winner) runs the request in its own fiber so interruption cancels the underlying effect, while concurrent callers (joiners) await the same Deferred, so interrupting a joiner only cancels its own wait. The winner completes the Deferred with the full `Exit`, preserving typed errors and requirements for every waiter.
`queryLayer()` creates an isolated in-memory `Map` keyed by a hash of the readonly key tuple (`JSON.stringify` by default, overridable per query via `Query.make({ hash })`). Each entry stores the last successful value, its timestamp, timing policy, focus/reconnect flags, a revalidation effect, and the current in-flight joiner plus winner fiber. Deduplication is an explicit Deferred-based single flight: the first caller (winner) forks the request into a supervised child fiber, while concurrent callers (joiners) await the same Deferred. The child caches successful values; the winner funnels every outcome through the Deferred in an `onExit` finalizer, so joiners are always released and failures are never cached.

The service applies this decision sequence:

Expand All @@ -29,11 +29,31 @@ Failures are never cached as data. A failed revalidation leaves the previous suc

## First-version decisions

- Keys must be JSON-serializable readonly arrays; custom hashers are deferred.
- Keys must be JSON-serializable readonly arrays by default; per-query `hash`
overrides hashing for `fetch`, while raw-key operations keep structural
identity. Hashing failures surface as typed `KeyHashError`, never throws.
- Cache expiration is lazy rather than timer-driven, avoiding background runtime ownership.
- `refetchOnFocus` and `refetchOnReconnect` are stored as forward-compatible query options but have no runtime adapter yet.
- React bindings, persistence, pagination, SSR hydration, and devtools remain out of scope.
- `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
out of scope (see `docs/roadmap.md`).

## Interruption semantics per operation

- `fetch` (winner): interrupting the caller interrupts the supervised request
fiber through normal Effect supervision, cancelling the underlying effect.
- `fetch` (joiner): interrupting a concurrent caller only cancels its own
wait on the shared Deferred; the request continues for the others.
- `cancel(key)`: interrupts the winner fiber for that key, if one is running.
Waiters observe interruption; nothing is cached; the slot is cleared so the
next fetch starts fresh work. Cancelling an idle or missing key is a no-op.
- `prefetch`: fire-and-forget by design; the caller's fiber never touches the
request, so it cannot be cancelled through the returned `void`. Failures die
with the daemon and are never cached.
- Stale-while-revalidate and focus/reconnect daemons behave like `prefetch`:
detaching the caller means caller interruption never cancels them.

## Verification surface

The tests exercise the public seam rather than private maps: fresh caching, in-flight deduplication, key isolation, retries and exhaustion, manual cache operations, stale-while-revalidate, cache-time garbage collection, failure visibility, winner/joiner interruption, and mutation success and failure handling. `pnpm test` enforces 100% line/function coverage thresholds (see `vitest.config.ts`).
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`).
112 changes: 112 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Roadmap

Phased, trackable milestones from the current alpha to a stable 1.0. Each
phase lists concrete tasks and a quality gate: the phase is done only when
every box is checked **and** the gate passes.

Status legend: `[x]` done, `[ ]` todo. Update boxes in the same commit that
lands the work.

Global release rules (apply to every phase):

- `pnpm check` green (typecheck, lint, coverage tests, build).
- Coverage thresholds in `vitest.config.ts` hold (100% lines/functions).
- `docs/spec.md` and `docs/architecture.md` updated when behavior changes.
- `CHANGELOG.md` entry plus version bump and tag for every publish.

## Phase 0 — Alpha release (current)

Goal: publishable core on npm as `@wthw7/effect-query`.

- [x] Core `Query` / `Mutation` / `QueryService` implementation.
- [x] 20+ behavior tests with enforced coverage thresholds.
- [x] CI workflow, provenance release workflow, MIT license.
- [x] README with Why, Features, Usage, and Contributing.
- [x] One-time npm Trusted Publishing setup for the scoped package
(npmjs.com Settings → Trusted Publisher). Required before the first
tag-pushed release.
- [x] Tag `v0.1.0-alpha.0` marks the release commit (kept local: pushing it
would re-trigger publish of the existing version; tag-push automation
applies from the next release).
- [x] Verified the published tarball installs and imports in a blank project
(npm and bun), including a fetch/mutation smoke test.

Quality gate: `bun add @wthw7/effect-query@alpha` works in an empty
project, badge versions resolve, and CI is green on `main`.

## Phase 1 — Core completeness (beta)

Goal: close the gaps the alpha knowingly deferred.

- [x] Custom key hashers: `Query.make({ hash })` option, default stays
`JSON.stringify`. Non-serializable keys fail typed with `KeyHashError`.
- [x] Focus/reconnect triggers: UI-agnostic `Query.notifyFocus()` /
`Query.notifyReconnect()` that revalidate queries opting in via
`refetchOnFocus` / `refetchOnReconnect`.
- [x] `Query.prefetch(query, ...args)`: warm the cache without awaiting data.
- [x] `Query.cancel(key)`: cancel an in-flight request and clear its slot.
- [x] Document interruption semantics per operation in `docs/architecture.md`.

Quality gate: new behaviors covered by tests named after observable behavior,
no public API change without a spec entry, coverage thresholds still hold,
publish `0.2.0-beta.0` and verify the tarball as in Phase 0.

## Phase 2 — Framework adapters

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

- [ ] New package (e.g. `effect-query-react`) depending on core, never the
reverse. React stays out of core `package.json`.
- [ ] `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,
unmount-cancel, and error states.
- [ ] 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.

## Phase 3 — Advanced cache

Goal: close the remaining v0.1 out-of-scope items, one at a time, each
shippable independently.

- [ ] Pagination helpers: `Query.infinite` (or equivalent) with page params
and `fetchNextPage`, sharing the structural key space.
- [ ] SSR / hydration: `dehydrate()` / `hydrate()` over serializable entries
with `cacheTime`-aware expiry on restore.
- [ ] Offline persistence: pluggable storage seam (default in-memory),
rehydrate on startup, versioned payloads with typed decode failures.
- [ ] Devtools hook: read-only event stream (fetch start/success/failure,
invalidate, GC) a devtools UI can subscribe to; core stays UI-free.
- [ ] Normalized/entity cache: only if a concrete consumer needs it; needs an
ADR before any code.

Quality gate per item: ADR or spec entry first, behavior tests including
failure paths (corrupt payload, version mismatch), docs and changelog updated,
minor-version publish verified as in Phase 0.

## Phase 4 — Stable 1.0

Goal: freeze the API consumers can rely on.

- [ ] Mark experimental APIs stable or remove them; no `refetchOn*` stubs.
- [ ] Benchmarks: dedup under contention, SWR latency, GC under key churn.
Record baselines in the repo and fail CI on large regressions.
- [ ] Semver policy documented (what counts as major in each package).
- [ ] Migration notes from the last beta; CHANGELOG complete.
- [ ] Security review pass: no credentials in logs/fixtures, dependency audit.

Quality gate: all prior gates hold, benchmarks recorded, `1.0.0` published,
GitHub release notes link the changelog.

## Non-goals

Ideas that stay out unless a consumer demonstrates need with an ADR:

- Normalized relational cache with entity graph updates.
- Offline-first conflict resolution / CRDT sync.
- Framework adapters beyond the Phase 2 proof (Vue, Svelte, Solid).
- Server-side cache distribution or shared cross-process stores.
10 changes: 9 additions & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const program = Effect.gen(function* () {
interface QueryOptions<A, E, R, Key extends readonly unknown[]> {
readonly key: (...args: any[]) => Key
readonly execute: (...args: any[]) => Effect.Effect<A, E, R>
readonly hash?: (key: Key) => string
readonly staleTime?: Duration.DurationInput
readonly cacheTime?: Duration.DurationInput
readonly retry?: RetryPolicy
Expand Down Expand Up @@ -77,9 +78,16 @@ const updateUser = Mutation.make({
### Cache / Service

- Single `QueryService` Layer that owns the in-memory cache
- Keys are structural (readonly arrays)
- Keys are structural (readonly arrays) hashed by `JSON.stringify` by default;
`Query.make({ hash })` overrides hashing for `fetch`. Raw-key operations
(`getData`, `setData`, `invalidate`, `cancel`) always use the default
hasher. Unhashable keys fail typed with `KeyHashError`, never throw.
- Lazy garbage collection via `cacheTime`
- `Query.invalidate(key)` / `Query.setData(key, data)` / `Query.getData(key)`
- `Query.prefetch(query, ...args)` warms the cache without awaiting data
- `Query.cancel(key)` interrupts the in-flight request for a key, if any
- `Query.notifyFocus()` / `Query.notifyReconnect()` revalidate entries whose
queries opt in via `refetchOnFocus` / `refetchOnReconnect`

## Key Behaviors

Expand Down
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@matheuseabra/effect-query",
"name": "@wthw7/effect-query",
"version": "0.1.0-alpha.0",
"description": "Typed, interruptible query caching for Effect",
"license": "MIT",
Expand Down Expand Up @@ -32,8 +32,7 @@
"LICENSE"
],
"publishConfig": {
"access": "public",
"provenance": true
"access": "public"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ export {
type QueryKey,
type QueryOptions,
type QueryService as QueryServiceShape,
type RetryPolicy
type RetryPolicy,
KeyHashError
} from "./query.js"
Loading
Loading