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
51 changes: 51 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: CI

on:
pull_request:
push:
branches:
- main

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 24.x
cache: pnpm

- run: pnpm install --frozen-lockfile

- name: Lint
run: pnpm lint

- name: Build
run: pnpm build

- name: Typecheck
run: pnpm typecheck

- name: Install Playwright Chromium
working-directory: packages/effect-query
run: pnpm exec playwright install --with-deps chromium

- name: Test
run: pnpm test

- name: Lint the published package
working-directory: packages/effect-query
run: |
pnpm dlx publint --strict
pnpm dlx @arethetypeswrong/cli --pack . --profile node16
13 changes: 13 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,22 @@ jobs:

- run: pnpm install --frozen-lockfile

- name: Lint
run: pnpm lint

- name: Build
run: pnpm build

- name: Typecheck
run: pnpm typecheck

- name: Install Playwright Chromium
working-directory: packages/effect-query
run: pnpm exec playwright install --with-deps chromium

- name: Test
run: pnpm test

- name: Determine npm tag
id: determine_npm_tag
shell: bash
Expand Down
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,23 @@ const runtime = ManagedRuntime.make(GreetingApiLive);
export const eqFromRuntime = createEffectQueryFromManagedRuntime(runtime);
```

## Runtime lifecycle

`createEffectQuery(layer)` builds the layer lazily, once, on the first query and shares it between every query and mutation. Each factory also exposes the runtime:

```ts
// Run an Effect outside of a query, e.g. in a router loader.
await eq.runtime.runPromise(effect);

// Run the finalizers of the scoped services of the layer (sockets, RPC clients, fibers...).
await eq.dispose();
```

- **Dispose when you are done with it.** Call `dispose()` when the factories are no longer used. Typical cases are an HMR update (`import.meta.hot?.dispose(() => eq.dispose())`) or the end of a server request when the factories are created per request. Queries that start after `dispose()` fail with an `EffectQueryDefect`.
- **A failed layer build is retried.** If building the layer dies (e.g. a transient network error while acquiring a resource), the failed runtime is discarded. The next query builds the layer again, so TanStack Query's retries can recover.
- **A runtime you pass in is never replaced.** `createEffectQueryFromManagedRuntime` keeps the runtime you passed in as it is, and `dispose()` disposes it.
- **Create the factories per request on the server.** A factory created at module level shares one runtime between every request.

# Query Example

```tsx
Expand Down Expand Up @@ -303,6 +320,26 @@ if (error instanceof EffectQueryFailure) {
}
```

| | `EffectQueryFailure` | `EffectQueryDefect` |
| --- | --- | --- |
| When | The Effect failed with a typed failure | The Effect died, or was interrupted |
| `name` / `_tag` | `"EffectQueryFailure"` | `"EffectQueryDefect"` |
| `message` | Short summary (the failure's `message` or `_tag`), safe to show in a UI | Short summary of the defect |
| `cause` (standard `Error` property) | The failure | The defect |
| Payload | `failure`: the first failure, `failureCause`: the full `Cause` | `defect`: the value passed to `Effect.die` or the exception thrown, `defectCause`: the full `Cause`, `interrupted`: `true` if the Effect was interrupted rather than dying |

Use `Cause.pretty(error.failureCause)` or `Cause.pretty(error.defectCause)` for a full, multi-line report including stack traces. Only show it in development, never in a UI.

If an Effect function throws instead of returning an Effect, the exception becomes an `EffectQueryDefect` too.

These errors are class instances and do not survive serialization (`structuredClone`, `JSON`, or SSR payloads): after a round trip, `match`, `failure` and `instanceof` are gone. TanStack Query does not dehydrate failed queries by default, so this only matters if you opt into it with `shouldDehydrateQuery`. In that case, map errors to plain data before they cross the boundary.

## Cancellation and logging

- **Cancelling interrupts the Effect.** Every query runs its Effect with TanStack Query's `AbortSignal`, so when a query is cancelled the Effect is interrupted and its finalizers run. A query is cancelled by `queryClient.cancelQueries`, or when its last observer unmounts while it is still fetching. When the last observer unmounts mid-fetch, the query goes back to its previous state rather than finishing in the background.
- **Only defects are logged.** Defects are logged at the `Error` level through the logger of your runtime, so provide a `Logger` layer to route or silence them. Typed failures are expected and reach you through TanStack Query, so they are not logged. Interruptions are not logged either.
- **Spans are named after the key.** Each query and mutation runs in a span named after the first element of its `queryKey` / `mutationKey`.

## Mutation Error Handling

The same pattern works for mutations, allowing you to handle errors in callbacks:
Expand Down Expand Up @@ -352,7 +389,8 @@ function UserPage({ id }: { id: string }) {

- Only errors produced by the given queries are handled. Anything else is rethrown to the next error boundary up the tree, so the `fallback` type is always accurate.
- `reset` refetches the failed queries (it uses TanStack Query's `QueryErrorResetBoundary`).
- The boundary resets on its own when the `queryKey` of `query` changes, e.g. when navigating between ids.
- The boundary resets on its own when the `queryKey` of `query` changes, e.g. when navigating between ids. Each query is hashed with its own `queryKeyHashFn`, so keys that need a custom hash (e.g. containing a `BigInt`) work.
- With Solid SSR, a boundary that rendered its fallback on the server keeps it while hydrating. Its `fallback` then receives the typed error from the client's cache if there is one, otherwise an `EffectQueryDefect` wrapping the deserialized server error (`error.defect`).

`QueryErrorBoundary` is also exported from `effect-query/solid` (wraps Solid's `ErrorBoundary`, set `throwOnError: true` on the queries) and `effect-query/vue` (uses `onErrorCaptured`; the error is passed to the `#fallback="{ error, reset }"` slot; set `throwOnError: true` so `await query.suspense()` rejects).

Expand Down
1 change: 1 addition & 0 deletions packages/effect-query/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
},
"license": "MIT",
"author": "Dominik V\u00edt <dominik@voidhash.com>",
"sideEffects": false,
"exports": {
".": {
"import": {
Expand Down
64 changes: 59 additions & 5 deletions packages/effect-query/src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,55 @@ type EffectQueryErrorMatcher<
}
: Record<never, never>);

/** A short, single line description of a failure or defect, safe to show in a UI or log line. */
const describe = (value: unknown): string => {
if (value instanceof Error) {
return value.message === ""
? value.name
: `${value.name}: ${value.message}`;
}
if (typeof value === "object" && value !== null) {
if ("message" in value && typeof value.message === "string") {
return value.message;
}
if ("_tag" in value && typeof value._tag === "string") {
return value._tag;
}
}
return String(value);
};

/**
* The error TanStack Query sees when the Effect failed with an expected, typed failure.
*
* `failure` is the first failure of the Effect, `failureCause` the full `Cause`. `cause` (the
* standard `Error` property) is set to `failure`, so devtools show it as the underlying error.
*/
export class EffectQueryFailure<
TFailure extends { _tag: string } | never = never,
> extends Error {
readonly _tag: typeof EffectQueryFailureTag;
override readonly name = EffectQueryFailureTag;
readonly failure: TFailure;
readonly failureCause: Cause.Cause<TFailure>;
constructor(
message: string,
failure: TFailure,
cause: Cause.Cause<TFailure>
) {
super(message);
super(message, { cause: failure });
this._tag = EffectQueryFailureTag;
this.failure = failure;
this.failureCause = cause;
}

static fromCause<F extends { _tag: string }>(
failure: F,
cause: Cause.Cause<F>
): EffectQueryFailure<F> {
return new EffectQueryFailure(describe(failure), failure, cause);
}

match<TReturn>(
matcher: EffectQueryErrorMatcher<
TFailure extends { _tag: string } ? TFailure : never,
Expand All @@ -58,13 +90,35 @@ export class EffectQueryFailure<
}
}

export class EffectQueryDefect<TDefect> extends Error {
/**
* The error TanStack Query sees when the Effect died (a defect) or was interrupted.
*
* `defectCause` is the `Cause` the Effect ended with. `defect` is the squashed defect, i.e. the
* value passed to `Effect.die` or the exception thrown, which is also the standard `cause`.
*/
export class EffectQueryDefect<TDefect = unknown> extends Error {
readonly _tag: typeof EffectQueryDefectTag;
override readonly name = EffectQueryDefectTag;
readonly defect: unknown;
readonly defectCause: Cause.Cause<TDefect>;
constructor(message: string, defect: TDefect) {
super(message);
constructor(message: string, cause: Cause.Cause<TDefect>) {
const defect = Cause.squash(cause);
super(message, { cause: defect });
this._tag = EffectQueryDefectTag;
this.defectCause = Cause.die(defect);
this.defect = defect;
this.defectCause = cause;
}

static fromCause<D>(cause: Cause.Cause<D>): EffectQueryDefect<D> {
const message = Cause.hasInterruptsOnly(cause)
? "The Effect was interrupted"
: describe(Cause.squash(cause));
return new EffectQueryDefect(message, cause);
}

/** Whether the Effect was interrupted (e.g. the query was cancelled) rather than dying. */
get interrupted(): boolean {
return Cause.hasInterruptsOnly(this.defectCause);
}

match<TReturn>(
Expand Down
97 changes: 80 additions & 17 deletions packages/effect-query/src/core/options.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Cause, type Effect, Exit, ManagedRuntime, Option } from "effect";
import { Cause, Effect, Exit, type ManagedRuntime, Option } from "effect";
import type { Layer } from "effect/Layer";
import { EffectQueryDefect, EffectQueryFailure } from "./errors";
import { EffectQueryRunner } from "./runner";
Expand Down Expand Up @@ -43,13 +43,12 @@ export const toEffectQueryError = <E>(
): EffectQueryFailure<never> | EffectQueryDefect<unknown> => {
const failure = Cause.findErrorOption(cause);
if (Option.isSome(failure)) {
return new EffectQueryFailure(
Cause.pretty(cause),
return EffectQueryFailure.fromCause(
failure.value as never,
cause as Cause.Cause<never>
);
) as EffectQueryFailure<never>;
}
return new EffectQueryDefect(Cause.pretty(cause), cause);
return EffectQueryDefect.fromCause(cause as Cause.Cause<unknown>);
};

const runToPromise = async <A, E, R>(
Expand All @@ -74,7 +73,8 @@ const wrapQueryFn = (runner: EffectQueryRunner, queryFn: unknown): unknown => {
return (context: QueryFunctionContextLike) =>
runToPromise(
runner,
effectFn(context as never),
// A function that throws instead of returning an Effect surfaces as an `EffectQueryDefect`.
Effect.suspend(() => effectFn(context as never)),
spanNameFromKey(context.queryKey, DEFAULT_QUERY_SPAN),
context.signal
);
Expand All @@ -91,11 +91,57 @@ const wrapMutationFn = (
return (variables: unknown, context?: MutationFunctionContextLike) =>
runToPromise(
runner,
effectFn(variables as never, context as never),
Effect.suspend(() => effectFn(variables as never, context as never)),
spanNameFromKey(context?.mutationKey, DEFAULT_MUTATION_SPAN)
);
};

/** Keeps the wrapped function stable while the getter keeps returning the same function. */
const memoizeLast = (
wrap: (fn: unknown) => unknown,
read: () => unknown
): (() => unknown) => {
let last: { readonly input: unknown; readonly output: unknown } | undefined;
return () => {
const input = read();
if (last === undefined || last.input !== input) {
last = { input, output: wrap(input) };
}
return last.output;
};
};

/**
* Copies `options`, replacing `key` with `wrap(options[key])`.
*
* Property descriptors are copied rather than spread, so getters (e.g. Solid's
* `get enabled() { return id() !== undefined }`) stay live instead of being read once. A getter
* for `key` itself stays a getter, wrapped on every access.
*/
const withWrapped = <T extends object>(
options: T,
key: "mutationFn" | "queryFn",
wrap: (fn: unknown) => unknown
): T => {
const descriptors: PropertyDescriptorMap =
Object.getOwnPropertyDescriptors(options);
const descriptor = descriptors[key];
const wrapped: PropertyDescriptor = descriptor?.get
? {
configurable: true,
enumerable: true,
get: memoizeLast(wrap, descriptor.get.bind(options)),
}
: {
configurable: true,
enumerable: true,
value: wrap((options as Record<string, unknown>)[key]),
writable: true,
};
descriptors[key] = wrapped;
return Object.defineProperties({}, descriptors) as T;
};

/**
* Runtime implementation shared by every framework adapter. TanStack Query's own
* `queryOptions` helpers are identity functions, so the only work left is turning the
Expand All @@ -107,27 +153,44 @@ export interface EffectQueryOptionFactories {
readonly queryOptions: <T extends QueryOptionsLike>(options: T) => T;
}

/** The runtime handle every adapter's `createEffectQuery` result exposes. */
export interface EffectQueryRuntimeHandle<Input> {
/**
* Disposes the underlying `ManagedRuntime`, running the finalizers of the scoped services of
* the layer. Call it when the factories are no longer used, e.g. on HMR or at the end of a
* server request. Queries started afterwards die with an `EffectQueryDefect`.
*/
readonly dispose: () => Promise<void>;
/**
* The `ManagedRuntime` the Effects run on, e.g. to run an Effect outside of a query with
* `runtime.runPromise`. For `createEffectQuery` this is replaced when building the layer dies.
*/
readonly runtime: ManagedRuntime.ManagedRuntime<Input, never>;
}

export const makeOptionFactories = (
runner: EffectQueryRunner
): EffectQueryOptionFactories => {
const queryOptions = <T extends QueryOptionsLike>(options: T): T => ({
...options,
queryFn: wrapQueryFn(runner, options.queryFn),
});
): EffectQueryOptionFactories & EffectQueryRuntimeHandle<never> => {
const queryOptions = <T extends QueryOptionsLike>(options: T): T =>
withWrapped(options, "queryFn", (queryFn) => wrapQueryFn(runner, queryFn));

return {
dispose: () => runner.dispose(),
infiniteQueryOptions: queryOptions,
mutationOptions: (options) => ({
...options,
mutationFn: wrapMutationFn(runner, options.mutationFn),
}),
mutationOptions: (options) =>
withWrapped(options, "mutationFn", (mutationFn) =>
wrapMutationFn(runner, mutationFn)
),
queryOptions,
get runtime() {
return runner.runtime;
},
};
};

export const runnerFromLayer = <Input>(
layer: Layer<Input, never, never>
): EffectQueryRunner => new EffectQueryRunner(ManagedRuntime.make(layer));
): EffectQueryRunner => EffectQueryRunner.fromLayer(layer);

export const runnerFromManagedRuntime = <Input>(
runtime: ManagedRuntime.ManagedRuntime<Input, never>
Expand Down
Loading
Loading