diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 414e9ad..a0196ec 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 08ed7af..c5077e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index a594fc8..67129eb 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# effect-query
-[](https://www.npmjs.com/package/@matheuseabra/effect-query)
+[](https://www.npmjs.com/package/@wthw7/effect-query)
[](https://github.com/matheuseabra/effect-query/actions/workflows/ci.yml)
[](./LICENSE)
@@ -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
@@ -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,
@@ -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
@@ -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,
@@ -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) =>
@@ -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
diff --git a/docs/architecture.md b/docs/architecture.md
index 695efa5..ebc30d7 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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:
@@ -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`).
diff --git a/docs/roadmap.md b/docs/roadmap.md
new file mode 100644
index 0000000..a217625
--- /dev/null
+++ b/docs/roadmap.md
@@ -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.
diff --git a/docs/spec.md b/docs/spec.md
index 703de59..9334846 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -45,6 +45,7 @@ const program = Effect.gen(function* () {
interface QueryOptions {
readonly key: (...args: any[]) => Key
readonly execute: (...args: any[]) => Effect.Effect
+ readonly hash?: (key: Key) => string
readonly staleTime?: Duration.DurationInput
readonly cacheTime?: Duration.DurationInput
readonly retry?: RetryPolicy
@@ -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
diff --git a/package.json b/package.json
index e1c4a7d..8b10168 100644
--- a/package.json
+++ b/package.json
@@ -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",
@@ -32,8 +32,7 @@
"LICENSE"
],
"publishConfig": {
- "access": "public",
- "provenance": true
+ "access": "public"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
diff --git a/src/index.ts b/src/index.ts
index 751ef19..a162198 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -8,5 +8,6 @@ export {
type QueryKey,
type QueryOptions,
type QueryService as QueryServiceShape,
- type RetryPolicy
+ type RetryPolicy,
+ KeyHashError
} from "./query.js"
diff --git a/src/query.ts b/src/query.ts
index eb49fbb..1ab5f9d 100644
--- a/src/query.ts
+++ b/src/query.ts
@@ -1,8 +1,13 @@
-import { Context, Deferred, Duration, Effect, Exit, Layer, Option, Schedule } from "effect"
+import { Context, Data, Deferred, Duration, Effect, Fiber, Layer, Option, Schedule } from "effect"
export type QueryKey = readonly unknown[]
export type QueryArgs = readonly unknown[]
+export class KeyHashError extends Data.TaggedError("KeyHashError")<{
+ readonly key: unknown
+ readonly reason: unknown
+}> {}
+
export interface RetryPolicy {
readonly times?: number
readonly schedule?: Schedule.Schedule
@@ -11,6 +16,7 @@ export interface RetryPolicy {
export interface QueryOptions {
readonly key: (...args: Args) => Key
readonly execute: (...args: Args) => Effect.Effect
+ readonly hash?: (key: Key) => string
readonly staleTime?: Duration.DurationInput
readonly cacheTime?: Duration.DurationInput
readonly retry?: RetryPolicy
@@ -21,6 +27,7 @@ export interface QueryOptions {
readonly key: (...args: Args) => Key
readonly execute: (...args: Args) => Effect.Effect
+ readonly hash?: (key: Key) => string
readonly staleTime: Duration.DurationInput
readonly cacheTime: Duration.DurationInput
readonly retry?: RetryPolicy
@@ -28,19 +35,26 @@ export interface QueryDefinition {
+export interface MutationDefinition {
readonly execute: (...args: Args) => Effect.Effect
- readonly onSuccess?: (value: A) => Effect.Effect
+ readonly onSuccess?: (value: A) => Effect.Effect
}
export interface QueryService {
readonly fetch: (
query: QueryDefinition,
args: Args
- ) => Effect.Effect
- readonly invalidate: (key: QueryKey) => Effect.Effect
- readonly setData: (key: QueryKey, value: A) => Effect.Effect
- readonly getData: (key: QueryKey) => Effect.Effect>
+ ) => Effect.Effect
+ readonly prefetch: (
+ query: QueryDefinition,
+ args: Args
+ ) => Effect.Effect
+ readonly invalidate: (key: QueryKey) => Effect.Effect
+ readonly cancel: (key: QueryKey) => Effect.Effect
+ readonly setData: (key: QueryKey, value: A) => Effect.Effect
+ readonly getData: (key: QueryKey) => Effect.Effect, KeyHashError>
+ readonly notifyFocus: Effect.Effect
+ readonly notifyReconnect: Effect.Effect
}
export const QueryService = Context.GenericTag(
@@ -57,9 +71,25 @@ interface CacheEntry {
staleTimeMs: number
cacheTimeMs: number
inFlight: Effect.Effect | undefined
+ inFlightFiber: Fiber.RuntimeFiber | undefined
+ refetchOnFocus: boolean
+ refetchOnReconnect: boolean
+ // Requirements are erased: trigger revalidation only has QueryService in
+ // scope, so queries needing more fail silently in the background daemon
+ // without touching the cached entry.
+ revalidate: Effect.Effect | undefined
}
-const keyHash = (key: QueryKey): string => JSON.stringify(key)
+const defaultKeyHash = (key: QueryKey): string => JSON.stringify(key)
+
+const hashKey = (
+ key: Key,
+ hash: ((key: Key) => string) | undefined
+): Effect.Effect =>
+ Effect.try({
+ try: () => (hash ?? defaultKeyHash)(key),
+ catch: (reason) => new KeyHashError({ key, reason })
+ })
const toMillis = (input: Duration.DurationInput | undefined, fallback: Duration.DurationInput): number =>
Duration.toMillis(input ?? fallback)
@@ -75,11 +105,14 @@ const withRetry = (effect: Effect.Effect, policy: RetryPolicy<
return Effect.retry(effect, policy)
}
-// Single-flight execution for one cache key. The first caller (winner) runs
-// the effect in its own fiber, so interrupting the winner cancels the
-// underlying request. Concurrent callers (joiners) await a Deferred instead,
-// so interrupting a joiner only cancels its own wait. Failures complete the
-// Deferred without caching, letting the next fetch retry from scratch.
+// Single-flight execution for one cache key. The first caller (winner) forks
+// the request into a supervised child fiber, so interrupting the winner
+// cancels the request through supervision, and `cancel` can interrupt it by
+// key. Concurrent callers (joiners) await a Deferred instead, so interrupting
+// a joiner only cancels its own wait. The child caches successful values;
+// the winner funnels every outcome (success, failure, interruption) through
+// the Deferred in an `onExit` finalizer, so joiners never hang. Failures are
+// never cached as data, letting the next fetch retry from scratch.
const runFresh = (
entry: CacheEntry,
execute: Effect.Effect,
@@ -93,15 +126,33 @@ const runFresh = (
}
const deferred = yield* Deferred.make()
entry.inFlight = Deferred.await(deferred)
- const exit = yield* restore(Effect.exit(withRetry(execute, policy)))
- if (Exit.isSuccess(exit)) {
- entry.hasValue = true
- entry.value = exit.value
- entry.updatedAt = Date.now()
- }
- entry.inFlight = undefined
- yield* Deferred.done(deferred, exit)
- return yield* restore(exit)
+ const child = yield* Effect.fork(
+ Effect.interruptible(withRetry(execute, policy)).pipe(
+ Effect.tap((value) =>
+ Effect.sync(() => {
+ entry.hasValue = true
+ entry.value = value
+ entry.updatedAt = Date.now()
+ })
+ )
+ )
+ )
+ entry.inFlightFiber = child
+ return yield* restore(
+ Fiber.join(child).pipe(
+ Effect.onExit((exit) =>
+ Effect.uninterruptible(
+ Effect.zipRight(
+ Effect.sync(() => {
+ entry.inFlight = undefined
+ entry.inFlightFiber = undefined
+ }),
+ Deferred.done(deferred, exit)
+ )
+ )
+ )
+ )
+ )
})
)
@@ -125,7 +176,11 @@ const makeService = (): QueryService => {
updatedAt: 0,
staleTimeMs,
cacheTimeMs,
- inFlight: undefined
+ inFlight: undefined,
+ inFlightFiber: undefined,
+ refetchOnFocus: false,
+ refetchOnReconnect: false,
+ revalidate: undefined
}
entries.set(hash, entry)
return entry
@@ -134,9 +189,10 @@ const makeService = (): QueryService => {
const fetch = (
query: QueryDefinition,
args: Args
- ): Effect.Effect =>
+ ): Effect.Effect =>
Effect.gen(function* () {
- const hash = keyHash(query.key(...args))
+ const key = query.key(...args)
+ const hash = yield* hashKey(key, query.hash)
const now = Date.now()
const existing = entries.get(hash)
if (existing !== undefined) removeExpired(hash, existing, now)
@@ -147,6 +203,13 @@ const makeService = (): QueryService => {
)
entry.staleTimeMs = toMillis(query.staleTime, DEFAULT_STALE_TIME)
entry.cacheTimeMs = toMillis(query.cacheTime, DEFAULT_CACHE_TIME)
+ entry.refetchOnFocus = query.refetchOnFocus ?? false
+ entry.refetchOnReconnect = query.refetchOnReconnect ?? false
+ entry.revalidate = runFresh(
+ entry,
+ query.execute(...args),
+ query.retry
+ ) as unknown as Effect.Effect
if (isFresh(entry, now)) return entry.value as A
if (entry.hasValue) {
@@ -158,22 +221,44 @@ const makeService = (): QueryService => {
return yield* runFresh(entry, query.execute(...args), query.retry)
})
+ const notify = (flag: "refetchOnFocus" | "refetchOnReconnect"): Effect.Effect =>
+ Effect.forEach(
+ entries.values(),
+ (entry) =>
+ entry[flag] && entry.revalidate !== undefined
+ ? Effect.asVoid(Effect.forkDaemon(Effect.ignore(entry.revalidate)))
+ : Effect.void,
+ { discard: true }
+ )
+
return {
fetch,
+ prefetch: (query, args) =>
+ Effect.asVoid(Effect.forkDaemon(Effect.ignore(fetch(query, args)))),
invalidate: (key) =>
- Effect.sync(() => {
- entries.delete(keyHash(key))
+ Effect.gen(function* () {
+ entries.delete(yield* hashKey(key, undefined))
+ }),
+ cancel: (key) =>
+ Effect.gen(function* () {
+ const hash = yield* hashKey(key, undefined)
+ const fiber = entries.get(hash)?.inFlightFiber
+ if (fiber !== undefined) yield* Effect.asVoid(Fiber.interrupt(fiber))
}),
setData: (key, value) =>
- Effect.sync(() => {
- const entry = getOrCreateEntry(keyHash(key), DEFAULT_STALE_TIME, toMillis(DEFAULT_CACHE_TIME, 0))
+ Effect.gen(function* () {
+ const entry = getOrCreateEntry(
+ yield* hashKey(key, undefined),
+ DEFAULT_STALE_TIME,
+ toMillis(DEFAULT_CACHE_TIME, 0)
+ )
entry.hasValue = true
entry.value = value
entry.updatedAt = Date.now()
}),
getData: (key: QueryKey) =>
- Effect.sync(() => {
- const hash = keyHash(key)
+ Effect.gen(function* () {
+ const hash = yield* hashKey(key, undefined)
const entry = entries.get(hash)
if (entry === undefined) return Option.none()
if (isExpired(entry, Date.now())) {
@@ -181,7 +266,9 @@ const makeService = (): QueryService => {
return Option.none()
}
return entry.hasValue ? Option.some(entry.value as A) : Option.none()
- })
+ }),
+ notifyFocus: notify("refetchOnFocus"),
+ notifyReconnect: notify("refetchOnReconnect")
}
}
@@ -198,17 +285,17 @@ const queryMake = (
const queryFetch = (
query: QueryDefinition,
...args: Args
-): Effect.Effect =>
+): Effect.Effect =>
Effect.flatMap(QueryService, (service) => service.fetch(query, args))
-const mutationMake = (
- definition: MutationDefinition
-): MutationDefinition => definition
+const mutationMake = (
+ definition: MutationDefinition
+): MutationDefinition => definition
-const mutationExecute = (
- mutation: MutationDefinition,
+const mutationExecute = (
+ mutation: MutationDefinition,
...args: Args
-): Effect.Effect =>
+): Effect.Effect =>
Effect.gen(function* () {
const value = yield* mutation.execute(...args)
if (mutation.onSuccess !== undefined) yield* mutation.onSuccess(value)
@@ -218,12 +305,23 @@ const mutationExecute = (
export const Query = {
make: queryMake,
fetch: queryFetch,
- invalidate: (key: QueryKey): Effect.Effect =>
+ prefetch: (
+ query: QueryDefinition,
+ ...args: Args
+ ): Effect.Effect =>
+ Effect.flatMap(QueryService, (service) => service.prefetch(query, args)),
+ invalidate: (key: QueryKey): Effect.Effect =>
Effect.flatMap(QueryService, (service) => service.invalidate(key)),
- setData: (key: QueryKey, value: A): Effect.Effect =>
+ cancel: (key: QueryKey): Effect.Effect =>
+ Effect.flatMap(QueryService, (service) => service.cancel(key)),
+ setData: (key: QueryKey, value: A): Effect.Effect =>
Effect.flatMap(QueryService, (service) => service.setData(key, value)),
- getData: (key: QueryKey): Effect.Effect, never, QueryService> =>
- Effect.flatMap(QueryService, (service) => service.getData(key))
+ getData: (key: QueryKey): Effect.Effect, KeyHashError, QueryService> =>
+ Effect.flatMap(QueryService, (service) => service.getData(key)),
+ notifyFocus: (): Effect.Effect =>
+ Effect.flatMap(QueryService, (service) => service.notifyFocus),
+ notifyReconnect: (): Effect.Effect =>
+ Effect.flatMap(QueryService, (service) => service.notifyReconnect)
}
export const Mutation = {
diff --git a/test/query.test.ts b/test/query.test.ts
index 8c64b71..f96b236 100644
--- a/test/query.test.ts
+++ b/test/query.test.ts
@@ -1,6 +1,6 @@
import { Effect, Exit, Fiber, Option, Schedule } from "effect"
import { describe, expect, it } from "vitest"
-import { Mutation, Query, queryLayer, type QueryServiceShape } from "../src/index.js"
+import { Mutation, Query, queryLayer, KeyHashError, type QueryServiceShape } from "../src/index.js"
const run = (program: Effect.Effect) =>
Effect.runPromise(program.pipe(Effect.provide(queryLayer())))
@@ -323,6 +323,190 @@ describe("Query", () => {
expect(await run(program)).toBe(1)
expect(calls).toBe(1)
})
+
+ it("shares entries across key shapes with a custom hasher", async () => {
+ let calls = 0
+ const query = Query.make({
+ key: (id: number, stamp: number) => ["user", id, stamp] as const,
+ hash: (key) => `user:${key[1]}`,
+ execute: (id: number) => Effect.sync(() => ({ id, call: ++calls })),
+ staleTime: "1 minute"
+ })
+
+ const program = Effect.gen(function* () {
+ const first = yield* Query.fetch(query, 1, 100)
+ const second = yield* Query.fetch(query, 1, 200)
+ return [first, second] as const
+ })
+ const [first, second] = await run(program)
+
+ expect(first).toEqual({ id: 1, call: 1 })
+ expect(second).toEqual({ id: 1, call: 1 })
+ expect(calls).toBe(1)
+ })
+
+ it("fails typed when the default hasher cannot serialize the key", async () => {
+ const query = Query.make({
+ key: (id: bigint) => ["big", id] as const,
+ execute: (id: bigint) => Effect.succeed(id)
+ })
+
+ const error = await Effect.runPromise(
+ Effect.flip(Query.fetch(query, 1n).pipe(Effect.provide(queryLayer())))
+ )
+
+ expect(error).toBeInstanceOf(KeyHashError)
+ expect(error._tag).toBe("KeyHashError")
+ })
+
+ it("fails typed on manual operations with unserializable keys", async () => {
+ const error = await Effect.runPromise(
+ Effect.flip(Query.getData(["big", 1n]).pipe(Effect.provide(queryLayer())))
+ )
+
+ expect(error).toBeInstanceOf(KeyHashError)
+ })
+
+ it("revalidates focus-opted queries on focus", async () => {
+ let focusedCalls = 0
+ let plainCalls = 0
+ const focused = Query.make({
+ key: () => ["focused"] as const,
+ execute: () => Effect.sync(() => ++focusedCalls),
+ refetchOnFocus: true
+ })
+ const plain = Query.make({
+ key: () => ["plain"] as const,
+ execute: () => Effect.sync(() => ++plainCalls)
+ })
+
+ const program = Effect.gen(function* () {
+ yield* Query.fetch(focused)
+ yield* Query.fetch(plain)
+ yield* Query.notifyFocus()
+ let current = yield* Query.getData(["focused"])
+ for (let i = 0; i < 50 && !(Option.isSome(current) && current.value === 2); i++) {
+ yield* Effect.sleep("10 millis")
+ current = yield* Query.getData(["focused"])
+ }
+ return current
+ })
+ const result = await run(program)
+
+ expect(Option.getOrThrow(result)).toBe(2)
+ expect(focusedCalls).toBe(2)
+ expect(plainCalls).toBe(1)
+ })
+
+ it("revalidates reconnect-opted queries on reconnect", async () => {
+ let calls = 0
+ const query = Query.make({
+ key: () => ["online"] as const,
+ execute: () => Effect.sync(() => ++calls),
+ refetchOnReconnect: true
+ })
+
+ const program = Effect.gen(function* () {
+ yield* Query.fetch(query)
+ yield* Query.notifyReconnect()
+ let current = yield* Query.getData(["online"])
+ for (let i = 0; i < 50 && !(Option.isSome(current) && current.value === 2); i++) {
+ yield* Effect.sleep("10 millis")
+ current = yield* Query.getData(["online"])
+ }
+ return current
+ })
+
+ expect(Option.getOrThrow(await run(program))).toBe(2)
+ expect(calls).toBe(2)
+ })
+
+ it("treats notifications with no matching entries as no-ops", async () => {
+ const program = Effect.gen(function* () {
+ yield* Query.notifyFocus()
+ yield* Query.notifyReconnect()
+ })
+
+ await expect(run(program)).resolves.toBeUndefined()
+ })
+
+ it("warms the cache without awaiting data", async () => {
+ let calls = 0
+ const query = Query.make({
+ key: () => ["warm"] as const,
+ execute: () => Effect.sleep("20 millis").pipe(Effect.map(() => ++calls)),
+ staleTime: "1 minute"
+ })
+
+ const program = Effect.gen(function* () {
+ yield* Query.prefetch(query)
+ const immediate = yield* Query.getData(["warm"])
+ let cached = immediate
+ for (let i = 0; i < 50 && Option.isNone(cached); i++) {
+ yield* Effect.sleep("10 millis")
+ cached = yield* Query.getData(["warm"])
+ }
+ const value = yield* Query.fetch(query)
+ return { immediate, cached, value }
+ })
+ const result = await run(program)
+
+ expect(Option.isNone(result.immediate)).toBe(true)
+ expect(Option.getOrThrow(result.cached)).toBe(1)
+ expect(result.value).toBe(1)
+ expect(calls).toBe(1)
+ })
+
+ it("cancelling in-flight interrupts the request and clears the slot", async () => {
+ let interrupted = false
+ const query = Query.make({
+ key: () => ["cancelable"] as const,
+ execute: () =>
+ Effect.sleep("5 seconds").pipe(
+ Effect.as("done"),
+ Effect.onInterrupt(() => Effect.sync(() => { interrupted = true }))
+ )
+ })
+
+ const program = Effect.gen(function* () {
+ const fiber = yield* Effect.fork(Query.fetch(query))
+ yield* Effect.sleep("20 millis")
+ yield* Query.cancel(["cancelable"])
+ const exit = yield* Fiber.await(fiber)
+ const cached = yield* Query.getData(["cancelable"])
+ const retry = yield* Effect.fork(Query.fetch(query))
+ yield* Effect.sleep("20 millis")
+ yield* Query.cancel(["cancelable"])
+ const retryExit = yield* Fiber.await(retry)
+ return { exit, cached, retryExit }
+ })
+ const result = await run(program)
+
+ expect(Exit.isInterrupted(result.exit)).toBe(true)
+ expect(interrupted).toBe(true)
+ expect(Option.isNone(result.cached)).toBe(true)
+ expect(Exit.isInterrupted(result.retryExit)).toBe(true)
+ })
+
+ it("cancelling an idle or missing key is a no-op", async () => {
+ const query = Query.make({
+ key: () => ["idle"] as const,
+ execute: () => Effect.succeed("ok"),
+ staleTime: "1 minute"
+ })
+
+ const program = Effect.gen(function* () {
+ yield* Query.cancel(["missing"])
+ const value = yield* Query.fetch(query)
+ yield* Query.cancel(["idle"])
+ const cached = yield* Query.getData(["idle"])
+ return { value, cached }
+ })
+ const result = await run(program)
+
+ expect(result.value).toBe("ok")
+ expect(Option.getOrThrow(result.cached)).toBe("ok")
+ })
})
describe("Mutation", () => {