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
23 changes: 23 additions & 0 deletions .changeset/rename-result-query-mutation-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"wellcrafted": minor
---

Rename the hook-local Result adapters: `queryOptions` → `resultQueryOptions` and `mutationOptions` → `resultMutationOptions`.

**Breaking change.** Update imports and call sites:

```diff
-import { queryOptions, mutationOptions } from "wellcrafted/query";
+import { resultQueryOptions, resultMutationOptions } from "wellcrafted/query";
```

`createQueryFactories`, `defineQuery`, and `defineMutation` are unchanged — they still compose through the renamed adapters, so there is still exactly one place that unwraps `Result` into TanStack's throwing contract.

Why: the old names collided with TanStack Query's own `queryOptions` / `mutationOptions` identity helpers. The `result*` prefix removes that collision and names what the adapters do — adapt a `Result`-returning `queryFn` / `mutationFn` — so both can coexist in one file without aliasing.

This clarifies the two-family model:

- **`resultQueryOptions` / `resultMutationOptions`** — hook-local, reactive, or local-`QueryClient` operations passed straight to a framework hook.
- **`createQueryFactories(...).defineQuery` / `defineMutation`** — reusable, `QueryClient`-bound handles with imperative helpers (`.fetch`, `.ensure`, callable mutations).

Cache operations (`invalidateQueries`, `setQueryData`, `ensureQueryData`, …) stay on the `QueryClient` and are intentionally not wrapped.
2 changes: 1 addition & 1 deletion skills/query-factories/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const createPost = defineMutation({
});
```

`mutationKey` is required on `defineMutation` and `mutationOptions`, just as `queryKey` is required on `defineQuery`.
`mutationKey` is required on `defineMutation` and `resultMutationOptions`, just as `queryKey` is required on `defineQuery`.

## Reactive Options and Imperative Helpers

Expand Down
35 changes: 24 additions & 11 deletions src/query/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,28 @@ The query utilities solve a common integration challenge: your service functions

`wellcrafted/query` exposes two layers built on the same conversion path:

1. **`queryOptions` / `mutationOptions`**: platform-agnostic adapters that turn a Result-returning `queryFn` or `mutationFn` into normal TanStack Query options. No `QueryClient` needed. Compose them directly with any framework hook (`createQuery`, `useQuery`, `createMutation`, `useMutation`).
1. **`resultQueryOptions` / `resultMutationOptions`**: platform-agnostic adapters that turn a Result-returning `queryFn` or `mutationFn` into normal TanStack Query options. No `QueryClient` needed. Compose them directly with any framework hook (`createQuery`, `useQuery`, `createMutation`, `useMutation`).

2. **`createQueryFactories(queryClient)` -> `defineQuery` / `defineMutation`**: bind the same options to a specific `QueryClient` and attach imperative helpers. Queries expose explicit `.fetch()` and `.ensure()` methods because those choose different cache policies. Mutations are callable because there is one imperative action: run the mutation.

```typescript
import { QueryClient } from '@tanstack/query-core';
import {
createQueryFactories,
queryOptions,
mutationOptions,
resultQueryOptions,
resultMutationOptions,
} from 'wellcrafted/query';

// Local options used directly inside a hook
const user = createQuery(() =>
queryOptions({
resultQueryOptions({
queryKey: ['user', userId],
queryFn: () => services.getUser(userId),
}),
);

const save = createMutation(() =>
mutationOptions({
resultMutationOptions({
mutationKey: ['saveUser'],
mutationFn: (input: SaveUserInput) => services.saveUser(input),
}),
Expand All @@ -42,24 +42,37 @@ const queryClient = new QueryClient();
const { defineQuery, defineMutation } = createQueryFactories(queryClient);
```

### `queryOptions(input)`
### `resultQueryOptions(input)`

- Accepts a `queryKey` plus a `queryFn` that returns `Result<TData, TError>` (sync or async).
- Returns standard `QueryObserverOptions` whose `queryFn` resolves `Ok(data)` with `data` and throws on `Err(error)`.
- Preserves literal `queryKey` tuples (no `as const` needed) and Result data/error inference.

### `mutationOptions(input)`
### `resultMutationOptions(input)`

- Accepts a `mutationKey` plus a `mutationFn` that returns `Result<TData, TError>` (sync or async).
- Returns standard mutation observer options whose `mutationFn` resolves `Ok(data)` with `data` and throws on `Err(error)`.
- Infers variables from the `mutationFn` parameter.

### When to use which
### Which one do I reach for?

- Reach for `queryOptions` / `mutationOptions` when the options are local to a hook call site and you do not need imperative execution outside reactivity.
- Reach for `defineQuery` / `defineMutation` when you want a reusable definition with `.options` for hooks plus imperative helpers (`.fetch`, `.ensure`, and callable mutations) for preloaders, event handlers, and workflows.
Both families ride the same conversion path, so the choice is about *where the operation lives*, not about how Results are unwrapped.

> Note on naming: TanStack Query's framework adapters export their own `queryOptions` and `mutationOptions` identity helpers. Wellcrafted's versions occupy the same name on purpose; this package is the Result-aware equivalent. If you ever need both in one file, alias one on import.
Reach for **`resultQueryOptions` / `resultMutationOptions`** when the operation is local to the hook call site — any of:

- **Hook-local**: it is defined and used in one component, not shared.
- **Reactive options**: the `queryKey`, `enabled`, or `queryFn` closes over framework state (Svelte `$derived`, React state, props). These adapters run *inside* the reactive thunk (`createQuery(() => resultQueryOptions({ … }))`), so the options recompute every render. `defineQuery.options` is a static snapshot computed once and cannot carry a reactive key.
- **No global / a local `QueryClient`**: you are in a shared package with no app client to bind, or you want a purpose-built client with its own policy. These adapters are client-agnostic; the hook supplies the client.

Reach for **`createQueryFactories(queryClient).defineQuery` / `defineMutation`** when the operation is a reusable, `QueryClient`-bound handle — any of:

- **Shared / reusable identity**: one definition consumed from several call sites (an RPC/query layer).
- **Imperative execution**: you need `.fetch()` / `.ensure()` on a query, or a callable mutation handle, for preloaders, event handlers, and workflows — not just reactive `.options`.
- **Bound to the app client**: it lives at module scope against one long-lived `QueryClient`.

> **Cache operations stay on the `QueryClient`.** Wellcrafted deliberately does **not** wrap `invalidateQueries`, `setQueryData`, `getQueryData`, `ensureQueryData`, `prefetchQuery`, or any other cache method. Those already have TanStack's own contract and no `Result` to unwrap, so wrapping them would add surface without value. Call them directly on the `queryClient` (see the mutation cache-update examples below). The two families above wrap exactly one thing: a Result-returning `queryFn` / `mutationFn`.

> **Note on naming:** these adapters were previously called `queryOptions` / `mutationOptions`, which collided with TanStack Query's own identity helpers of the same name. The `result*` prefix removes that collision and says what they do — adapt a `Result`-returning function — so both can coexist in one file without aliasing.

## Architecture Pattern: The RPC-like Approach

Expand Down
4 changes: 2 additions & 2 deletions src/query/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export {
createQueryFactories,
defineKeys,
mutationOptions,
queryOptions,
resultMutationOptions,
resultQueryOptions,
} from "./utils.js";
32 changes: 16 additions & 16 deletions src/query/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@ import { Err, Ok } from "../result/index.js";
import {
createQueryFactories,
defineKeys,
mutationOptions,
queryOptions,
resultMutationOptions,
resultQueryOptions,
} from "./utils.js";

const queryClient = new QueryClient();
const { defineQuery, defineMutation } = createQueryFactories(queryClient);

describe("queryOptions", () => {
describe("resultQueryOptions", () => {
it("infers literal queryKey tuple without `as const`", () => {
const options = queryOptions({
const options = resultQueryOptions({
queryKey: ["users", "user-123"],
queryFn: ({ queryKey }) => {
expectTypeOf(queryKey).toEqualTypeOf<readonly ["users", "user-123"]>();
Expand All @@ -33,7 +33,7 @@ describe("queryOptions", () => {
it("preserves Ok/Err data and error types through inference", () => {
type AuthError = { code: "UNAUTHORIZED"; message: string };

const options = queryOptions({
const options = resultQueryOptions({
queryKey: ["session"],
queryFn: ():
| ReturnType<typeof Ok<{ userId: string }>>
Expand All @@ -53,7 +53,7 @@ describe("queryOptions", () => {
});

it("resolves Ok values into the TanStack data channel", async () => {
const options = queryOptions({
const options = resultQueryOptions({
queryKey: ["ok"],
queryFn: () => Ok(42),
});
Expand All @@ -68,7 +68,7 @@ describe("queryOptions", () => {
});

it("throws Err values into the TanStack error channel", async () => {
const options = queryOptions({
const options = resultQueryOptions({
queryKey: ["err"],
queryFn: () => Err("boom"),
});
Expand All @@ -84,7 +84,7 @@ describe("queryOptions", () => {
});

it("supports sync Result-returning queryFn", async () => {
const options = queryOptions({
const options = resultQueryOptions({
queryKey: ["sync"],
queryFn: () => Ok("sync-data"),
});
Expand All @@ -98,12 +98,12 @@ describe("queryOptions", () => {
});
});

describe("mutationOptions", () => {
describe("resultMutationOptions", () => {
it("infers variables and data from mutationFn", async () => {
type Input = { name: string };
type SaveError = { code: "CONFLICT"; message: string };

const options = mutationOptions({
const options = resultMutationOptions({
mutationKey: ["users", "create"],
mutationFn: (
input: Input,
Expand Down Expand Up @@ -132,7 +132,7 @@ describe("mutationOptions", () => {
it("accepts observer-only hook options", () => {
type SaveError = { code: "CONFLICT"; message: string };

const options = mutationOptions({
const options = resultMutationOptions({
mutationKey: ["users", "create"],
mutationFn: (input: { name: string }) =>
input.name.length > 0
Expand All @@ -147,7 +147,7 @@ describe("mutationOptions", () => {
});

it("resolves Ok values into the TanStack data channel", async () => {
const options = mutationOptions({
const options = resultMutationOptions({
mutationKey: ["m"],
mutationFn: async (n: number) => Ok(n * 2),
});
Expand All @@ -157,7 +157,7 @@ describe("mutationOptions", () => {
});

it("supports sync Result-returning mutationFn", async () => {
const options = mutationOptions({
const options = resultMutationOptions({
mutationKey: ["sync-mutation"],
mutationFn: (n: number) => Ok(n * 3),
});
Expand All @@ -167,7 +167,7 @@ describe("mutationOptions", () => {
});

it("throws Err values into the TanStack error channel", async () => {
const options = mutationOptions({
const options = resultMutationOptions({
mutationKey: ["m-err"],
mutationFn: async () => Err("fail"),
});
Expand All @@ -191,12 +191,12 @@ describe("defineQuery", () => {
>();
});

it("composes through queryOptions: .options matches queryOptions shape", () => {
it("composes through resultQueryOptions: .options matches resultQueryOptions shape", () => {
const userQuery = defineQuery({
queryKey: ["users", "u1"],
queryFn: () => Ok({ id: "u1" }),
});
const standalone = queryOptions({
const standalone = resultQueryOptions({
queryKey: ["users", "u1"],
queryFn: () => Ok({ id: "u1" }),
});
Expand Down
34 changes: 17 additions & 17 deletions src/query/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import type {
import { Err, Ok, type Result, resolve } from "../result/index.js";

/**
* Input for `queryOptions` and `defineQuery`.
* Input for `resultQueryOptions` and `defineQuery`.
*
* Mirrors TanStack Query's `QueryObserverOptions` but expects `queryFn` to
* return a Wellcrafted `Result`. The Result is unwrapped into TanStack's
* throwing data/error contract by `queryOptions`.
* throwing data/error contract by `resultQueryOptions`.
*
* @template TQueryFnData - The success type produced by `queryFn`
* @template TError - The error type carried by the Result
Expand All @@ -38,11 +38,11 @@ type QueryOptionsInput<
};

/**
* Input for `mutationOptions` and `defineMutation`.
* Input for `resultMutationOptions` and `defineMutation`.
*
* Mirrors TanStack Query's `MutationObserverOptions` but expects `mutationFn`
* to return a Wellcrafted `Result`. The Result is unwrapped into TanStack's
* throwing data/error contract by `mutationOptions`.
* throwing data/error contract by `resultMutationOptions`.
*
* @template TData - The success type produced by `mutationFn`
* @template TError - The error type carried by the Result
Expand Down Expand Up @@ -77,20 +77,20 @@ type MutationOptionsInput<
* `QueryClient`-bound imperative helpers from `defineQuery`:
*
* ```ts
* const query = createQuery(() => queryOptions({
* const query = createQuery(() => resultQueryOptions({
* queryKey: ['user', userId],
* queryFn: () => services.getUser(userId),
* }));
* ```
*
* `defineQuery` composes through this helper, so the `.options` it returns
* is the same shape `queryOptions` produces.
* is the same shape `resultQueryOptions` produces.
*
* @param input - Result-aware query configuration
* @returns TanStack Query `QueryObserverOptions` with `queryFn` rewired to
* resolve `Ok` and throw `Err`
*/
export function queryOptions<
export function resultQueryOptions<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
Expand Down Expand Up @@ -122,20 +122,20 @@ export function queryOptions<
* `QueryClient`-bound imperative helpers from `defineMutation`:
*
* ```ts
* const save = createMutation(() => mutationOptions({
* const save = createMutation(() => resultMutationOptions({
* mutationKey: ['saveUser'],
* mutationFn: (input: SaveUserInput) => services.saveUser(input),
* }));
* ```
*
* `defineMutation` composes through this helper, so the `.options` it
* returns is the same shape `mutationOptions` produces.
* returns is the same shape `resultMutationOptions` produces.
*
* @param input - Result-aware mutation configuration
* @returns TanStack Query `MutationObserverOptions` with `mutationFn` rewired
* to resolve `Ok` and throw `Err`
*/
export function mutationOptions<
export function resultMutationOptions<
TData,
TError,
TVariables = void,
Expand All @@ -162,7 +162,7 @@ export function mutationOptions<
*
* Query imperative reads require an explicit cache policy.
*
* - `options`: Options shape produced by `queryOptions`, ready for hooks.
* - `options`: Options shape produced by `resultQueryOptions`, ready for hooks.
* - `fetch()`: Always evaluates freshness; refetches if stale.
* - `ensure()`: Prefers cached data; fetches only when missing.
*/
Expand Down Expand Up @@ -190,7 +190,7 @@ type DefineQueryOutput<
* The returned function directly executes the mutation.
*
* - `(variables)` (callable): Imperatively runs the mutation, returning a Result.
* - `options`: Options shape produced by `mutationOptions`, ready for hooks.
* - `options`: Options shape produced by `resultMutationOptions`, ready for hooks.
*/
type DefineMutationOutput<
TData,
Expand All @@ -207,11 +207,11 @@ type DefineMutationOutput<
* Use this when you want a reusable query/mutation definition that carries
* its own imperative query helpers (`.fetch`, `.ensure`) and callable mutation
* execution powered by a specific client. For local one-shot options that only need
* to flow into a framework hook, prefer `queryOptions` / `mutationOptions`
* to flow into a framework hook, prefer `resultQueryOptions` / `resultMutationOptions`
* directly: those are platform-agnostic and do not require a `QueryClient`.
*
* Both `defineQuery` and `defineMutation` compose through `queryOptions` and
* `mutationOptions`, so there is exactly one place that unwraps `Result`
* Both `defineQuery` and `defineMutation` compose through `resultQueryOptions` and
* `resultMutationOptions`, so there is exactly one place that unwraps `Result`
* into TanStack's throwing contract.
*
* @param queryClient - The TanStack `QueryClient` to bind imperative helpers to
Expand Down Expand Up @@ -249,7 +249,7 @@ export function createQueryFactories(queryClient: QueryClient) {
TQueryKey
>,
): DefineQueryOutput<TQueryFnData, TError, TData, TQueryData, TQueryKey> => {
const options = queryOptions(input);
const options = resultQueryOptions(input);

async function fetch(): Promise<Result<TQueryData, TError>> {
try {
Expand Down Expand Up @@ -303,7 +303,7 @@ export function createQueryFactories(queryClient: QueryClient) {
TMutationKey
>,
): DefineMutationOutput<TData, TError, TVariables, TContext> => {
const options = mutationOptions(input);
const options = resultMutationOptions(input);

async function run(variables: TVariables) {
try {
Expand Down
Loading