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 -[![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) @@ -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", () => {