authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-05-15 13:39:57-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-07-22 12:53:46-07:00
log7ad627cb1d7df5aad122280d12e72244e86cb447
treed855cb307d31a85f066bae1f003eb9dd5ceb0708
parentcc21b6dbb13e3b9425367a3e4a740ae8478a1633
signaturebadge-check Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ

feat: authenticated mutations

Closes #9

20 files changed, 1082 insertions(+), 79 deletions(-)

jsr.json+6-3
......@@ -1,10 +1,10 @@
11{
22 "name": "@clo/react-mutation",
3 "version": "2.1.0",
3 "version": "3.0.0",
44 "exports": {
55 ".": "./src/mod.ts",
6 "./tanstack-query.ts": "./src/tanstack-query.ts",
7 "./object-path.ts": "./src/object-path.ts"
6 "./tanstack-query": "./src/tanstack-query.ts",
7 "./object-path": "./src/object-path.ts"
88 },
99 "imports": {
1010 "@tanstack/react-query": "npm:@tanstack/react-query@^5",
......@@ -16,6 +16,9 @@
1616 "README.md",
1717 "src/**/*",
1818 "test/**/*"
19 ],
20 "exclude": [
21 "src/play.ts"
1922 ]
2023 },
2124 "license": "ISC"
package.json+5
......@@ -15,6 +15,11 @@
1515 "@clo/lib": "npm:@jsr/clo__lib@^3.0.0",
1616 "@std/assert": "npm:@jsr/std__assert@^1.0.17"
1717 },
18 "exports": {
19 ".": "./src/mod.ts",
20 "./tanstack-query": "./src/tanstack-query.ts",
21 "./object-path": "./src/object-path.ts"
22 },
1823 "devDependencies": {
1924 "@tanstack/react-query": "^5.90.20",
2025 "@testing-library/react": "^16.3.2",
readme.changes.md+28
......@@ -1,5 +1,32 @@
11# notable changes in React Mutation
22
3## v3
4
5### breaking
6
7- `id` is now required for all mutations. It must be unique per client;
8 duplicate registration throws in production and replaces the previous
9 registration in development (hot reload re-runs `define`).
10- mutation arguments must be JSON serializable (`Json[]`)
11- `handleUnauthenticated` and `onUnauthenticated` now receive a serializable
12 `MutationAction` (`{ id, description, args }`) instead of a description string
13
14### features
15
16- authentication integration for mutations (`auth: true`, `userContext`,
17 `isAllowed`, `handleUnauthenticated`)
18- auth scopes for permission gates: declare `authScopes` on the client and
19 `auth: "<scope>"` on mutations
20- `MutationClient.run(action)` re-runs a stored `MutationAction`, enabling
21 sign-in flows that resume the blocked mutation
22- `MutationButton` hides non-allowed mutations that cannot route to the
23 sign-in flow; opt out with `showNotAllowed`
24
25### bugfixes
26
27- `runAsHeadlessPromise` correctly rethrows the underlying error
28- clarify some intent in the readme
29
330## v2.1.0
431
532### features
......@@ -14,6 +41,7 @@
1441### bugfixes
1542
1643- typescript violation not passing client context to optimistic handlers
44- `onSuccess` prop split into `onSuccessUi` and `onSuccessData`. but it didn't even work before so this isn't breaking.
1745
1846## v2
1947
readme.md+79-5
......@@ -28,10 +28,7 @@ React Mutation starts with a `MutationClient`, which shares global state for an
2828```ts
2929import { showToastUI } from "...";
3030import { MutationClient } from "@clo/react-mutation";
31import {
32 boundQueryClientGet,
33 queryClientOptimisticHelpers,
34} from "@clo/react-mutation";
31import { boundQueryClientGet, queryClientOptimisticHelpers, reactiveFromQueryCache } from "@clo/react-mutation/tanstack-query";
3532import { QueryClient } from "@tanstack/react-query";
3633
3734const queryClient = new QueryClient();
......@@ -39,7 +36,7 @@ export const mutations = new MutationClient({
3936 // All properties in `context` are available within every function.
4037 context: {
4138 client: queryClient,
42 get: boundQueryClientGet(client),
39 get: boundQueryClientGet(queryClient),
4340
4441 // Can add any easy helpers for your codebase.
4542 navigateAway: (urlThatIsBeingDeleted: string, redirect: string) => ...,
......@@ -62,6 +59,33 @@ export const mutations = new MutationClient({
6259 reportSuccess(userFriendlySuccessMessage: string) {
6360 showToastUI("success", userFriendlyErrorMessage);
6461 },
62
63 // Optionally, your session system can be integrated to provide `auth: true`
64 // mutations that require a sign in before enabling. When a mutation cannot
65 // be performed due to missing auth, its `isAllowed` field reads false.
66 userContext: reactiveFromQueryCache(
67 queryClient,
68 queryCurrentUser,
69 (user) => user ? { user } : null,
70 ),
71 // Optionally, on top of `userContext`, custom subsets of authentication can be
72 // defined for different permission levels, for example admin-only. This is used
73 // at the call site with `auth: "admin"`.
74 authScopes: {
75 admin: reactiveFromQueryCache(queryClient, queryCurrentUser, (user) => !!user?.isAdmin),
76 },
77 // On top of `userContext`, the session system can integrate its login flow to
78 // the mutation system. When configured, all authenticated mutations will be
79 // marked enabled but `!isAllowed`, except ones in scopes (so an admin
80 // mutation is still disabled and not allowed). When triggering a mutation, it
81 // is routed to this function instead.
82 handleUnauthenticated(action, mutation) {
83 // `action` is serializable. Could commit it to `sessionStorage` to survive
84 // a full-page sign-in flow, for example.
85 openLoginModal(`Sign in to ${action.description}`, () => {
86 mutations.run(action);
87 });
88 },
6589});
6690```
6791
......@@ -76,6 +100,8 @@ const queryItem = (id: string) => queryOptions({ ... });
76100
77101// The convention is to name handlers starting with `mut`
78102const mutDeleteItem = mutations.define({
103 // A stable identifier, unique per application.
104 id: "item/delete",
79105 // `mutate` comes first (for type inference), and
80106 // is only worried about syncing with the backend.
81107 async mutate(id: string) {
......@@ -217,6 +243,7 @@ will override the earlier calls by rolling back the optimistic state.
217243
218244```tsx
219245const mutUpdateField = mutations.define({
246 id: "item/update-field",
220247 async mutate(id: string, value: string) { /* mutation */ },
221248 optimistic({ args: [id, value], helpers }) {
222249 helpers.objSet(queryItem(id), ["value"], value);
......@@ -252,6 +279,7 @@ anything, `snapshot` can be used to detect no-op mutations.
252279
253280```tsx
254281const mutUpdateField = mutations.define({
282 id: "item/update-field",
255283 async mutate(id: string, value: string) {/* mutation */},
256284
257285 optimistic({ args: [id, value], helpers }) {
......@@ -284,6 +312,8 @@ The `useMutate(null | Mutation)` react hook returns an object with the following
284312- `run` (Function) this starts the mutation.
285313- `clear` (Function) clear the status of sucess or error states.
286314- `isPending` (boolean) if a loading indicator should be visible.
315- `isDisabled` (boolean) if the underlying form/button should be disabled
316- `isAllowed` (boolean) if the mutation is allowed considering authentication and pre-checks
287317- `isSuccess` (boolean) if the mutation has succeeded.
288318- `result` (Result or undefined) the successful result of the mutation.
289319- `isError` (boolean) if the mutation failed.
......@@ -361,3 +391,47 @@ It can now be used for easy mutations:
361391 </MutationButton>
362392</>;
363393```
394
395## Authenticated Mutations
396
397Once the `MutationClient` is connected to the application's authentication
398system, mutations themselves can declare `auth: true`. This does two things:
399
400- `useMutate` and mutation buttons read `isAllowed: false` while signed out.
401 Without a `handleUnauthenticated` handler they also disable; with one they
402 stay enabled so a click can route to the sign-in flow.
403- The mutation implementation is given the `UserContext` to utilize.
404
405`describe` is the one function whose user context is nullable: it also runs
406while signed out to build the `action.description` given to the sign-in flow.
407
408```tsx
409const mutUpdateBio = mutations.define({
410 id: "user/update-bio",
411 auth: true,
412 async mutate(bio: string) {
413 this.user; // if the user context, if needed
414 },
415 optimistic({ args: [bio], helpers }) {
416 helpers.objSet(queryCurrentUser(), ["bio"], bio);
417 },
418 // (...the rest...)
419});
420```
421
422Scopes can allow easily adding permission gates. Unlike `auth: true`, a scoped
423mutation whose scope is unsatisfied always disables; it is never routed to
424`handleUnauthenticated`.
425
426```tsx
427const mutBanUser = mutations.define({
428 id: "user/ban",
429 auth: "admin",
430 async mutate(targetId: string) {/* mutation */},
431 // (...the rest...)
432});
433```
434
435By default, `MutationButton` will hide non-allowed mutations that cannot route
436to the sign-in flow, which can be opted out by passing the `showNotAllowed`
437prop.
src/client.ts+82-8
......@@ -1,21 +1,30 @@
11import { BlockingMutation, type MutationOptions } from "./mutation.ts";
2import type { Mutation } from "./types.ts";
2import type { Json, Mutation, MutationAction } from "./types.ts";
33
44export interface MutationClientConfig {
55 context: {};
66 optimisticHelpers: {};
7 userContext: {};
8 authScopes: string;
79}
810
911export type MutationClientFromConfig<Config extends MutationClientConfig> = MutationClient<
1012 Config["context"],
11 Config["optimisticHelpers"]
13 Config["optimisticHelpers"],
14 Config["userContext"],
15 Config["authScopes"]
1216>;
1317
1418const defaultDeepEquals = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
1519
20// Declared locally to avoid depending on node types.
21declare const process: { env: { NODE_ENV?: string } };
22
1623export interface MutationClientOptions<
1724 Context extends object,
1825 OptimisticHelpers extends object,
26 UserContext extends object = {},
27 AuthScopes extends string = never,
1928> {
2029 context: Context;
2130 getOptimisticHelpers: (
......@@ -35,6 +44,30 @@ export interface MutationClientOptions<
3544 * @default true
3645 */
3746 enabled?: boolean;
47 /** To support authenticated mutations, define a function that returns additional context. */
48 userContext?: Reactive<UserContext | null>;
49 /**
50 * Subsets of authentication for different permission levels, used at the
51 * define site with `auth: "<scope>"`. A scoped mutation is allowed only when
52 * a user is available and its scope reads `true`; unlike `auth: true`, it is
53 * never routed to `handleUnauthenticated`.
54 */
55 authScopes?: Record<AuthScopes, Reactive<boolean>>;
56 /**
57 * Called instead of running an authenticated mutation when no user is
58 * available. The action is JSON serializable and can be stored, then passed
59 * to {@linkcode MutationClient.run} once the user signs in.
60 */
61 handleUnauthenticated?: (
62 this: { context: Context },
63 action: MutationAction,
64 mutation: Mutation<Json[], unknown>,
65 ) => void;
66}
67
68export interface Reactive<T> {
69 get: () => T;
70 sub: (onChange: () => void) => () => void;
3871}
3972
4073export interface OptimisticEvents {
......@@ -45,16 +78,25 @@ export interface OptimisticEvents {
4578export class MutationClient<
4679 Context extends object,
4780 OptimisticHelpers extends object,
81 UserContext extends object = {},
82 AuthScopes extends string = never,
4883> {
4984 context: Context;
85 userContext?: Reactive<UserContext | null>;
86 authScopes?: { [scope: string]: Reactive<boolean> };
87 handleUnauthenticated?: (action: MutationAction, mutation: Mutation<Json[], unknown>) => void;
5088 getOptimisticHelpers: (event: OptimisticEvents) => OptimisticHelpers;
5189 reportError: (message: string, error: unknown) => void;
5290 reportSuccess?: (message: string) => void;
5391 deepEquals: (a: unknown, b: unknown) => boolean;
5492 enabled: boolean;
93 #mutations: Map<string, Mutation<Json[], unknown>> = new Map();
5594
56 constructor(options: MutationClientOptions<Context, OptimisticHelpers>) {
95 constructor(options: MutationClientOptions<Context, OptimisticHelpers, UserContext, AuthScopes>) {
5796 this.context = options.context;
97 this.userContext = options.userContext;
98 this.authScopes = options.authScopes;
99 this.handleUnauthenticated = options.handleUnauthenticated;
58100 this.getOptimisticHelpers = options.getOptimisticHelpers;
59101 this.reportError = options.reportError;
60102 this.reportSuccess = options.reportSuccess;
......@@ -65,17 +107,49 @@ export class MutationClient<
65107 /**
66108 * Define a standard mutation.
67109 */
68 define<const Args extends unknown[], Result>(
110 define<const Args extends Json[], Result, const Auth extends boolean | AuthScopes = false>(
69111 options: MutationOptions<
70112 Args,
71113 Result,
72 { context: Context; optimisticHelpers: OptimisticHelpers }
114 Auth,
115 { context: Context; optimisticHelpers: OptimisticHelpers; userContext: UserContext; authScopes: AuthScopes }
73116 >,
74117 ): Mutation<Args, Result> {
75 return new BlockingMutation<
118 if (typeof options.auth === "string" && !this.authScopes?.[options.auth]) {
119 throw new Error(`Unknown auth scope "${options.auth}".`);
120 }
121 if (this.#mutations.has(options.id)) {
122 // Hot reload re-evaluates defining modules against the same client, so
123 // a duplicate is assumed to be a replacement unless this is positively
124 // a production build. The `typeof` guard supports unbundled browsers;
125 // bundled browser builds inline NODE_ENV but leave `typeof process`
126 // alone, which downgrades them to the replace-and-warn path.
127 if (typeof process !== "undefined" && process.env.NODE_ENV === "production") {
128 throw new Error(`Mutation id "${options.id}" is already registered.`);
129 }
130 console.error(`Mutation id "${options.id}" registered twice; assuming hot reload and replacing it.`);
131 }
132 const mutation = new BlockingMutation<
76133 Args,
77134 Result,
78 { context: Context; optimisticHelpers: OptimisticHelpers }
79 >(this, options);
135 Auth,
136 { context: Context; optimisticHelpers: OptimisticHelpers; userContext: UserContext; authScopes: AuthScopes }
137 >(
138 this,
139 options,
140 );
141 this.#mutations.set(options.id, mutation as unknown as Mutation<Json[], unknown>);
142 return mutation;
143 }
144
145 /**
146 * Re-run a mutation from a stored {@linkcode MutationAction}, such as one
147 * captured by `handleUnauthenticated` before the user signed in. Results are
148 * reported through the global handlers.
149 */
150 run(action: Pick<MutationAction, "id" | "args">): void {
151 const mutation = this.#mutations.get(action.id);
152 if (!mutation) throw new Error(`Mutation id "${action.id}" is not registered.`);
153 mutation.run(...action.args);
80154 }
81155}
src/mod.ts+2-1
......@@ -3,6 +3,7 @@ export {
33 type MutationClientConfig,
44 type MutationClientFromConfig,
55 type MutationClientOptions,
6 type Reactive,
67} from "./client.ts";
78export type { MutationOptions, OptimisticContext } from "./mutation.ts";
89export {
......@@ -16,4 +17,4 @@ export {
1617 type UseMutateResultBase,
1718 type UseMutateSuccess,
1819} from "./react.ts";
19export type { Mutation, MutationEvent } from "./types.ts";
20export type { Json, Mutation, MutationAction, MutationEvent } from "./types.ts";
src/mutation.ts+152-50
......@@ -1,7 +1,7 @@
11import { message as errMessage } from "@clo/lib/error.ts";
2import type { MutationClient, MutationClientFromConfig } from "./client.ts";
2import type { MutationClientFromConfig, Reactive } from "./client.ts";
33import type { MutationClientConfig } from "./client.ts";
4import type { Mutation, MutationEvent, RunOptions } from "./types.ts";
4import type { Json, Mutation, MutationEvent, RunOptions } from "./types.ts";
55
66/**
77 * Argument to `defineBlocking`.
......@@ -10,10 +10,26 @@ import type { Mutation, MutationEvent, RunOptions } from "./types.ts";
1010 * @template Config - global values and helpers from `MutationContext`
1111 */
1212export interface MutationOptions<
13 Args extends unknown[],
13 Args extends Json[],
1414 Result,
15 Auth extends boolean | string,
1516 Config extends MutationClientConfig,
1617> {
18 /**
19 * Stable identifier, unique per client; duplicate registration throws in
20 * production and replaces the previous registration in development, where
21 * hot reload re-runs `define`. Identifies the mutation in a stored
22 * `MutationAction` so a call attempted while signed out can be restored
23 * with `MutationClient.run`.
24 */
25 id: string;
26 /**
27 * Require a current user before the mutation can run. Passing one of the
28 * client's `authScopes` additionally requires that scope to read `true`.
29 */
30 auth?: Auth;
31 /** Additional guard for fine-grained auth or domain-specific availability. */
32 isAllowed?: Reactive<boolean>;
1733 /**
1834 * This function is only responsible for performing the underlying API call,
1935 * syncronizing the optimistic state with reality. Throw on failure. A rest
......@@ -24,27 +40,31 @@ export interface MutationOptions<
2440 * In practice, optimistic context is never needed in this function, but it
2541 * is provided as the `this` value if you truly desire it.
2642 */
27 mutate: (this: Config["context"], ...args: Args) => Promise<Result>;
43 mutate: (this: Context<Config, Auth>, ...args: Args) => Promise<Result>;
44 /**
45 * Specifying the optimistic strategy is required. To disable, pass an empty
46 * function with a comment to document why it isn't needed.
47 */
48 optimistic: (
49 context: OptimisticContext<Args, Result, Config, Auth>,
50 ) => void;
2851 /**
2952 * Used in error messages and debug tools.
3053 * Phrase it considering the template `Could not ${describe(...)}`
54 *
55 * Unlike every other function, user context is nullable: this also runs
56 * while signed out to build the `MutationAction` description given to the
57 * sign-in flow.
3158 */
32 describe: string | ((context: Config["context"] & { args: Args }) => string);
59 describe: string | ((context: Context<Config, false> & { args: Args }) => string);
3360 /**
3461 * Used in success messages.
3562 * Phrase it as a complete success message: "Deleted Item"
3663 */
3764 describeResult:
3865 | string
39 | ((context: Config["context"] & { args: Args; result: Result }) => string)
66 | ((context: Context<Config, Auth> & { args: Args; result: Result }) => string)
4067 | null;
41 /**
42 * Specifying the optimistic strategy is required. To disable, pass an empty
43 * function with a comment to document why it isn't needed.
44 */
45 optimistic: (
46 context: OptimisticContext<Args, Result, Config>,
47 ) => void;
4868 /**
4969 * If the optimistic updator function is perfect, then this may be set to false.
5070 * @default true
......@@ -55,7 +75,7 @@ export interface MutationOptions<
5575 * specifying, then all mutations of the same key will evaluate in serial,
5676 * but optimistic updates will apply instantly.
5777 */
58 key?: (context: Config["context"] & { args: Args }) => string | string[];
78 key?: (context: Context<Config, Auth> & { args: Args }) => string | string[];
5979 /**
6080 * Enable debouncing with "last call wins" behavior. When rapid calls arrive,
6181 * the previous optimistic update is rolled back and the new one applied.
......@@ -76,14 +96,19 @@ export interface MutationOptions<
7696 * If the snapshots are equal (using deepEquals), the mutation is cancelled.
7797 * Only `onSettled` callbacks fire, not `onSuccess` or global handlers.
7898 */
79 snapshot?: (context: Config["context"] & { args: Args }) => unknown;
99 snapshot?: (context: Context<Config, Auth> & { args: Args }) => unknown;
80100}
81101
102type Context<Config extends MutationClientConfig, Auth extends boolean | string> =
103 & Config["context"]
104 & (Auth extends false ? Partial<Config["userContext"]> : Config["userContext"]);
105
82106export type OptimisticContext<
83107 Args extends unknown[],
84108 Result,
85109 Config extends MutationClientConfig,
86> = Config["context"] & {
110 Auth extends boolean | string,
111> = Context<Config, Auth> & {
87112 args: Args;
88113 helpers: Config["optimisticHelpers"];
89114 /** Add an event listener to roll back the update */
......@@ -94,9 +119,11 @@ export type OptimisticContext<
94119 onRefetch: (cb: () => Promise<void>) => void;
95120};
96121
97interface PendingDebouncedState<Args extends unknown[], Result> {
122interface PendingDebouncedState<Args extends unknown[], Result, Context> {
98123 /** Arguments from the most recent call */
99124 args: Args;
125 /** Context captured when the most recent call was allowed */
126 context: Context;
100127 /** Number of rollbacks the most recent call added */
101128 rollbackCount: number;
102129 /** All pending promises from all superseded calls */
......@@ -130,23 +157,24 @@ function unwrapMutationError(caught: unknown) {
130157 return { error: caught, description: null };
131158}
132159
133interface Channel<Args extends unknown[], Result, OptimisticHelpers> {
160interface Channel<Args extends unknown[], Result, OptimisticHelpers, Context> {
134161 listeners: Set<(update: MutationEvent<Result>) => void>;
135162 status: "idle" | "waiting" | "mutating" | "refetching" | "skipped";
136163 rollbacks: Array<() => void>;
137164 refetches: Array<() => Promise<void>>;
138 queue: Array<Item<Args, Result>>;
165 queue: Array<Item<Args, Result, Context>>;
139166 // Shared optimistic helpers instance for the channel
140167 helpers: OptimisticHelpers | null;
141168 // Debounce state (only used if debounce option is set)
142169 debounceTimer: ReturnType<typeof setTimeout> | null;
143 pendingDebounced: PendingDebouncedState<Args, Result> | null;
170 pendingDebounced: PendingDebouncedState<Args, Result, Context> | null;
144171 // Track when last debounced mutation executed (for debounceImmediate)
145172 lastDebouncedExecutionTime: number | null;
146173}
147174
148interface Item<Args extends unknown[], Result> {
175interface Item<Args extends unknown[], Result, Context> {
149176 args: Args;
177 context: Context;
150178 rollbacks: number;
151179 onSuccess: Array<(result: Result) => void>;
152180 resolve: (result: Result) => void;
......@@ -154,29 +182,70 @@ interface Item<Args extends unknown[], Result> {
154182}
155183
156184export class BlockingMutation<
157 Args extends unknown[],
185 Args extends Json[],
158186 Result,
187 Auth extends boolean | string,
159188 Config extends MutationClientConfig,
160189> implements Mutation<Args, Result> {
161 #options: MutationOptions<Args, Result, Config>;
190 #options: MutationOptions<Args, Result, Auth, Config>;
162191 #client: MutationClientFromConfig<Config>;
163192 #channels: Map<
164193 string,
165 Channel<Args, Result, Config["optimisticHelpers"]>
194 Channel<Args, Result, Config["optimisticHelpers"], Context<Config, Auth>>
166195 > = new Map();
167196 client: MutationClientFromConfig<Config>;
168197
169198 constructor(
170 client: MutationClient<Config["context"], Config["optimisticHelpers"]>,
171 options: MutationOptions<Args, Result, Config>,
199 client: MutationClientFromConfig<Config>,
200 options: MutationOptions<Args, Result, Auth, Config>,
172201 ) {
173202 this.#options = options;
174203 this.#client = client;
175204 this.client = client;
176205 }
177206
207 get id(): string {
208 return this.#options.id;
209 }
210
211 #context(): Context<Config, Auth> {
212 return {
213 ...this.#client.context,
214 ...(this.#client.userContext?.get() ?? {}),
215 } as Context<Config, Auth>;
216 }
217
218 #reportUnauthenticated(options: RunOptions<Result>, args: Args) {
219 const handler = options.onUnauthenticated ?? this.#client.handleUnauthenticated;
220 handler?.call(
221 this.#client,
222 { id: this.#options.id, description: this.describe(...args), args },
223 this as unknown as Mutation<Json[], unknown>,
224 );
225 }
226
227 isUnauthenticated(): boolean {
228 return this.#options.auth === true && this.#client.userContext?.get() == null;
229 }
230
231 isAllowed(): boolean {
232 const { auth } = this.#options;
233 if (auth !== undefined && auth !== false && this.#client.userContext?.get() == null) return false;
234 if (typeof auth === "string" && !(this.#client.authScopes?.[auth]?.get() ?? false)) return false;
235 return this.#options.isAllowed?.get() ?? true;
236 }
237
238 subscribeAllowed(cb: () => void): () => void {
239 const { auth } = this.#options;
240 const unsubscribes = [
241 this.#options.isAllowed?.sub(cb),
242 typeof auth === "string" ? this.#client.authScopes?.[auth]?.sub(cb) : undefined,
243 ];
244 return () => unsubscribes.forEach((unsubscribe) => unsubscribe?.());
245 }
246
178247 key(args: Args) {
179 const k = this.#options.key?.({ ...this.#client.context, args })
248 const k = this.#options.key?.({ ...this.#context(), args })
180249 ?? "shared";
181250 return JSON.stringify(k);
182251 }
......@@ -211,7 +280,7 @@ export class BlockingMutation<
211280 }
212281
213282 #notify(
214 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
283 channel: Channel<Args, Result, Config["optimisticHelpers"], Context<Config, Auth>>,
215284 status: MutationEvent<Result>["status"],
216285 result: Result | null = null,
217286 error: unknown = null,
......@@ -222,7 +291,7 @@ export class BlockingMutation<
222291
223292 #setIdle(
224293 key: string,
225 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
294 channel: Channel<Args, Result, Config["optimisticHelpers"], Context<Config, Auth>>,
226295 ) {
227296 // Check if there are pending debounced calls waiting
228297 if (channel.pendingDebounced !== null) {
......@@ -250,7 +319,7 @@ export class BlockingMutation<
250319 describe(...args: Args): string {
251320 const { describe } = this.#options;
252321 return typeof describe === "function"
253 ? describe({ ...this.#client.context, args })
322 ? describe({ ...this.#context(), args })
254323 : describe;
255324 }
256325
......@@ -258,7 +327,7 @@ export class BlockingMutation<
258327 const { describeResult } = this.#options;
259328 if (describeResult === null) return undefined;
260329 return typeof describeResult === "function"
261 ? describeResult({ ...this.#client.context, args, result })
330 ? describeResult({ ...this.#context(), args, result })
262331 : describeResult;
263332 }
264333
......@@ -284,8 +353,17 @@ export class BlockingMutation<
284353 }
285354
286355 const args = array.slice() as Args;
287 const { onSuccessUi: onSuccess, onSuccessData, onError, onSettled, onRestore } = args
288 .pop() as RunOptions<Result>;
356 const options = args.pop() as RunOptions<Result>;
357 const { onSuccessUi: onSuccess, onSuccessData, onError, onSettled, onRestore } = options;
358
359 if (!this.isAllowed()) {
360 if (this.isUnauthenticated()) {
361 this.#reportUnauthenticated(options, args);
362 return Promise.reject(new Error("Mutation requires authentication."));
363 }
364 return Promise.reject(new Error("Mutation is not allowed."));
365 }
366
289367 const promise = this.#runWithOptions(args, onRestore, true);
290368 return promise.then((result) => {
291369 // Call user handlers
......@@ -312,8 +390,22 @@ export class BlockingMutation<
312390 }
313391
314392 const args = array.slice() as Args;
315 const { onSuccessUi, onSuccessData, onError, onSettled, onRestore } = args
316 .pop() as RunOptions<Result>;
393 const options = args.pop() as RunOptions<Result>;
394 const { onSuccessUi, onSuccessData, onError, onSettled, onRestore } = options;
395 if (!this.isAllowed()) {
396 if (this.isUnauthenticated()) {
397 this.#reportUnauthenticated(options, args);
398 return;
399 }
400 const error = new Error("Mutation is not allowed.");
401 onError?.(error);
402 onSettled?.({ status: "error", error });
403 if (!onError) {
404 this.#client.reportError(formatFriendlyError(this.describe(...args), error), error);
405 }
406 return;
407 }
408
317409 const suppressAll = this.#options.debounceMs !== undefined && !onSuccessUi && !onError;
318410 const suppressGlobalSuccess = onSuccessUi !== undefined || suppressAll;
319411 const suppressGlobalError = onError !== undefined || suppressAll;
......@@ -357,6 +449,7 @@ export class BlockingMutation<
357449 }
358450 const key = this.key(args);
359451 const channel = this.#getOrPutChannel(key);
452 const context = this.#context();
360453
361454 // Check if debouncing is enabled
362455 if (this.#options.debounceMs !== undefined) {
......@@ -370,6 +463,7 @@ export class BlockingMutation<
370463 args,
371464 key,
372465 channel,
466 context,
373467 userOnRestore,
374468 !!shouldExecuteImmediate,
375469 suppressGlobalHandlers,
......@@ -378,7 +472,7 @@ export class BlockingMutation<
378472
379473 // Take snapshot before optimistic update (if snapshot function defined)
380474 const beforeSnapshot = this.#options.snapshot
381 ? this.#options.snapshot.call(this.#client.context, { args })
475 ? this.#options.snapshot.call(context, { ...context, args })
382476 : undefined;
383477
384478 // Create shared optimistic helpers instance for the channel if it doesn't exist
......@@ -416,7 +510,7 @@ export class BlockingMutation<
416510
417511 try {
418512 this.#options.optimistic({
419 ...this.#client.context,
513 ...context,
420514 args,
421515 helpers: channel.helpers,
422516 onRestore,
......@@ -452,8 +546,8 @@ export class BlockingMutation<
452546 // Take snapshot after optimistic update and check for no-op
453547 if (beforeSnapshot !== undefined) {
454548 const afterSnapshot = this.#options.snapshot!.call(
455 this.#client.context,
456 { args },
549 context,
550 { ...context, args },
457551 );
458552 const isNoOp = this.#client.deepEquals(beforeSnapshot, afterSnapshot);
459553
......@@ -475,6 +569,7 @@ export class BlockingMutation<
475569 const { promise, resolve, reject } = Promise.withResolvers<Result>();
476570 channel.queue.push({
477571 args,
572 context,
478573 rollbacks,
479574 onSuccess,
480575 resolve,
......@@ -490,7 +585,7 @@ export class BlockingMutation<
490585
491586 #executeNext(
492587 key: string,
493 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
588 channel: Channel<Args, Result, Config["optimisticHelpers"], Context<Config, Auth>>,
494589 ) {
495590 const item = channel.queue.shift();
496591 if (!item) {
......@@ -498,11 +593,11 @@ export class BlockingMutation<
498593 return;
499594 }
500595
501 const { args, onSuccess, resolve, reject } = item;
596 const { args, context, onSuccess, resolve, reject } = item;
502597 channel.status = "mutating";
503598 this.#notify(channel, "mutating");
504599
505 this.#options.mutate.call(this.#client.context, ...args).then((result) => {
600 this.#options.mutate.call(context, ...args).then((result) => {
506601 // remove rollbacks and apply optimistic success handlers
507602 channel.rollbacks.splice(0, item.rollbacks);
508603 onSuccess.forEach((cb) => cb(result));
......@@ -575,7 +670,8 @@ export class BlockingMutation<
575670 #runDebouncedAndReturn(
576671 args: Args,
577672 key: string,
578 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
673 channel: Channel<Args, Result, Config["optimisticHelpers"], Context<Config, Auth>>,
674 context: Context<Config, Auth>,
579675 userOnRestore: (() => void) | undefined,
580676 shouldExecuteImmediate: boolean,
581677 shouldCallGlobalHandler: boolean,
......@@ -584,7 +680,10 @@ export class BlockingMutation<
584680 const isFirstDebouncedCall = channel.pendingDebounced === null;
585681 let initialSnapshot: unknown;
586682 if (isFirstDebouncedCall && this.#options.snapshot) {
587 initialSnapshot = this.#options.snapshot.call(this.#client.context, { args });
683 initialSnapshot = this.#options.snapshot.call(
684 context,
685 { ...context, args },
686 );
588687 }
589688
590689 // If there's a pending debounced call, roll it back
......@@ -627,7 +726,7 @@ export class BlockingMutation<
627726
628727 try {
629728 this.#options.optimistic({
630 ...this.client.context,
729 ...context,
631730 args,
632731 helpers: channel.helpers,
633732 onRestore,
......@@ -664,8 +763,8 @@ export class BlockingMutation<
664763 // Check for no-op by comparing to initial snapshot
665764 if (this.#options.snapshot) {
666765 const currentSnapshot = this.#options.snapshot.call(
667 this.#client.context,
668 { args },
766 context,
767 { ...context, args },
669768 );
670769 const snapshotToCompare = isFirstDebouncedCall
671770 ? initialSnapshot!
......@@ -708,6 +807,7 @@ export class BlockingMutation<
708807 // First debounced call
709808 channel.pendingDebounced = {
710809 args,
810 context,
711811 rollbackCount: rollbacks,
712812 pending: [{ resolve, reject }],
713813 onSuccess,
......@@ -721,6 +821,7 @@ export class BlockingMutation<
721821 } else {
722822 // Subsequent debounced call - update state
723823 channel.pendingDebounced.args = args;
824 channel.pendingDebounced.context = context;
724825 channel.pendingDebounced.rollbackCount = rollbacks;
725826 channel.pendingDebounced.pending.push({ resolve, reject });
726827 channel.pendingDebounced.onSuccess = onSuccess;
......@@ -744,7 +845,7 @@ export class BlockingMutation<
744845 }
745846
746847 #rollbackPendingDebounced(
747 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
848 channel: Channel<Args, Result, Config["optimisticHelpers"], Context<Config, Auth>>,
748849 ) {
749850 if (!channel.pendingDebounced) return;
750851
......@@ -763,7 +864,7 @@ export class BlockingMutation<
763864
764865 #enqueueDebouncedCall(
765866 key: string,
766 channel: Channel<Args, Result, Config["optimisticHelpers"]>,
867 channel: Channel<Args, Result, Config["optimisticHelpers"], Context<Config, Auth>>,
767868 ) {
768869 // Clear timer
769870 channel.debounceTimer = null;
......@@ -774,7 +875,7 @@ export class BlockingMutation<
774875 return;
775876 }
776877
777 const { args, rollbackCount, pending, onSuccess, shouldCallGlobalHandler } = channel.pendingDebounced;
878 const { args, context, rollbackCount, pending, onSuccess, shouldCallGlobalHandler } = channel.pendingDebounced;
778879 channel.pendingDebounced = null;
779880
780881 // Track execution time for debounceImmediate
......@@ -822,6 +923,7 @@ export class BlockingMutation<
822923 // Add to queue (same structure as regular blocking mutation)
823924 channel.queue.push({
824925 args,
926 context,
825927 rollbacks: rollbackCount,
826928 onSuccess,
827929 resolve: wrapperResolve,
src/react.ts+45-7
......@@ -1,6 +1,5 @@
11import { message as errMessage } from "@clo/lib/error.ts";
22import type { Timer } from "@clo/lib/ts.ts";
3import { isDisabled } from "@testing-library/user-event/dist/cjs/utils/index.js";
43import {
54 type FC,
65 type MouseEvent,
......@@ -30,6 +29,7 @@ export function useMutate<
3029 if (mutation !== observer.mutation) {
3130 observer.mutation = mutation;
3231 observer.reset();
32 observer.subscribeAllowed();
3333 }
3434 return observer.binding;
3535}
......@@ -56,6 +56,8 @@ export interface UseMutateResultBase<Args extends unknown[], Result> {
5656 args: Args | undefined;
5757 /** `true` when controls should be disabled */
5858 isDisabled: boolean;
59 /** `true` when a mutation exists and its authentication requirement is satisfied. */
60 isAllowed: boolean;
5961}
6062
6163export interface UseMutateSuccess<Result> {
......@@ -121,7 +123,6 @@ type AnyMutationStateWithoutRun<Args extends unknown[], Result> =
121123 isSuccess: boolean;
122124 isError: boolean;
123125 args: Args | undefined;
124 isDisabled: boolean;
125126 };
126127
127128export type AnyMutationState<Args extends unknown[], Result> =
......@@ -140,7 +141,6 @@ function initialState() {
140141 isError: false,
141142 isOptimisticData: false,
142143 args: undefined,
143 isDisabled: true,
144144 } as const;
145145}
146146
......@@ -148,6 +148,7 @@ class Observer<Args extends unknown[], Result> {
148148 setRerender: (fn: number) => void;
149149 mutation: Mutation<Args, Result> | null = null;
150150 unsubscribe: (() => void) | null = null;
151 unsubscribeAllowed: (() => void) | null = null;
151152 currentKey: string | null = null;
152153 pendingTimer: Timer | null = null;
153154 debounced: boolean = false;
......@@ -174,12 +175,30 @@ class Observer<Args extends unknown[], Result> {
174175
175176 reset() {
176177 this.unsubscribe?.();
178 this.unsubscribeAllowed?.();
177179 this.unsubscribe = null;
180 this.unsubscribeAllowed = null;
178181 this.currentKey = null;
179182 this.state = initialState();
180183 this.debounced = false;
181184 }
182185
186 subscribeAllowed() {
187 const mutation = this.mutation;
188 if (!mutation) return;
189 const rerenderIfWatched = () => {
190 if (this.watched.has("isAllowed") || this.watched.has("isDisabled")) {
191 this.setRerender(Math.random());
192 }
193 };
194 const unsubscribeMutation = mutation.subscribeAllowed(rerenderIfWatched);
195 const unsubscribeClient = mutation.client.userContext?.sub(rerenderIfWatched) ?? (() => {});
196 this.unsubscribeAllowed = () => {
197 unsubscribeMutation();
198 unsubscribeClient();
199 };
200 }
201
183202 resetPending() {
184203 this.setState({ isPending: false });
185204 if (this.pendingTimer) clearTimeout(this.pendingTimer);
......@@ -373,10 +392,18 @@ class Observer<Args extends unknown[], Result> {
373392 self.watched.add("isMutating");
374393 return self.state.isMutating;
375394 },
376 // TODO: when auth drops this will be dependant on the auth status and isMutating
395 get isAllowed() {
396 self.watched.add("isAllowed");
397 return self.mutation?.isAllowed() ?? false;
398 },
377399 get isDisabled() {
400 self.watched.add("isDisabled");
378401 self.watched.add("isMutating");
379 return !self.mutation || (self.state.isMutating && !self.debounced);
402 const mutation = self.mutation;
403 if (!mutation || (self.state.isMutating && !self.debounced)) return true;
404 if (mutation.isAllowed()) return false;
405 // Unauthenticated clicks stay enabled when they can route to a sign-in flow.
406 return !(mutation.isUnauthenticated() && mutation.client.handleUnauthenticated);
380407 },
381408 get isPending() {
382409 self.watched.add("isPending");
......@@ -431,6 +458,8 @@ export interface MutationButtonProps<Args extends unknown[], Result> {
431458 onSuccessUi?: (result: Result) => void;
432459 /** Does not prevent the global handler */
433460 onSuccessData?: (result: Result) => void;
461 /** Called instead of running an authenticated mutation when no user is available. */
462 onUnauthenticated?: RunOptions<Result>["onUnauthenticated"];
434463
435464 /** Global event handlers will still be called! */
436465 onSettled?: (
......@@ -442,6 +471,9 @@ export interface MutationButtonProps<Args extends unknown[], Result> {
442471 error: unknown;
443472 },
444473 ) => void;
474
475 /** Setting this to true will opt out of the behavior that non-allowed buttons are hidden. */
476 showNotAllowed?: boolean;
445477}
446478
447479/**
......@@ -493,7 +525,9 @@ function GenericMutationButton<
493525 onError,
494526 onSuccessUi,
495527 onSuccessData,
528 onUnauthenticated,
496529 onSettled,
530 showNotAllowed,
497531 ...forwarded
498532 } = props;
499533 forwarded satisfies Omit<Props, keyof MutationButtonProps<Args, Result>>;
......@@ -508,9 +542,13 @@ function GenericMutationButton<
508542 if (!computedArgs || e.defaultPrevented) return;
509543 state.runWithOptions(
510544 ...computedArgs,
511 { onSuccessUi, onSuccessData, onError, onSettled },
545 { onSuccessUi, onSuccessData, onError, onUnauthenticated, onSettled },
512546 );
513 }, [args, onClick, onError, onSettled, onSuccessUi, onSuccessData, state]);
547 }, [args, onClick, onError, onSettled, onSuccessUi, onSuccessData, onUnauthenticated, state]);
548
549 // Buttons the user can never click are hidden; a signed-out `auth: true`
550 // button stays visible when it can route the click to the sign-in flow.
551 if (!showNotAllowed && !state.isAllowed && state.isDisabled) return null;
514552
515553 // NOTE: the JSR has trouble with JSX syntax for some reason.
516554 return jsx(
src/tanstack-query.ts+28-1
......@@ -1,5 +1,5 @@
11import { QueryClient, type QueryFunction, type QueryKey, type Updater } from "@tanstack/react-query";
2import type { OptimisticEvents } from "./client.ts";
2import type { OptimisticEvents, Reactive } from "./client.ts";
33import { type AllObjectPaths, type GetObjectPath, getPath, setPath } from "./object-path.ts";
44
55export type QueryKeyAndFn<T = unknown, Key extends QueryKey = QueryKey> = {
......@@ -19,6 +19,33 @@ export function boundQueryClientGet(
1919 };
2020}
2121
22export function reactiveFromQueryCache<T>(
23 client: QueryClient,
24 { queryKey }: QueryKeyAndFn<T>,
25): Reactive<T | undefined>;
26export function reactiveFromQueryCache<T, R>(
27 client: QueryClient,
28 { queryKey }: QueryKeyAndFn<T>,
29 deriver: (value: T | undefined) => R,
30): Reactive<R>;
31export function reactiveFromQueryCache<T>(
32 client: QueryClient,
33 { queryKey }: QueryKeyAndFn<T>,
34 deriver: (value: T | undefined) => unknown = x => x,
35): Reactive<unknown> {
36 return {
37 get: () => deriver(client.getQueryData<T>(queryKey)),
38 sub: (onChange) => {
39 const queryKeyJson = JSON.stringify(queryKey);
40 return client.getQueryCache().subscribe((event) => {
41 if (JSON.stringify(event.query.queryKey) === queryKeyJson) {
42 onChange();
43 }
44 });
45 },
46 };
47}
48
2249class TanstackQueryOptimisticHelpers {
2350 #client: QueryClient;
2451 #onRefetch: OptimisticEvents["onRefetch"];
src/types.ts+39-3
......@@ -1,6 +1,31 @@
11import type { MutationClient } from "./client.ts";
22
3/**
4 * Mutation arguments must be JSON serializable so a call can be stored and
5 * replayed later, such as re-running a mutation after the user signs in.
6 */
7export type Json =
8 | string
9 | number
10 | boolean
11 | null
12 | Json[]
13 | { [key: string]: Json };
14
15/**
16 * A serializable record of a mutation call. Produced when an authenticated
17 * mutation is attempted without a user; pass it to `MutationClient.run`
18 * after sign-in to re-run the call.
19 */
20export interface MutationAction {
21 id: string;
22 description: string;
23 args: Json[];
24}
25
326export interface Mutation<Args extends unknown[], Result> {
27 /** Unique identifier passed to `define`. */
28 readonly id: string;
429 /** Calling the mutation. Errors are turned into UI toasts. */
530 run(...args: Args): void;
631 /** Calls the mutation with custom handlers that can suppress global handlers. */
......@@ -25,7 +50,14 @@ export interface Mutation<Args extends unknown[], Result> {
2550 ): () => void;
2651 describe(...args: Args): string;
2752 describeResult: ((args: Args, result: Result) => string | undefined) | null;
28 client: MutationClient<object, object>;
53 /**
54 * `true` when an `auth: true` mutation has no user available. Scoped
55 * mutations are excluded; they disable instead of routing to the sign-in flow.
56 */
57 isUnauthenticated(): boolean;
58 isAllowed(): boolean;
59 subscribeAllowed(cb: () => void): () => void;
60 client: MutationClient<object, object, object>;
2961}
3062
3163export interface RunOptions<Result> {
......@@ -47,8 +79,12 @@ export interface RunOptions<Result> {
4779 ) => void;
4880 /** Called when optimistic state is being restored/rolled back */
4981 onRestore?: () => void;
50 /** @internal Suppresses global handlers for debounced mutations */
51 __suppressGlobalForDebounce?: boolean;
82 /**
83 * Called instead of running an authenticated mutation when no user is
84 * available. The action is JSON serializable and can be passed to
85 * `MutationClient.run` after sign-in.
86 */
87 onUnauthenticated?: (action: MutationAction, mutation: Mutation<Json[], unknown>) => void;
5288}
5389
5490export interface MutationEvent<Result> {
test/auth.test.tsx created+568
......@@ -0,0 +1,568 @@
1import {
2 createMutationButton,
3 type MutationAction,
4 MutationClient,
5 type Reactive,
6 useMutate,
7} from "@clo/react-mutation";
8import { assertEquals, assertThrows } from "@std/assert";
9import { act, render, screen } from "@testing-library/react";
10import { userEvent } from "@testing-library/user-event";
11import type { FC, MouseEventHandler, ReactNode } from "react";
12import { test, vi } from "vitest";
13import { IterableStream } from "./share.ts";
14
15interface User {
16 id: string;
17}
18
19function mutableReactive<T>(value: T): Reactive<T> & { set(value: T): void } {
20 const listeners = new Set<() => void>();
21 return {
22 get: () => value,
23 set(next) {
24 value = next;
25 listeners.forEach((listener) => listener());
26 },
27 sub(listener) {
28 listeners.add(listener);
29 return () => listeners.delete(listener);
30 },
31 };
32}
33
34function createAuthClient(currentUser: () => User | null, withHandler = true, admin?: Reactive<boolean>) {
35 const unauthenticated: MutationAction[] = [];
36 const errors: string[] = [];
37 const client = new MutationClient<{ contextValue: number }, {}, { user: User; accountId: string }, "admin">({
38 context: { contextValue: 42 },
39 userContext: {
40 get: () => {
41 const user = currentUser();
42 return user ? { user, accountId: "a1" } : null;
43 },
44 sub: () => () => {},
45 },
46 authScopes: admin ? { admin } : undefined,
47 handleUnauthenticated: withHandler ? (action) => unauthenticated.push(action) : undefined,
48 getOptimisticHelpers: () => ({}),
49 reportError: (message) => errors.push(message),
50 });
51
52 return { client, unauthenticated, errors };
53}
54
55test("authenticated mutations receive user in every context", async () => {
56 const currentUser = { id: "u1" };
57 const { client } = createAuthClient(() => currentUser);
58 const seen: string[] = [];
59 let snapshotValue = 0;
60
61 const mutation = client.define({
62 id: "test-auth",
63 auth: true,
64 mutate: async function(value: number) {
65 seen.push(`mutate:${this.user?.id}:${this.accountId}:${this.contextValue}:${value}`);
66 return value + 1;
67 },
68 key: ({ user, accountId, contextValue, args: [value] }) => {
69 seen.push(`key:${user?.id}:${accountId}:${contextValue}:${value}`);
70 return String(value);
71 },
72 snapshot: ({ user, accountId, contextValue, args: [value] }) => {
73 seen.push(`snapshot:${user?.id}:${accountId}:${contextValue}:${value}`);
74 return snapshotValue;
75 },
76 optimistic: ({ user, accountId, contextValue, args: [value] }) => {
77 seen.push(`optimistic:${user?.id}:${accountId}:${contextValue}:${value}`);
78 snapshotValue += 1;
79 },
80 describe: ({ user, accountId, contextValue, args: [value] }) => {
81 seen.push(`describe:${user?.id}:${accountId}:${contextValue}:${value}`);
82 return "Test auth";
83 },
84 describeResult: ({ user, accountId, contextValue, args: [value], result }) => {
85 seen.push(`result:${user?.id}:${accountId}:${contextValue}:${value}:${result}`);
86 return "Tested auth";
87 },
88 refetchOnSuccess: false,
89 });
90
91 assertEquals(mutation.key([1]), "\"1\"");
92 assertEquals(mutation.describe(1), "Test auth");
93 assertEquals(mutation.describeResult?.([1], 2), "Tested auth");
94 assertEquals(await mutation.runAsHeadlessPromise(1, {}), 2);
95
96 assertEquals(seen, [
97 "key:u1:a1:42:1",
98 "describe:u1:a1:42:1",
99 "result:u1:a1:42:1:2",
100 "key:u1:a1:42:1",
101 "snapshot:u1:a1:42:1",
102 "optimistic:u1:a1:42:1",
103 "snapshot:u1:a1:42:1",
104 "mutate:u1:a1:42:1",
105 ]);
106});
107
108test("authenticated mutations do not run while unauthenticated", async () => {
109 const { client, unauthenticated } = createAuthClient(() => null);
110 const localUnauthenticated: string[] = [];
111 const mutate = vi.fn(async () => "ok");
112 const optimistic = vi.fn();
113
114 const mutation = client.define({
115 id: "test-auth",
116 auth: true,
117 mutate,
118 optimistic,
119 describe: ({ user, contextValue }) => `Test auth as ${user?.id ?? "guest"}:${contextValue}`,
120 describeResult: "Tested auth",
121 });
122
123 mutation.runWithOptions({
124 onUnauthenticated: () => localUnauthenticated.push("local"),
125 });
126 mutation.run();
127 await Promise.resolve();
128
129 assertEquals(localUnauthenticated, ["local"]);
130 assertEquals(unauthenticated, [{ id: "test-auth", description: "Test auth as guest:42", args: [] }]);
131 assertEquals(optimistic.mock.calls, []);
132 assertEquals(mutate.mock.calls, []);
133});
134
135test("duplicate mutation ids throw in production and replace in development", async () => {
136 const { client } = createAuthClient(() => null);
137 const options = {
138 id: "test-duplicate",
139 mutate: async () => "ok",
140 optimistic: () => {},
141 describe: "Test duplicate",
142 describeResult: null,
143 refetchOnSuccess: false,
144 };
145 client.define(options);
146
147 vi.stubEnv("NODE_ENV", "production");
148 try {
149 assertThrows(() => client.define(options), Error, "Mutation id \"test-duplicate\" is already registered.");
150 } finally {
151 vi.unstubAllEnvs();
152 }
153
154 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
155 try {
156 const replacement = vi.fn(async () => "ok");
157 client.define({ ...options, mutate: replacement });
158 assertEquals(errorSpy.mock.calls.length, 1);
159
160 client.run({ id: "test-duplicate", args: [] });
161 assertEquals(replacement.mock.calls, [[]]);
162 } finally {
163 errorSpy.mockRestore();
164 }
165});
166
167test("unauthenticated actions survive JSON storage and restore after sign-in", async () => {
168 let currentUser: User | null = null;
169 const { client, unauthenticated } = createAuthClient(() => currentUser);
170 const mutate = vi.fn(async (value: number, note: string) => `${value}:${note}`);
171
172 client.define({
173 id: "test-restore",
174 auth: true,
175 mutate,
176 optimistic: () => {},
177 describe: "Test restore",
178 describeResult: null,
179 refetchOnSuccess: false,
180 });
181
182 client.run({ id: "test-restore", args: [1, "hello"] });
183 assertEquals(mutate.mock.calls, []);
184 assertEquals(unauthenticated, [{ id: "test-restore", description: "Test restore", args: [1, "hello"] }]);
185
186 const stored: MutationAction = JSON.parse(JSON.stringify(unauthenticated[0]));
187 currentUser = { id: "u1" };
188 client.run(stored);
189 await Promise.resolve();
190
191 assertEquals(mutate.mock.calls, [[1, "hello"]]);
192 assertThrows(() => client.run({ id: "test-unknown", args: [] }), Error, "not registered");
193});
194
195test("useMutate derives isAllowed and isDisabled from authentication", async () => {
196 const user = userEvent.setup({ delay: null });
197 let currentUser: User | null = null;
198 const { client } = createAuthClient(() => currentUser, false);
199 const s = new IterableStream<string>();
200
201 const mutation = client.define({
202 id: "test-auth",
203 auth: true,
204 mutate: async () => (await s.next()).value,
205 optimistic: () => {},
206 describe: "Test auth",
207 describeResult: "Tested auth",
208 });
209
210 let renders: Array<{ isAllowed: boolean; isDisabled: boolean; isMutating: boolean }> = [];
211 function TestComponent() {
212 const { run, isAllowed, isDisabled, isMutating } = useMutate(mutation);
213 renders.push({ isAllowed, isDisabled, isMutating });
214
215 return <button data-testid="run" onClick={() => run()}>run</button>;
216 }
217
218 const view = render(<TestComponent />);
219 assertEquals(renders, [{ isAllowed: false, isDisabled: true, isMutating: false }]);
220 renders = [];
221
222 currentUser = { id: "u1" };
223 view.rerender(<TestComponent />);
224 assertEquals(renders, [{ isAllowed: true, isDisabled: false, isMutating: false }]);
225 renders = [];
226
227 await act(() => user.click(screen.getByTestId("run")));
228 assertEquals(renders, [{ isAllowed: true, isDisabled: true, isMutating: true }]);
229 renders = [];
230
231 await act(async () => {
232 s.push("ok");
233 });
234 assertEquals(renders, [{ isAllowed: true, isDisabled: false, isMutating: false }]);
235});
236
237test("useMutate reacts to userContext changes", () => {
238 const userContext = mutableReactive<{ user: User; accountId: string } | null>(null);
239 const client = new MutationClient<{ contextValue: number }, {}, { user: User; accountId: string }>({
240 context: { contextValue: 42 },
241 userContext,
242 getOptimisticHelpers: () => ({}),
243 reportError: () => {},
244 });
245 const mutation = client.define({
246 id: "test-auth",
247 auth: true,
248 mutate: async () => "ok",
249 optimistic: () => {},
250 describe: "Test auth",
251 describeResult: "Tested auth",
252 });
253
254 let renders: Array<{ isAllowed: boolean; isDisabled: boolean }> = [];
255 function TestComponent() {
256 const { isAllowed, isDisabled } = useMutate(mutation);
257 renders.push({ isAllowed, isDisabled });
258 return null;
259 }
260
261 render(<TestComponent />);
262 assertEquals(renders, [{ isAllowed: false, isDisabled: true }]);
263 renders = [];
264
265 act(() => userContext.set({ user: { id: "u1" }, accountId: "a1" }));
266 assertEquals(renders, [{ isAllowed: true, isDisabled: false }]);
267});
268
269test("useMutate reacts to mutation isAllowed changes", () => {
270 const isAllowed = mutableReactive(false);
271 const { client } = createAuthClient(() => null);
272 const mutation = client.define({
273 id: "test-allowed",
274 isAllowed,
275 mutate: async () => "ok",
276 optimistic: () => {},
277 describe: "Test allowed",
278 describeResult: "Tested allowed",
279 });
280
281 let renders: Array<{ isAllowed: boolean; isDisabled: boolean }> = [];
282 function TestComponent() {
283 const { isAllowed, isDisabled } = useMutate(mutation);
284 renders.push({ isAllowed, isDisabled });
285 return null;
286 }
287
288 render(<TestComponent />);
289 assertEquals(renders, [{ isAllowed: false, isDisabled: true }]);
290 renders = [];
291
292 act(() => isAllowed.set(true));
293 assertEquals(renders, [{ isAllowed: true, isDisabled: false }]);
294});
295
296test("unauthenticated with a handler stays enabled and routes clicks to it", async () => {
297 const user = userEvent.setup({ delay: null });
298 const { client, unauthenticated } = createAuthClient(() => null);
299 const mutate = vi.fn(async () => "ok");
300
301 const mutation = client.define({
302 id: "test-auth",
303 auth: true,
304 mutate,
305 optimistic: () => {},
306 describe: "Test auth",
307 describeResult: "Tested auth",
308 });
309
310 let lastRender = { isAllowed: true, isDisabled: true };
311 function TestComponent() {
312 const { run, isAllowed, isDisabled } = useMutate(mutation);
313 lastRender = { isAllowed, isDisabled };
314 return <button data-testid="run" onClick={() => run()}>run</button>;
315 }
316
317 render(<TestComponent />);
318 assertEquals(lastRender, { isAllowed: false, isDisabled: false });
319
320 await act(() => user.click(screen.getByTestId("run")));
321 assertEquals(unauthenticated, [{ id: "test-auth", description: "Test auth", args: [] }]);
322 assertEquals(mutate.mock.calls, []);
323});
324
325test("running while isAllowed is false reports an error instead of the sign-in flow", () => {
326 const isAllowed = mutableReactive(false);
327 const { client, unauthenticated, errors } = createAuthClient(() => null);
328 const mutate = vi.fn(async () => "ok");
329
330 const mutation = client.define({
331 id: "test-not-allowed",
332 isAllowed,
333 mutate,
334 optimistic: () => {},
335 describe: "Test allowed",
336 describeResult: null,
337 });
338
339 mutation.run();
340
341 assertEquals(unauthenticated, []);
342 assertEquals(mutate.mock.calls, []);
343 assertEquals(errors, ["Could not test allowed: Mutation is not allowed."]);
344});
345
346test("MutationButton hides authenticated mutations without a sign-in flow", async () => {
347 const user = userEvent.setup({ delay: null });
348 const { client } = createAuthClient(() => null, false);
349 const mutate = vi.fn(async () => "ok");
350
351 const mutation = client.define({
352 id: "test-auth",
353 auth: true,
354 mutate,
355 optimistic: () => {},
356 describe: "Test auth",
357 describeResult: "Tested auth",
358 });
359
360 const MutationButtonBase: FC<{
361 children?: ReactNode;
362 disabled?: boolean;
363 isPending: boolean;
364 onClick: MouseEventHandler<HTMLElement> | undefined;
365 }> = ({ children, disabled, isPending, onClick }) => (
366 <button data-testid="button" disabled={disabled || isPending} onClick={onClick}>
367 {children}
368 </button>
369 );
370 const MutationButton = createMutationButton(MutationButtonBase);
371
372 const view = render(<MutationButton mutation={mutation} args={[]}>run</MutationButton>);
373 assertEquals(screen.queryByTestId("button"), null);
374
375 view.rerender(<MutationButton mutation={mutation} args={[]} showNotAllowed>run</MutationButton>);
376 assertEquals((screen.getByTestId("button") as HTMLButtonElement).disabled, true);
377 await act(() => user.click(screen.getByTestId("button")));
378 assertEquals(mutate.mock.calls, []);
379});
380
381test("auth scopes gate mutations without routing to sign-in", () => {
382 let currentUser: User | null = { id: "u1" };
383 const admin = mutableReactive(false);
384 const { client, unauthenticated, errors } = createAuthClient(() => currentUser, true, admin);
385 const mutate = vi.fn(async () => "ok");
386
387 const mutation = client.define({
388 id: "test-admin",
389 auth: "admin",
390 mutate,
391 optimistic: () => {},
392 describe: "Test admin",
393 describeResult: null,
394 refetchOnSuccess: false,
395 });
396
397 assertEquals(mutation.isAllowed(), false);
398 assertEquals(mutation.isUnauthenticated(), false);
399
400 mutation.run();
401 assertEquals(unauthenticated, []);
402 assertEquals(mutate.mock.calls, []);
403 assertEquals(errors, ["Could not test admin: Mutation is not allowed."]);
404
405 admin.set(true);
406 assertEquals(mutation.isAllowed(), true);
407 mutation.run();
408 assertEquals(mutate.mock.calls, [[]]);
409
410 currentUser = null;
411 assertEquals(mutation.isAllowed(), false);
412 assertEquals(mutation.isUnauthenticated(), false);
413});
414
415test("defining an unknown auth scope throws", () => {
416 const { client } = createAuthClient(() => null);
417
418 assertThrows(
419 () =>
420 client.define({
421 id: "test-unknown-scope",
422 auth: "admin",
423 mutate: async () => "ok",
424 optimistic: () => {},
425 describe: "Test unknown scope",
426 describeResult: null,
427 }),
428 Error,
429 "Unknown auth scope \"admin\".",
430 );
431});
432
433test("useMutate reacts to auth scope changes", () => {
434 const admin = mutableReactive(false);
435 const { client } = createAuthClient(() => ({ id: "u1" }), true, admin);
436 const mutation = client.define({
437 id: "test-admin",
438 auth: "admin",
439 mutate: async () => "ok",
440 optimistic: () => {},
441 describe: "Test admin",
442 describeResult: null,
443 });
444
445 let renders: Array<{ isAllowed: boolean; isDisabled: boolean }> = [];
446 function TestComponent() {
447 const { isAllowed, isDisabled } = useMutate(mutation);
448 renders.push({ isAllowed, isDisabled });
449 return null;
450 }
451
452 render(<TestComponent />);
453 assertEquals(renders, [{ isAllowed: false, isDisabled: true }]);
454 renders = [];
455
456 act(() => admin.set(true));
457 assertEquals(renders, [{ isAllowed: true, isDisabled: false }]);
458});
459
460test("MutationButton hides non-allowed mutations unless showNotAllowed", () => {
461 const admin = mutableReactive(false);
462 const { client } = createAuthClient(() => ({ id: "u1" }), true, admin);
463 const mutation = client.define({
464 id: "test-admin",
465 auth: "admin",
466 mutate: async () => "ok",
467 optimistic: () => {},
468 describe: "Test admin",
469 describeResult: null,
470 });
471
472 const MutationButtonBase: FC<{
473 children?: ReactNode;
474 disabled?: boolean;
475 isPending: boolean;
476 onClick: MouseEventHandler<HTMLElement> | undefined;
477 }> = ({ children, disabled, isPending, onClick }) => (
478 <button data-testid="button" disabled={disabled || isPending} onClick={onClick}>
479 {children}
480 </button>
481 );
482 const MutationButton = createMutationButton(MutationButtonBase);
483
484 const view = render(<MutationButton mutation={mutation} args={[]}>run</MutationButton>);
485 assertEquals(screen.queryByTestId("button"), null);
486
487 view.rerender(<MutationButton mutation={mutation} args={[]} showNotAllowed>run</MutationButton>);
488 assertEquals((screen.getByTestId("button") as HTMLButtonElement).disabled, true);
489
490 act(() => admin.set(true));
491 assertEquals((screen.getByTestId("button") as HTMLButtonElement).disabled, false);
492});
493
494test("MutationButton stays visible for unauthenticated mutations with a sign-in flow", () => {
495 const { client } = createAuthClient(() => null);
496 const mutation = client.define({
497 id: "test-auth",
498 auth: true,
499 mutate: async () => "ok",
500 optimistic: () => {},
501 describe: "Test auth",
502 describeResult: "Tested auth",
503 });
504
505 const MutationButtonBase: FC<{
506 children?: ReactNode;
507 disabled?: boolean;
508 isPending: boolean;
509 onClick: MouseEventHandler<HTMLElement> | undefined;
510 }> = ({ children, disabled, isPending, onClick }) => (
511 <button data-testid="button" disabled={disabled || isPending} onClick={onClick}>
512 {children}
513 </button>
514 );
515 const MutationButton = createMutationButton(MutationButtonBase);
516
517 render(<MutationButton mutation={mutation} args={[]}>run</MutationButton>);
518 assertEquals((screen.getByTestId("button") as HTMLButtonElement).disabled, false);
519});
520
521test("mutation isAllowed can disable unrelated to auth", () => {
522 let allow = false;
523 const isAllowed: Reactive<boolean> = {
524 get: () => allow,
525 sub: () => () => {},
526 };
527 const { client } = createAuthClient(() => null);
528
529 const mutation = client.define({
530 id: "test-allowed",
531 isAllowed,
532 mutate: async () => "ok",
533 optimistic: () => {},
534 describe: "Test allowed",
535 describeResult: "Tested allowed",
536 });
537
538 assertEquals(mutation.isAllowed(), false);
539 allow = true;
540 assertEquals(mutation.isAllowed(), true);
541});
542
543test("authenticated mutation isAllowed receives user context", () => {
544 let currentUser: User | null = { id: "u1" };
545 let allow = true;
546 const isAllowed: Reactive<boolean> = {
547 get: () => allow,
548 sub: () => () => {},
549 };
550 const { client } = createAuthClient(() => currentUser);
551
552 const mutation = client.define({
553 id: "test-auth-allowed",
554 auth: true,
555 isAllowed,
556 mutate: async () => "ok",
557 optimistic: () => {},
558 describe: "Test allowed",
559 describeResult: "Tested allowed",
560 });
561
562 assertEquals(mutation.isAllowed(), true);
563 allow = false;
564 assertEquals(mutation.isAllowed(), false);
565 allow = true;
566 currentUser = null;
567 assertEquals(mutation.isAllowed(), false);
568});
test/debounce.test.tsx+3
......@@ -13,6 +13,7 @@ test("debounceMs: waits before first call", async () => {
1313 const s = new IterableStream<string>();
1414
1515 const mutTest = client.define({
16 id: "test-1",
1617 mutate: async () => {
1718 return (await s.next()).value;
1819 },
......@@ -107,6 +108,7 @@ test("debounceMs: batch multiple calls together", async () => {
107108
108109 const mutations: number[] = [];
109110 const mutTest = client.define({
111 id: "test-2",
110112 mutate: async (k: number) => {
111113 mutations.push(k);
112114 return (await s.next()).value;
......@@ -188,6 +190,7 @@ test.todo("debounceImmediate runs the first one right away", async () => {
188190
189191 const mutations: number[] = [];
190192 const mutTest = client.define({
193 id: "test-3",
191194 mutate: async (k: number) => {
192195 mutations.push(k);
193196 return (await s.next()).value;
test/optimistic.test.tsx+1
......@@ -18,6 +18,7 @@ test.each([
1818 const streamRefreshes = new IterableStream<string>();
1919
2020 const mutTest = client.define({
21 id: "test-1",
2122 mutate: async () => {
2223 return (await streamResults.next()).value;
2324 },
test/optimistic.ts+1
......@@ -10,6 +10,7 @@ test("passes context value to functions", async () => {
1010
1111 let values: number[] = [];
1212 const mutTest = client.define({
13 id: "test-1",
1314 async mutate() {
1415 values.push(this.contextValue);
1516 calls.push("mutate");
test/ordering.test.ts+9
......@@ -11,6 +11,7 @@ test.each([
1111 const { client, errorMessages, successMessages } = createTestMutationClient();
1212 const calls: string[] = [];
1313 const mutTest = client.define({
14 id: "test-1",
1415 mutate: async () => {
1516 calls.push("mutate");
1617 await delay(100);
......@@ -60,6 +61,7 @@ test("error case: describe is called before rollback", async () => {
6061 let optimisticState = false;
6162
6263 const mutTest = client.define({
64 id: "test-2",
6365 mutate: async () => {
6466 calls.push("mutate");
6567 await delay(100);
......@@ -122,6 +124,7 @@ test("error case with refetchOnSuccess=false still refetches", async () => {
122124 const calls: string[] = [];
123125
124126 const mutTest = client.define({
127 id: "test-3",
125128 mutate: async () => {
126129 calls.push("mutate");
127130 await delay(100);
......@@ -155,6 +158,7 @@ test("multiple mutations in sequence: ordering preserved", async () => {
155158 const calls: string[] = [];
156159
157160 const mutTest = client.define({
161 id: "test-4",
158162 mutate: async (id: number) => {
159163 calls.push(`mutate-${id}`);
160164 await delay(100);
......@@ -226,6 +230,7 @@ test("error in second mutation: first stays applied, second rolls back", async (
226230 let state = 0;
227231
228232 const mutTest = client.define({
233 id: "test-5",
229234 mutate: async (id: number) => {
230235 calls.push(`mutate-${id}`);
231236 await delay(100);
......@@ -275,6 +280,7 @@ test("onSuccess callback ordering relative to refetch", async () => {
275280 const calls: string[] = [];
276281
277282 const mutTest = client.define({
283 id: "test-6",
278284 mutate: async () => {
279285 calls.push("mutate");
280286 await delay(100);
......@@ -321,6 +327,7 @@ test("runWithOptions callbacks: onError called before global handler", async ()
321327 const calls: string[] = [];
322328
323329 const mutTest = client.define({
330 id: "test-7",
324331 mutate: async () => {
325332 await delay(100);
326333 throw new Error("Failed");
......@@ -364,6 +371,7 @@ test("runWithOptions callbacks: onSuccess called before global handler", async (
364371 const calls: string[] = [];
365372
366373 const mutTest = client.define({
374 id: "test-8",
367375 mutate: async () => {
368376 await delay(100);
369377 return "result";
......@@ -407,6 +415,7 @@ test("optimistic update with no describeResult: no success message", async () =>
407415 const calls: string[] = [];
408416
409417 const mutTest = client.define({
418 id: "test-9",
410419 mutate: async () => {
411420 await delay(100);
412421 return "result";
test/runWithOptions.test.tsx+2
......@@ -13,6 +13,7 @@ test("runWithOptions should allow react hook to do local handling", async () =>
1313 const s = new IterableStream<string>();
1414
1515 const mutTest = client.define({
16 id: "test-1",
1617 mutate: async () => {
1718 return (await s.next()).value;
1819 },
......@@ -74,6 +75,7 @@ test("runAsHeadlessPromise rejects with the underlying error", async () => {
7475 const localErrors: unknown[] = [];
7576
7677 const mutTest = client.define({
78 id: "test-2",
7779 mutate: async () => {
7880 return (await s.next()).value;
7981 },
test/setError.test.tsx+3
......@@ -12,6 +12,7 @@ test("setError should manually set error state on the hook", async () => {
1212 const { client, successMessages, errorMessages } = createTestMutationClient();
1313
1414 const mutTest = client.define({
15 id: "test-1",
1516 mutate: async () => {
1617 return "success";
1718 },
......@@ -92,6 +93,7 @@ test("setError should override success state", async () => {
9293 const s = new IterableStream<string>();
9394
9495 const mutTest = client.define({
96 id: "test-2",
9597 mutate: async () => {
9698 return (await s.next()).value;
9799 },
......@@ -206,6 +208,7 @@ test("setError should work with different error types", async () => {
206208 const { client } = createTestMutationClient();
207209
208210 const mutTest = client.define({
211 id: "test-3",
209212 mutate: async () => {
210213 return "success";
211214 },
test/snapshot.test.tsx+4
......@@ -15,6 +15,7 @@ test("snapshot should skip no-op mutation", async () => {
1515 let state = { value: "initial" };
1616 let failed = false;
1717 const mutUpdate = client.define({
18 id: "test-1",
1819 mutate: async (newValue: string) => {
1920 failed = true;
2021 },
......@@ -63,6 +64,7 @@ test("snapshot should allow mutation when value changes", async () => {
6364 let state = { value: "initial" };
6465
6566 const mutUpdate = client.define({
67 id: "test-2",
6668 mutate: async (newValue: string) => {
6769 return (await s.next()).value;
6870 },
......@@ -116,6 +118,7 @@ test("debounced snapshot should skip when final state equals initial", async ()
116118
117119 const mutations: string[] = [];
118120 const mutUpdate = client.define({
121 id: "test-3",
119122 mutate: async (newValue: string) => {
120123 mutations.push(newValue);
121124 },
......@@ -176,6 +179,7 @@ test("debounced snapshot should mutate when final differs from initial", async (
176179 let state = { value: "initial" };
177180
178181 const mutUpdate = client.define({
182 id: "test-4",
179183 mutate: async (newValue: string) => {
180184 return (await s.next()).value;
181185 },
test/tanstack-query-helpers.test.ts+22-1
......@@ -1,7 +1,7 @@
11import { assertEquals } from "@std/assert";
22import { QueryClient, queryOptions } from "@tanstack/react-query";
33import { test } from "vitest";
4import { queryClientOptimisticHelpers } from "../src/tanstack-query.ts";
4import { queryClientOptimisticHelpers, reactiveFromQueryCache } from "../src/tanstack-query.ts";
55
66interface TestData {
77 name: string;
......@@ -89,6 +89,27 @@ test("helpers can be spread and retain bound this", () => {
8989 assertEquals(result?.count, 12);
9090});
9191
92test("reactiveFromQueryCache derives and subscribes to query data", () => {
93 const { client, queryTest } = createTestQueryClient();
94 const reactive = reactiveFromQueryCache(
95 client,
96 queryTest,
97 (data) => data ? { name: data.name } : null,
98 );
99 let changes = 0;
100 const unsubscribe = reactive.sub(() => changes += 1);
101
102 assertEquals(reactive.get(), { name: "Test" });
103
104 client.setQueryData(queryTest.queryKey, { ...initialData, name: "Updated" });
105 assertEquals(reactive.get(), { name: "Updated" });
106 assertEquals(changes, 1);
107
108 unsubscribe();
109 client.setQueryData(queryTest.queryKey, { ...initialData, name: "Ignored" });
110 assertEquals(changes, 1);
111});
112
92113// ============================================================================
93114// set() tests
94115// ============================================================================
test/useMutate.test.tsx+3
......@@ -14,6 +14,7 @@ test("useMutate - global error and success handling", async () => {
1414 const s = new IterableStream<string>();
1515
1616 const mutTest = client.define({
17 id: "test-1",
1718 mutate: async () => {
1819 return (await s.next()).value;
1920 },
......@@ -99,6 +100,7 @@ test("useMutate - local error and success handling", async () => {
99100 const s = new IterableStream<string>();
100101
101102 const mutTest = client.define({
103 id: "test-2",
102104 mutate: async () => {
103105 return (await s.next()).value;
104106 },
......@@ -282,6 +284,7 @@ test("MutationButton should allow args={null} to disable mutation runs", async (
282284 const mutate = vi.fn(async (value: number) => value + 1);
283285
284286 const mutTest = client.define({
287 id: "test-3",
285288 mutate,
286289 describe: "Test the action",
287290 describeResult: "Tested the action",