diff --git a/.changeset/rename-result-query-mutation-options.md b/.changeset/rename-result-query-mutation-options.md new file mode 100644 index 0000000..90cb333 --- /dev/null +++ b/.changeset/rename-result-query-mutation-options.md @@ -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. diff --git a/skills/query-factories/SKILL.md b/skills/query-factories/SKILL.md index d852694..95da4de 100644 --- a/skills/query-factories/SKILL.md +++ b/skills/query-factories/SKILL.md @@ -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 diff --git a/src/query/README.md b/src/query/README.md index b3d3fab..1cace1b 100644 --- a/src/query/README.md +++ b/src/query/README.md @@ -10,7 +10,7 @@ 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. @@ -18,20 +18,20 @@ The query utilities solve a common integration challenge: your service functions 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), }), @@ -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` (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` (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 diff --git a/src/query/index.ts b/src/query/index.ts index 4b55538..31343af 100644 --- a/src/query/index.ts +++ b/src/query/index.ts @@ -1,6 +1,6 @@ export { createQueryFactories, defineKeys, - mutationOptions, - queryOptions, + resultMutationOptions, + resultQueryOptions, } from "./utils.js"; diff --git a/src/query/utils.test.ts b/src/query/utils.test.ts index 72e0613..f6c5aa1 100644 --- a/src/query/utils.test.ts +++ b/src/query/utils.test.ts @@ -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(); @@ -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> @@ -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), }); @@ -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"), }); @@ -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"), }); @@ -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, @@ -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 @@ -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), }); @@ -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), }); @@ -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"), }); @@ -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" }), }); diff --git a/src/query/utils.ts b/src/query/utils.ts index ac54645..578dad7 100644 --- a/src/query/utils.ts +++ b/src/query/utils.ts @@ -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 @@ -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 @@ -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, @@ -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, @@ -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. */ @@ -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, @@ -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 @@ -249,7 +249,7 @@ export function createQueryFactories(queryClient: QueryClient) { TQueryKey >, ): DefineQueryOutput => { - const options = queryOptions(input); + const options = resultQueryOptions(input); async function fetch(): Promise> { try { @@ -303,7 +303,7 @@ export function createQueryFactories(queryClient: QueryClient) { TMutationKey >, ): DefineMutationOutput => { - const options = mutationOptions(input); + const options = resultMutationOptions(input); async function run(variables: TVariables) { try {