authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-29 16:43:24-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-29 16:57:23-08:00
logf62d1ee915bf4e77c8e2d55d1286211289fbdd51
tree1b1406624e4c0e0c1f2cbe62bbee79ff46ea8197
parent2d5acae7f8014bc80c4ddf58592529eeaae282af
signaturelock-open Commit is signed but in an unrecognized format.

feat: some more crashouts


11 files changed, 406 insertions(+), 304 deletions(-)

jsr.json+1-1
......@@ -1,6 +1,6 @@
11{
22 "name": "@clo/react-mutation",
3 "version": "1.0.0-beta.5",
3 "version": "1.0.0-beta.6",
44 "exports": {
55 ".": "./src/mod.ts",
66 "./tanstack-query.ts": "./src/tanstack-query.ts",
package-lock.json+15
......@@ -19,6 +19,7 @@
1919 "@vitejs/plugin-react": "^5.1.1",
2020 "react": "^19.2.4",
2121 "react-dom": "^19.2.4",
22 "typescript": "^5.9.3",
2223 "vite": "^7.2.4",
2324 "vitest": "^4.0.18"
2425 },
......@@ -1998,6 +1999,20 @@
19981999 "node": ">=14.0.0"
19992000 }
20002001 },
2002 "node_modules/typescript": {
2003 "version": "5.9.3",
2004 "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
2005 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
2006 "dev": true,
2007 "license": "Apache-2.0",
2008 "bin": {
2009 "tsc": "bin/tsc",
2010 "tsserver": "bin/tsserver"
2011 },
2012 "engines": {
2013 "node": ">=14.17"
2014 }
2015 },
20012016 "node_modules/undici-types": {
20022017 "version": "7.16.0",
20032018 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
package.json+1
......@@ -23,6 +23,7 @@
2323 "@vitejs/plugin-react": "^5.1.1",
2424 "react": "^19.2.4",
2525 "react-dom": "^19.2.4",
26 "typescript": "^5.9.3",
2627 "vite": "^7.2.4",
2728 "vitest": "^4.0.18"
2829 },
readme.md+4-3
......@@ -15,9 +15,10 @@ The primary gains React Mutation provides are
1515- **Automatic result handling**. If a `useMutate` hook does not observe
1616 `isError`, unhandled errors will be propagated to a global handler, which can
1717 display a UI toast. Otherwise, the component can display the error locally.
18- Optimistic helpers allow defining rollbacks and refetching logic independant
19 of the actual mutation. The [built in helpers for React Query](#react-query-optimistic-helpers)
20 shows this power in more detail.
18- **Optimistic helpers with built-in rollbacks** make it super easy to alter the
19 UI without worrying about bugged error states. The
20 [built in helpers for React Query](#react-query-optimistic-helpers) shows
21 this power in more detail.
2122- Easy debouncing and batching utilities.
2223
2324## Usage
src/blocking.ts+54-40
......@@ -1,6 +1,6 @@
11import type { MutationClient, MutationClientFromConfig } from "./client.ts";
22import type { MutationClientConfig } from "./client.ts";
3import type { Mutation, MutationEvent } from "./types.ts";
3import type { Mutation, MutationEvent, RunOptions } from "./types.ts";
44import { message as errMessage } from "@clo/lib/error.ts";
55
66/**
......@@ -45,12 +45,6 @@ export interface MutationOptions<
4545 optimistic: (
4646 context: OptimisticContext<Args, Result, Config>,
4747 ) => void;
48 /**
49 * Refetch all of the data this mutation could have affected.
50 * Normally, optimistic helpers will perform
51 * This is called automatically on errors.
52 */
53 refetch?: (context: Config["context"] & { args: Args }) => Promise<void>;
5448 /**
5549 * If the optimistic updator function is perfect, then this may be set to false.
5650 * @default true
......@@ -231,26 +225,56 @@ export class BlockingMutation<
231225
232226 /** Calling the mutation in a global scope. Errors are turned into UI toasts. */
233227 run(...args: Args) {
228 this.runWithOptions(...args, {});
229 }
230
231 /** Calls the mutation with custom handlers that can suppress global handlers. */
232 runWithOptions(...array: [...Args, RunOptions<Result>]): Promise<Result> {
234233 if (!this.#client.enabled) {
235234 throw new Error(
236235 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",
237236 );
238237 }
239 this.runAndReturn(...args).then((result) => {
240 const message = this.describeResult(args, result);
241 if (message && this.#client.reportSuccess) {
242 this.#client.reportSuccess(message);
238
239 const args = array.slice() as Args;
240 const { onSuccess, onSuccessDataOnly, onError, onSettled } = args
241 .pop() as RunOptions<Result>;
242 const suppressGlobalSuccess = onSuccess !== undefined;
243 const suppressGlobalError = onError !== undefined;
244
245 const promise = this.runAsPromise(...args);
246 promise.then((result) => {
247 // Call user handlers
248 onSuccess?.(result);
249 onSuccessDataOnly?.(result);
250 onSettled?.({ status: "success", result });
251
252 // Call global handler unless suppressed
253 if (!suppressGlobalSuccess) {
254 const message = this.describeResult(args, result);
255 if (message && this.#client.reportSuccess) {
256 this.#client.reportSuccess(message);
257 }
243258 }
244259 }).catch((error) => {
245 const message = `Failed to ${this.describe(...args)}: ${
246 errMessage(error)
247 }`;
248 this.#client.reportError(message, error);
260 // Call user handlers
261 onError?.(error);
262 onSettled?.({ status: "error", error });
263
264 // Call global handler unless suppressed
265 if (!suppressGlobalError) {
266 const message = `Failed to ${this.describe(...args)}: ${
267 errMessage(error)
268 }`;
269 this.#client.reportError(message, error);
270 }
249271 });
272
273 return promise;
250274 }
251275
252276 /** Calls the mutation, treating the errors as promise rejection. */
253 runAndReturn(...args: Args): Promise<Result> {
277 runAsPromise(...args: Args): Promise<Result> {
254278 if (!this.#client.enabled) {
255279 throw new Error(
256280 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",
......@@ -366,23 +390,19 @@ export class BlockingMutation<
366390 this.#notify(channel, "refetching", result);
367391 // Call refetch and all refetch callbacks in parallel
368392 const refetchCallbacks = channel.refetches.splice(0);
369 Promise.allSettled([
370 this.#options.refetch?.({
371 ...this.#client.context,
372 args,
373 }),
374 ...refetchCallbacks.map((cb) => cb()),
375 ]).then((results) => {
376 // Report any errors from refetch or callbacks
377 results.forEach((result) => {
378 if (result.status === "rejected") {
379 const message = `Failed to refetch after ${
380 this.describe(...args)
381 }: ${errMessage(result.reason)}`;
382 this.#client.reportError(message, result.reason);
383 }
384 });
385 }).finally(() => {
393 Promise.allSettled(refetchCallbacks.map((cb) => cb())).then(
394 (results) => {
395 // Report any errors from refetch or callbacks
396 results.forEach((result) => {
397 if (result.status === "rejected") {
398 const message = `Failed to refetch after ${
399 this.describe(...args)
400 }: ${errMessage(result.reason)}`;
401 this.#client.reportError(message, result.reason);
402 }
403 });
404 },
405 ).finally(() => {
386406 this.#executeNext(key, channel);
387407 });
388408 } else {
......@@ -410,13 +430,7 @@ export class BlockingMutation<
410430 this.#notify(channel, "refetching", null, error);
411431 // Call refetch and all refetch callbacks in parallel
412432 const refetchCallbacks = channel.refetches.splice(0);
413 Promise.allSettled([
414 this.#options.refetch?.({
415 ...this.#client.context,
416 args,
417 }),
418 ...refetchCallbacks.map((cb) => cb()),
419 ]).then((results) => {
433 Promise.allSettled(refetchCallbacks.map((cb) => cb())).then((results) => {
420434 // Report any errors from refetch or callbacks
421435 results.forEach((result) => {
422436 if (result.status === "rejected") {
src/debounced.ts+49-16
......@@ -1,6 +1,6 @@
11import type { MutationClient, MutationClientFromConfig } from "./client.ts";
22import type { MutationClientConfig } from "./client.ts";
3import type { Mutation, MutationEvent } from "./types.ts";
3import type { Mutation, MutationEvent, RunOptions } from "./types.ts";
44import { message as errMessage } from "@clo/lib/error.ts";
55
66export interface DebouncedMutationOptions<
......@@ -293,8 +293,42 @@ export class DebouncedMutation<
293293 });
294294 }
295295
296 runWithOptions(...array: [...args: Args, options: RunOptions<Result>]): void {
297 if (!this.#client.enabled) {
298 throw new Error(
299 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",
300 );
301 }
302 const args = array.slice() as Args;
303 const { onSuccess, onSuccessDataOnly, onError, onSettled } = args
304 .pop() as RunOptions<Result>;
305 const suppressGlobalSuccess = onSuccess !== undefined;
306 const suppressGlobalError = onError !== undefined;
307
308 const promise = this.#runAndReturn(args, !suppressGlobalSuccess);
309
310 promise.then((result) => {
311 // Call user handlers
312 onSuccess?.(result);
313 onSuccessDataOnly?.(result);
314 onSettled?.({ status: "success", result });
315 }).catch((error) => {
316 // Call user handlers
317 onError?.(error);
318 onSettled?.({ status: "error", error });
319
320 // Call global error handler unless suppressed
321 if (!suppressGlobalError) {
322 const message = `Failed to ${this.describe(...args)}: ${
323 errMessage(error)
324 }`;
325 this.#client.reportError(message, error);
326 }
327 });
328 }
329
296330 /** Calls the mutation, treating the errors as promise rejection. */
297 runAndReturn(...args: Args): Promise<Result> {
331 runAsPromise(...args: Args): Promise<Result> {
298332 if (!this.#client.enabled) {
299333 throw new Error(
300334 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",
......@@ -546,20 +580,19 @@ export class DebouncedMutation<
546580 channel.status = "refetching";
547581 this.#notify(channel, "refetching", null, error);
548582 // Call refetch and all refetch callbacks in parallel
549 Promise.allSettled([
550 this.#options.refetch?.(),
551 ...refetchCallbacks.map((cb) => cb()),
552 ]).then((results) => {
553 // Report any errors from refetch or callbacks
554 results.forEach((result) => {
555 if (result.status === "rejected") {
556 const message = `Failed to refetch after ${
557 this.describe(...firstArgs)
558 }: ${errMessage(result.reason)}`;
559 this.#client.reportError(message, result.reason);
560 }
561 });
562 }).finally(() => {
583 Promise.allSettled(refetchCallbacks.map((cb) => cb())).then(
584 (results) => {
585 // Report any errors from refetch or callbacks
586 results.forEach((result) => {
587 if (result.status === "rejected") {
588 const message = `Failed to refetch after ${
589 this.describe(...firstArgs)
590 }: ${errMessage(result.reason)}`;
591 this.#client.reportError(message, result.reason);
592 }
593 });
594 },
595 ).finally(() => {
563596 // Check if new calls came in during the commit
564597 if (channel.pending.length > 0) {
565598 // There are pending calls that need to be committed
src/react.ts+63-17
......@@ -8,7 +8,7 @@ import {
88 useState,
99} from "react";
1010import { message as errMessage } from "@clo/lib/error.ts";
11import type { Mutation } from "./types.ts";
11import type { Mutation, RunOptions } from "./types.ts";
1212import { jsx } from "react/jsx-runtime";
1313
1414/**
......@@ -41,7 +41,10 @@ export type UseMutateResult<Args extends unknown[], Result> =
4141
4242export interface UseMutateResultBase<Args extends unknown[], Result> {
4343 run: (...args: Args) => void;
44 runWithResult: (...args: Args) => Promise<Result>;
44 runWithOptions: (
45 options: RunOptions<Result>,
46 ...args: Args
47 ) => Promise<Result>;
4548 clear: () => void;
4649}
4750
......@@ -220,7 +223,7 @@ class Observer<Args extends unknown[], Result> {
220223 this.watched.has("error") || this.watched.has("errorMessage");
221224 const watchesSuccess = this.watched.has("isSuccess") ||
222225 this.watched.has("result");
223 const promise = mutation.runAndReturn(...args)
226 const promise = mutation.runAsPromise(...args)
224227 .then((result) => {
225228 if (!watchesSuccess && mutation.describeResult) {
226229 const message = mutation.describeResult(args, result);
......@@ -240,12 +243,63 @@ class Observer<Args extends unknown[], Result> {
240243 return promise;
241244 }
242245
246 runWithOptions(options: RunOptions<Result>, ...args: Args): void {
247 const mutation = this.mutation;
248 if (!mutation) return;
249
250 this.currentArgs = args;
251 const key = mutation.key(args);
252
253 // Set up subscription if key changed
254 if (key !== this.currentKey) {
255 this.currentKey = key;
256 this.unsubscribe?.();
257 this.unsubscribe = mutation.subscribe(
258 mutation.key(args),
259 ({ status, error, result }) => {
260 if (status === "idle") {
261 this.setState({
262 isMutating: false,
263 isPending: false,
264 isOptimisticData: false,
265 });
266 return;
267 }
268 const hasError = error != null;
269 const hasResult = result != null;
270
271 this.setState({
272 status: hasError
273 ? "error"
274 : hasResult
275 ? "success"
276 : status === "mutating"
277 ? "mutating"
278 : "idle",
279 error: error ?? undefined,
280 errorMessage: this.computeErrorMessage(error ?? undefined),
281 result: result ?? undefined,
282 isMutating: status === "mutating",
283 isPending: status === "mutating" || status === "refetching",
284 isSuccess: hasResult && !hasError,
285 isError: hasError,
286 isOptimisticData: status === "waiting" || status === "mutating" ||
287 status === "refetching",
288 });
289 },
290 );
291 }
292
293 // Delegate to the mutation's runWithOptions
294 mutation.runWithOptions(...args, options);
295 }
296
243297 binding: UseMutateResult<Args, Result> = ((self: this) => ({
244298 run(...args) {
245299 return self.run(...args);
246300 },
247 runWithResult(...args) {
248 return self.run(...args);
301 runWithOptions(options, ...args) {
302 return self.runWithOptions(options, ...args);
249303 },
250304 clear() {
251305 self.setState({
......@@ -394,9 +448,6 @@ function GenericMutationButton<
394448 const localHook = useMutate("subscribe" in mutation ? mutation : null);
395449 const state = "subscribe" in mutation ? localHook : mutation;
396450
397 if (onError) void state.isError; // subscribe to the events
398 if (onSuccess) void state.isSuccess; // subscribe to the events
399
400451 // NOTE: the JSR has trouble with JSX syntax for some reason.
401452 return jsx(
402453 Component,
......@@ -407,15 +458,10 @@ function GenericMutationButton<
407458 if (e.defaultPrevented) return;
408459 const computedArgs = typeof args === "function" ? args(e) : args;
409460 if (!computedArgs || e.defaultPrevented) return;
410 state.runWithResult(...computedArgs)
411 .then((result) => {
412 onSuccess?.(result);
413 onSettled?.({ status: "success", result });
414 })
415 .catch((error) => {
416 onError?.(error);
417 onSettled?.({ status: "error", error });
418 });
461 state.runWithOptions(
462 { onSuccess, onError, onSettled },
463 ...computedArgs,
464 );
419465 }, [state]),
420466 isPending: state.isPending,
421467 } satisfies Parameters<typeof Component>[0],
src/tanstack-query.ts+34-1
......@@ -322,6 +322,39 @@ class TanstackQueryOptimisticHelpers {
322322 objArrayRemove<
323323 Data extends object,
324324 const Path extends AllObjectPaths<Data>,
325 >(
326 queryKey: QueryKeyAndFn<Data>,
327 path: Path,
328 removeFilter: (
329 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
330 index: number,
331 ) => boolean,
332 ) {
333 const prev = this.#get(queryKey);
334 if (!prev) return;
335 const { value: original, exists } = getPath(prev, path);
336 if (!exists || !Array.isArray(original)) return;
337
338 const newArray = original.filter((item, index) =>
339 !removeFilter(item, index)
340 );
341 this.#set(
342 queryKey,
343 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
344 );
345 this.#onRestore(() => {
346 // TODO: splice items back in case original changed
347 this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
348 });
349 }
350
351 /**
352 * Filter items to just include items that match `filter`. This is the inverse of `objArrayRemove`
353 * If the query or path doesn't exist, the updater is skipped.
354 */
355 objArrayFilter<
356 Data extends object,
357 const Path extends AllObjectPaths<Data>,
325358 >(
326359 queryKey: QueryKeyAndFn<Data>,
327360 path: Path,
......@@ -335,7 +368,7 @@ class TanstackQueryOptimisticHelpers {
335368 const { value: original, exists } = getPath(prev, path);
336369 if (!exists || !Array.isArray(original)) return;
337370
338 const newArray = original.filter((item, index) => !filter(item, index));
371 const newArray = original.filter(filter);
339372 this.#set(
340373 queryKey,
341374 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
src/types.ts+19-2
......@@ -3,8 +3,10 @@ import type { MutationClient } from "./client.ts";
33export interface Mutation<Args extends unknown[], Result> {
44 /** Calling the mutation. Errors are turned into UI toasts. */
55 run(...args: Args): void;
6 /** Calls the mutation, treating the errors as promise rejection. */
7 runAndReturn(...args: Args): Promise<Result>;
6 /** Calls the mutation with custom handlers that can suppress global handlers. */
7 runWithOptions(...args: [...args: Args, options: RunOptions<Result>]): void;
8 /** Calling the mutation. Errors are thrown in the promise. */
9 runAsPromise(...args: Args): Promise<Result>;
810
911 /** Returns the concurrency key used for a given set of arguments */
1012 key(args: Args): string;
......@@ -18,6 +20,21 @@ export interface Mutation<Args extends unknown[], Result> {
1820 client: MutationClient<object, object>;
1921}
2022
23export interface RunOptions<Result> {
24 /** Called on success, suppresses the global success handler */
25 onSuccess?: (result: Result) => void;
26 /** Called on success, does NOT suppress the global success handler */
27 onSuccessDataOnly?: (result: Result) => void;
28 /** Called on error, suppresses the global error handler */
29 onError?: (error: unknown) => void;
30 /** Called on settled (doesn't suppress global handlers) */
31 onSettled?: (
32 status:
33 | { status: "success"; result: Result }
34 | { status: "error"; error: unknown },
35 ) => void;
36}
37
2138export interface MutationEvent<Result> {
2239 status: "idle" | "waiting" | "mutating" | "refetching";
2340 result: Result | null;
test/blocking.test.ts+127-160
......@@ -57,16 +57,15 @@ test("BlockingMutation - basic mutation success", async () => {
5757 },
5858 describe: "test mutation",
5959 describeResult: "Success",
60 optimistic() {
61 // Empty optimistic update
62 },
63 async refetch() {
64 refetchCallCount++;
65 await delay(5);
60 optimistic({ onRefetch }) {
61 onRefetch(async () => {
62 refetchCallCount++;
63 await delay(5);
64 });
6665 },
6766 });
6867
69 const result = await mutation.runAndReturn("test");
68 const result = await mutation.runAsPromise("test");
7069 // Wait for refetch to complete
7170 await delay(20);
7271
......@@ -85,7 +84,6 @@ test("BlockingMutation - run() catches errors", async () => {
8584 describe: "failing mutation",
8685 describeResult: "Success",
8786 optimistic() {},
88 async refetch() {},
8987 });
9088
9189 mutation.run("test");
......@@ -105,11 +103,10 @@ test("BlockingMutation - runAndReturn() rejects on error", async () => {
105103 describe: "failing mutation",
106104 describeResult: "Success",
107105 optimistic() {},
108 async refetch() {},
109106 });
110107
111108 await assertRejects(
112 () => mutation.runAndReturn("test"),
109 () => mutation.runAsPromise("test"),
113110 Error,
114111 "mutation failed",
115112 );
......@@ -130,10 +127,9 @@ test("BlockingMutation - optimistic updates are applied immediately", async () =
130127 const [key, value] = args;
131128 helpers.setValue(key, value);
132129 },
133 async refetch() {},
134130 });
135131
136 const promise = mutation.runAndReturn("key1", "value1");
132 const promise = mutation.runAsPromise("key1", "value1");
137133
138134 // Optimistic update should be applied synchronously
139135 assertEquals(testStore.get("key1"), "value1");
......@@ -158,10 +154,9 @@ test("BlockingMutation - rollback on error", async () => {
158154 const [key, value] = args;
159155 helpers.setValue(key, value);
160156 },
161 async refetch() {},
162157 });
163158
164 await assertRejects(() => mutation.runAndReturn("key1", "value1"));
159 await assertRejects(() => mutation.runAsPromise("key1", "value1"));
165160
166161 // Optimistic update should be rolled back
167162 assertEquals(testStore.has("key1"), false);
......@@ -182,10 +177,9 @@ test("BlockingMutation - onSuccess callback is called", async () => {
182177 successResults.push(result);
183178 });
184179 },
185 async refetch() {},
186180 });
187181
188 await mutation.runAndReturn("test");
182 await mutation.runAsPromise("test");
189183
190184 assertEquals(successResults, ["result-test"]);
191185});
......@@ -204,7 +198,7 @@ test("BlockingMutation - mutations with same key execute serially", async () =>
204198 describe: "test mutation",
205199 describeResult: "Success",
206200 optimistic() {},
207 async refetch() {},
201
208202 refetchOnSuccess: false,
209203 key() {
210204 return "same-key";
......@@ -212,8 +206,8 @@ test("BlockingMutation - mutations with same key execute serially", async () =>
212206 });
213207
214208 // Start two mutations with the same key
215 const promise1 = mutation.runAndReturn("1");
216 const promise2 = mutation.runAndReturn("2");
209 const promise1 = mutation.runAsPromise("1");
210 const promise2 = mutation.runAsPromise("2");
217211
218212 await Promise.all([promise1, promise2]);
219213 await delay(10);
......@@ -236,7 +230,7 @@ test("BlockingMutation - mutations with different keys execute in parallel", asy
236230 describe: "test mutation",
237231 describeResult: "Success",
238232 optimistic() {},
239 async refetch() {},
233
240234 key({ args }) {
241235 const [id] = args;
242236 return id;
......@@ -244,8 +238,8 @@ test("BlockingMutation - mutations with different keys execute in parallel", asy
244238 });
245239
246240 // Start two mutations with different keys
247 const promise1 = mutation.runAndReturn("key1");
248 const promise2 = mutation.runAndReturn("key2");
241 const promise1 = mutation.runAsPromise("key1");
242 const promise2 = mutation.runAsPromise("key2");
249243
250244 await Promise.all([promise1, promise2]);
251245
......@@ -263,7 +257,7 @@ test("BlockingMutation - key() returns JSON stringified key", () => {
263257 describe: "test mutation",
264258 describeResult: "Success",
265259 optimistic() {},
266 async refetch() {},
260
267261 key({ args }) {
268262 const [id] = args;
269263 return id;
......@@ -283,7 +277,6 @@ test("BlockingMutation - key() defaults to 'shared' when no key function", () =>
283277 describe: "test mutation",
284278 describeResult: "Success",
285279 optimistic() {},
286 async refetch() {},
287280 });
288281
289282 assertEquals(mutation.key(["test-id"]), JSON.stringify("shared"));
......@@ -299,7 +292,7 @@ test("BlockingMutation - key() can return array", () => {
299292 describe: "test mutation",
300293 describeResult: "Success",
301294 optimistic() {},
302 async refetch() {},
295
303296 key({ args }) {
304297 const [userId, itemId] = args;
305298 return [userId, itemId];
......@@ -322,7 +315,6 @@ test("BlockingMutation - describe() with string", () => {
322315 describe: "create item",
323316 describeResult: "Success",
324317 optimistic() {},
325 async refetch() {},
326318 });
327319
328320 assertEquals(mutation.describe("test"), "create item");
......@@ -339,8 +331,8 @@ test("BlockingMutation - describe() with function", () => {
339331 const [id] = args;
340332 return `delete item ${id}`;
341333 },
334 describeResult: null,
342335 optimistic() {},
343 async refetch() {},
344336 });
345337
346338 assertEquals(mutation.describe("123"), "delete item 123");
......@@ -358,7 +350,7 @@ test("BlockingMutation - describe() receives context", () => {
358350 return `user ${userId} editing item ${id}`;
359351 },
360352 optimistic() {},
361 async refetch() {},
353 describeResult: null,
362354 });
363355
364356 assertEquals(
......@@ -378,16 +370,17 @@ test("BlockingMutation - subscribe() tracks mutation events", async () => {
378370 },
379371 describe: "test mutation",
380372 describeResult: "Success",
381 optimistic() {},
382 async refetch() {
383 await delay(5);
373 optimistic({ onRefetch }) {
374 onRefetch(async () => {
375 await delay(5);
376 });
384377 },
385378 });
386379
387380 const key = mutation.key(["test"]);
388381 mutation.subscribe(key, tracker.callback);
389382
390 await mutation.runAndReturn("test");
383 await mutation.runAsPromise("test");
391384 // Wait for refetch to complete
392385 await delay(20);
393386
......@@ -409,7 +402,7 @@ test("BlockingMutation - unsubscribe stops receiving events", async () => {
409402 describe: "test mutation",
410403 describeResult: "Success",
411404 optimistic() {},
412 async refetch() {},
405
413406 refetchOnSuccess: false,
414407 });
415408
......@@ -418,7 +411,7 @@ test("BlockingMutation - unsubscribe stops receiving events", async () => {
418411
419412 unsubscribe();
420413
421 await mutation.runAndReturn("test");
414 await mutation.runAsPromise("test");
422415 await delay(10);
423416
424417 // Should not have received any events
......@@ -435,14 +428,15 @@ test("BlockingMutation - refetchOnSuccess can be disabled", async () => {
435428 },
436429 describe: "test mutation",
437430 describeResult: "Success",
438 optimistic() {},
439 async refetch() {
440 refetchCallCount++;
431 optimistic({ onRefetch }) {
432 onRefetch(async () => {
433 refetchCallCount++;
434 });
441435 },
442436 refetchOnSuccess: false,
443437 });
444438
445 await mutation.runAndReturn("test");
439 await mutation.runAsPromise("test");
446440
447441 assertEquals(refetchCallCount, 0);
448442});
......@@ -457,13 +451,14 @@ test("BlockingMutation - refetch is called on error", async () => {
457451 },
458452 describe: "failing mutation",
459453 describeResult: "Success",
460 optimistic() {},
461 async refetch() {
462 refetchCallCount++;
454 optimistic({ onRefetch }) {
455 onRefetch(async () => {
456 refetchCallCount++;
457 });
463458 },
464459 });
465460
466 await assertRejects(() => mutation.runAndReturn("test"));
461 await assertRejects(() => mutation.runAsPromise("test"));
467462
468463 assertEquals(refetchCallCount, 1);
469464});
......@@ -485,15 +480,15 @@ test("BlockingMutation - queued mutations are cancelled on error", async () => {
485480 describe: "test mutation",
486481 describeResult: "Success",
487482 optimistic() {},
488 async refetch() {},
483
489484 key() {
490485 return "same-key";
491486 },
492487 });
493488
494 const promise1 = mutation.runAndReturn("1");
495 const promise2 = mutation.runAndReturn("2");
496 const promise3 = mutation.runAndReturn("3");
489 const promise1 = mutation.runAsPromise("1");
490 const promise2 = mutation.runAsPromise("2");
491 const promise3 = mutation.runAsPromise("3");
497492
498493 await assertRejects(() => promise1, Error, "first mutation failed");
499494 await assertRejects(() => promise2, Error, "first mutation failed");
......@@ -518,10 +513,9 @@ test("BlockingMutation - rollbacks are called in reverse order on error", async
518513 onRestore(() => rollbackOrder.push(2));
519514 onRestore(() => rollbackOrder.push(3));
520515 },
521 async refetch() {},
522516 });
523517
524 await assertRejects(() => mutation.runAndReturn("test"));
518 await assertRejects(() => mutation.runAsPromise("test"));
525519
526520 // Rollbacks should be called in reverse order
527521 assertEquals(rollbackOrder, [3, 2, 1]);
......@@ -544,17 +538,17 @@ test("BlockingMutation - multiple mutations: rollbacks only affect failed mutati
544538 optimistic({ args: [id], onRestore }) {
545539 onRestore(() => rollbackOrder.push(`rollback-${id}`));
546540 },
547 async refetch() {},
541
548542 key() {
549543 return "same-key";
550544 },
551545 });
552546
553547 // First mutation succeeds
554 await mutation.runAndReturn("success");
548 await mutation.runAsPromise("success");
555549
556550 // Second mutation fails
557 await assertRejects(() => mutation.runAndReturn("fail"));
551 await assertRejects(() => mutation.runAsPromise("fail"));
558552
559553 // Only the failed mutation's rollback should be called
560554 // And all rollbacks from queued items
......@@ -574,10 +568,9 @@ test("BlockingMutation - onRestore throws error if called after optimistic phase
574568 optimistic({ onRestore }) {
575569 capturedOnRestore = onRestore;
576570 },
577 async refetch() {},
578571 });
579572
580 await mutation.runAndReturn("test");
573 await mutation.runAsPromise("test");
581574
582575 // Calling onRestore after the optimistic phase should throw
583576 let error: Error | null = null;
......@@ -606,10 +599,9 @@ test("BlockingMutation - onSuccess throws error if called after optimistic phase
606599 optimistic({ onSuccess }) {
607600 capturedOnSuccess = onSuccess;
608601 },
609 async refetch() {},
610602 });
611603
612 await mutation.runAndReturn("test");
604 await mutation.runAsPromise("test");
613605
614606 // Calling onSuccess after the optimistic phase should throw
615607 let error: Error | null = null;
......@@ -637,11 +629,10 @@ test("BlockingMutation - error during optimistic update is rejected immediately"
637629 optimistic() {
638630 throw new Error("optimistic update failed");
639631 },
640 async refetch() {},
641632 });
642633
643634 await assertRejects(
644 () => mutation.runAndReturn("test"),
635 () => mutation.runAsPromise("test"),
645636 Error,
646637 "optimistic update failed",
647638 );
......@@ -662,10 +653,9 @@ test("BlockingMutation - error during optimistic update rolls back registered ca
662653 onRestore(() => rollbackOrder.push(2));
663654 throw new Error("optimistic update failed");
664655 },
665 async refetch() {},
666656 });
667657
668 await assertRejects(() => mutation.runAndReturn("test"));
658 await assertRejects(() => mutation.runAsPromise("test"));
669659
670660 // Rollbacks should be called even though optimistic update failed
671661 // Note: during optimistic error, rollbacks are executed in the order they were added
......@@ -681,14 +671,15 @@ test("BlockingMutation - refetch errors are reported but don't fail mutation", a
681671 },
682672 describe: "test mutation",
683673 describeResult: "Success",
684 optimistic() {},
685 async refetch() {
686 throw new Error("refetch failed");
674 optimistic({ onRefetch }) {
675 onRefetch(async () => {
676 throw new Error("refetch failed");
677 });
687678 },
688679 });
689680
690681 // Mutation should still succeed
691 const result = await mutation.runAndReturn("test");
682 const result = await mutation.runAsPromise("test");
692683 assertEquals(result, "test");
693684
694685 // But refetch error should be reported
......@@ -712,39 +703,14 @@ test("BlockingMutation - optimistic function receives args and helpers", async (
712703 receivedArgs = args;
713704 receivedHelpers = helpers;
714705 },
715 async refetch() {},
716706 });
717707
718 await mutation.runAndReturn("test");
708 await mutation.runAsPromise("test");
719709
720710 assertEquals(receivedArgs, ["test"]);
721711 assertEquals(typeof receivedHelpers, "object");
722712});
723713
724test("BlockingMutation - refetch receives context and args", async () => {
725 const { client } = createTestClient();
726 let receivedUserId: string | undefined;
727 let receivedArgs: unknown[] | undefined;
728
729 const mutation = client.define({
730 async mutate(_id: string, value: string) {
731 return value;
732 },
733 describe: "test mutation",
734 describeResult: "Success",
735 optimistic() {},
736 async refetch({ userId, args }) {
737 receivedUserId = userId;
738 receivedArgs = args;
739 },
740 });
741
742 await mutation.runAndReturn("test-id", "test-value");
743
744 assertEquals(receivedUserId, "test-user");
745 assertEquals(receivedArgs, ["test-id", "test-value"]);
746});
747
748714test("BlockingMutation - notifies error on mutation failure", async () => {
749715 const { client } = createTestClient();
750716 const tracker = createEventTracker<string>();
......@@ -757,13 +723,12 @@ test("BlockingMutation - notifies error on mutation failure", async () => {
757723 describe: "failing mutation",
758724 describeResult: "Success",
759725 optimistic() {},
760 async refetch() {},
761726 });
762727
763728 const key = mutation.key(["test"]);
764729 mutation.subscribe(key, tracker.callback);
765730
766 await assertRejects(() => mutation.runAndReturn("test"));
731 await assertRejects(() => mutation.runAsPromise("test"));
767732
768733 // Should have error event
769734 const errorEvents = tracker.events.filter((e) =>
......@@ -786,7 +751,7 @@ test("BlockingMutation - multiple subscribers receive events", async () => {
786751 describe: "test mutation",
787752 describeResult: "Success",
788753 optimistic() {},
789 async refetch() {},
754
790755 refetchOnSuccess: false,
791756 });
792757
......@@ -794,7 +759,7 @@ test("BlockingMutation - multiple subscribers receive events", async () => {
794759 mutation.subscribe(key, tracker1.callback);
795760 mutation.subscribe(key, tracker2.callback);
796761
797 await mutation.runAndReturn("test");
762 await mutation.runAsPromise("test");
798763 await delay(10);
799764
800765 // Both subscribers should receive events
......@@ -817,11 +782,11 @@ test("BlockingMutation - onSuccess is called before mutation resolves", async ()
817782 callOrder.push("onSuccess");
818783 });
819784 },
820 async refetch() {},
785
821786 refetchOnSuccess: false,
822787 });
823788
824 const promise = mutation.runAndReturn("test");
789 const promise = mutation.runAsPromise("test");
825790 promise.then(() => {
826791 callOrder.push("then");
827792 });
......@@ -845,15 +810,12 @@ test("BlockingMutation - result is passed to notification on success", async ()
845810 describe: "test mutation",
846811 describeResult: "Success",
847812 optimistic() {},
848 async refetch() {
849 await delay(5);
850 },
851813 });
852814
853815 const key = mutation.key(["test"]);
854816 mutation.subscribe(key, tracker.callback);
855817
856 await mutation.runAndReturn("test");
818 await mutation.runAsPromise("test");
857819 await delay(20);
858820
859821 // Should have refetching event with result
......@@ -876,16 +838,16 @@ test("BlockingMutation - channel is reused for same key", async () => {
876838 describe: "test mutation",
877839 describeResult: "Success",
878840 optimistic() {},
879 async refetch() {},
841
880842 refetchOnSuccess: false,
881843 });
882844
883845 // First mutation
884 await mutation.runAndReturn("first");
846 await mutation.runAsPromise("first");
885847 await delay(5);
886848
887849 // Second mutation with same key
888 await mutation.runAndReturn("second");
850 await mutation.runAsPromise("second");
889851 await delay(5);
890852
891853 assertEquals(events, ["mutate-first", "mutate-second"]);
......@@ -902,7 +864,7 @@ test("BlockingMutation - empty queue after all mutations complete", async () =>
902864 describe: "test mutation",
903865 describeResult: "Success",
904866 optimistic() {},
905 async refetch() {},
867
906868 refetchOnSuccess: false,
907869 key() {
908870 return "test-key";
......@@ -910,15 +872,15 @@ test("BlockingMutation - empty queue after all mutations complete", async () =>
910872 });
911873
912874 // Run multiple mutations
913 await mutation.runAndReturn("1");
914 await mutation.runAndReturn("2");
915 await mutation.runAndReturn("3");
875 await mutation.runAsPromise("1");
876 await mutation.runAsPromise("2");
877 await mutation.runAsPromise("3");
916878 await delay(10);
917879
918880 // All mutations should have completed
919881 // (We can't directly check the queue, but we can verify by running another mutation)
920882 const start = Date.now();
921 await mutation.runAndReturn("4");
883 await mutation.runAsPromise("4");
922884 const duration = Date.now() - start;
923885
924886 // Should execute immediately, not be queued (< 10ms if not queued)
......@@ -940,11 +902,11 @@ test("BlockingMutation - multiple onSuccess callbacks are all called", async ()
940902 onSuccess((result) => results.push(`second-${result}`));
941903 onSuccess((result) => results.push(`third-${result}`));
942904 },
943 async refetch() {},
905
944906 refetchOnSuccess: false,
945907 });
946908
947 await mutation.runAndReturn("test");
909 await mutation.runAsPromise("test");
948910
949911 assertEquals(results, ["first-test", "second-test", "third-test"]);
950912});
......@@ -959,14 +921,15 @@ test("BlockingMutation - refetchOnSuccess false skips refetch", async () => {
959921 },
960922 describe: "test mutation",
961923 describeResult: "Success",
962 optimistic() {},
963 async refetch() {
964 refetchCalled = true;
924 optimistic({ onRefetch }) {
925 onRefetch(async () => {
926 refetchCalled = true;
927 });
965928 },
966929 refetchOnSuccess: false,
967930 });
968931
969 await mutation.runAndReturn("test");
932 await mutation.runAsPromise("test");
970933 await delay(10);
971934
972935 // Refetch should not have been called
......@@ -982,14 +945,15 @@ test("BlockingMutation - refetch error after mutation failure is reported", asyn
982945 },
983946 describe: "failing mutation",
984947 describeResult: "Success",
985 optimistic() {},
986 async refetch() {
987 throw new Error("refetch also failed");
948 optimistic({ onRefetch }) {
949 onRefetch(async () => {
950 throw new Error("refetch also failed");
951 });
988952 },
989953 });
990954
991955 await assertRejects(
992 () => mutation.runAndReturn("test"),
956 () => mutation.runAsPromise("test"),
993957 Error,
994958 "mutation failed",
995959 );
......@@ -1026,11 +990,11 @@ test("BlockingMutation - debounce: basic debounced execution", async () => {
1026990 const [key, value] = args;
1027991 helpers.setValue(key, value);
1028992 },
1029 async refetch() {},
993
1030994 debounceMs: 50,
1031995 });
1032996
1033 const promise = mutation.runAndReturn("key1", "value1");
997 const promise = mutation.runAsPromise("key1", "value1");
1034998
1035999 // Optimistic update should be applied immediately
10361000 assertEquals(testStore.get("key1"), "value1");
......@@ -1063,14 +1027,14 @@ test("BlockingMutation - debounce: last call wins with multiple rapid calls", as
10631027 const [key, value] = args;
10641028 helpers.setValue(key, value);
10651029 },
1066 async refetch() {},
1030
10671031 debounceMs: 50,
10681032 });
10691033
10701034 // Make three rapid calls
1071 const promise1 = mutation.runAndReturn("key1", "a");
1072 const promise2 = mutation.runAndReturn("key1", "b");
1073 const promise3 = mutation.runAndReturn("key1", "c");
1035 const promise1 = mutation.runAsPromise("key1", "a");
1036 const promise2 = mutation.runAsPromise("key1", "b");
1037 const promise3 = mutation.runAsPromise("key1", "c");
10741038
10751039 // Last optimistic update should be applied
10761040 assertEquals(testStore.get("key1"), "c");
......@@ -1109,17 +1073,17 @@ test("BlockingMutation - debounce: optimistic rollback and reapply", async () =>
11091073 // Add a second value to test multiple rollbacks
11101074 helpers.setValue(`${key}-2`, `${value}-2`);
11111075 },
1112 async refetch() {},
1076
11131077 debounceMs: 50,
11141078 });
11151079
11161080 // First call sets two values
1117 mutation.runAndReturn("key1", "a");
1081 mutation.runAsPromise("key1", "a");
11181082 assertEquals(testStore.get("key1"), "a");
11191083 assertEquals(testStore.get("key1-2"), "a-2");
11201084
11211085 // Second call should rollback first call's optimistic and apply its own
1122 const promise = mutation.runAndReturn("key1", "b");
1086 const promise = mutation.runAsPromise("key1", "b");
11231087 assertEquals(testStore.get("key1"), "b");
11241088 assertEquals(testStore.get("key1-2"), "b-2");
11251089
......@@ -1144,16 +1108,16 @@ test("BlockingMutation - debounce: timer reset behavior", async () => {
11441108 describe: "debounced mutation",
11451109 describeResult: "Success",
11461110 optimistic() {},
1147 async refetch() {},
1111
11481112 debounceMs: 100,
11491113 });
11501114
11511115 // Call at t=0
1152 const promise1 = mutation.runAndReturn("first");
1116 const promise1 = mutation.runAsPromise("first");
11531117
11541118 // Call at t=50 (should reset timer)
11551119 await delay(50);
1156 const promise2 = mutation.runAndReturn("second");
1120 const promise2 = mutation.runAsPromise("second");
11571121
11581122 // At t=100, mutation should NOT have executed yet
11591123 await delay(50);
......@@ -1180,18 +1144,18 @@ test("BlockingMutation - debounce: integration with blocking queue", async () =>
11801144 describe: "debounced mutation",
11811145 describeResult: "Success",
11821146 optimistic() {},
1183 async refetch() {},
1147
11841148 debounceMs: 30,
11851149 key: () => "shared",
11861150 });
11871151
11881152 // Start a debounced call that will enter queue first
1189 const promise1 = mutation.runAndReturn("first");
1153 const promise1 = mutation.runAsPromise("first");
11901154
11911155 // While it's waiting in debounce, fire more debounced calls
11921156 await delay(10);
1193 const promise2 = mutation.runAndReturn("second");
1194 const promise3 = mutation.runAndReturn("third");
1157 const promise2 = mutation.runAsPromise("second");
1158 const promise3 = mutation.runAsPromise("third");
11951159
11961160 // Wait for all to complete
11971161 await Promise.all([promise1, promise2, promise3]);
......@@ -1220,13 +1184,13 @@ test("BlockingMutation - debounce: error during optimistic update", async () =>
12201184 }
12211185 helpers.setValue("key", value);
12221186 },
1223 async refetch() {},
1187
12241188 debounceMs: 50,
12251189 });
12261190
12271191 // Call that throws during optimistic
12281192 await assertRejects(
1229 () => mutation.runAndReturn("error"),
1193 () => mutation.runAsPromise("error"),
12301194 Error,
12311195 "optimistic error",
12321196 );
......@@ -1235,7 +1199,7 @@ test("BlockingMutation - debounce: error during optimistic update", async () =>
12351199 assertEquals(testStore.has("key"), false);
12361200
12371201 // Subsequent successful call should work
1238 const promise = mutation.runAndReturn("good");
1202 const promise = mutation.runAsPromise("good");
12391203 assertEquals(testStore.get("key"), "good");
12401204 await promise;
12411205});
......@@ -1251,10 +1215,12 @@ test("BlockingMutation - debounce: status transitions", async () => {
12511215 },
12521216 describe: "debounced mutation",
12531217 describeResult: "Success",
1254 optimistic() {},
1255 async refetch() {
1256 await delay(10);
1218 optimistic({ onRefetch }) {
1219 onRefetch(async () => {
1220 await delay(10);
1221 });
12571222 },
1223
12581224 debounceMs: 50,
12591225 });
12601226
......@@ -1262,7 +1228,7 @@ test("BlockingMutation - debounce: status transitions", async () => {
12621228 const unsubscribe = mutation.subscribe(key, callback);
12631229
12641230 // First call should transition to waiting
1265 mutation.runAndReturn("test");
1231 mutation.runAsPromise("test");
12661232 await delay(10);
12671233 assertEquals(events[events.length - 1].status, "waiting");
12681234
......@@ -1291,19 +1257,20 @@ test("BlockingMutation - debounce: debounced call executes after queue error", a
12911257 },
12921258 describe: "debounced mutation",
12931259 describeResult: "Success",
1294 optimistic() {},
1295 async refetch() {
1296 await delay(10);
1260 optimistic({ onRefetch }) {
1261 onRefetch(async () => {
1262 await delay(10);
1263 });
12971264 },
12981265 debounceMs: 50,
12991266 key: () => "shared",
13001267 });
13011268
13021269 // Start a call that will fail (enters debounce)
1303 const promise1 = mutation.runAndReturn("fail");
1270 const promise1 = mutation.runAsPromise("fail");
13041271
13051272 // Immediately override with a successful call (last call wins)
1306 const promise2 = mutation.runAndReturn("success");
1273 const promise2 = mutation.runAsPromise("success");
13071274
13081275 // Both promises should resolve with the same successful result
13091276 // (because debouncing causes "last call wins")
......@@ -1327,20 +1294,20 @@ test("BlockingMutation - debounce: all promises resolve together", async () => {
13271294 describe: "debounced mutation",
13281295 describeResult: "Success",
13291296 optimistic() {},
1330 async refetch() {},
1297
13311298 debounceMs: 50,
13321299 });
13331300
13341301 // Create three rapid calls
1335 const promise1 = mutation.runAndReturn("id", "a").then((result) => {
1302 const promise1 = mutation.runAsPromise("id", "a").then((result) => {
13361303 resolvedAt.push(Date.now());
13371304 return result;
13381305 });
1339 const promise2 = mutation.runAndReturn("id", "b").then((result) => {
1306 const promise2 = mutation.runAsPromise("id", "b").then((result) => {
13401307 resolvedAt.push(Date.now());
13411308 return result;
13421309 });
1343 const promise3 = mutation.runAndReturn("id", "c").then((result) => {
1310 const promise3 = mutation.runAsPromise("id", "c").then((result) => {
13441311 resolvedAt.push(Date.now());
13451312 return result;
13461313 });
......@@ -1367,7 +1334,7 @@ test("BlockingMutation - debounce: cleanup on channel deletion", async () => {
13671334 describe: "debounced mutation",
13681335 describeResult: "Success",
13691336 optimistic() {},
1370 async refetch() {},
1337
13711338 debounceMs: 100,
13721339 });
13731340
......@@ -1377,7 +1344,7 @@ test("BlockingMutation - debounce: cleanup on channel deletion", async () => {
13771344 const unsubscribe = mutation.subscribe(key, () => {});
13781345
13791346 // Start a debounced call
1380 mutation.runAndReturn("test");
1347 mutation.runAsPromise("test");
13811348 await delay(10);
13821349
13831350 // Unsubscribe while debounce is pending
......@@ -1403,16 +1370,16 @@ test("BlockingMutation - debounce: multiple keys debounce independently", async
14031370 describe: "debounced mutation",
14041371 describeResult: "Success",
14051372 optimistic() {},
1406 async refetch() {},
1373
14071374 debounceMs: 50,
14081375 key: ({ args }) => args[0],
14091376 });
14101377
14111378 // Rapid calls to different keys
1412 const promise1a = mutation.runAndReturn("key1");
1413 const promise1b = mutation.runAndReturn("key1");
1414 const promise2a = mutation.runAndReturn("key2");
1415 const promise2b = mutation.runAndReturn("key2");
1379 const promise1a = mutation.runAsPromise("key1");
1380 const promise1b = mutation.runAsPromise("key1");
1381 const promise2a = mutation.runAsPromise("key2");
1382 const promise2b = mutation.runAsPromise("key2");
14161383
14171384 await Promise.all([promise1a, promise1b, promise2a, promise2b]);
14181385
......@@ -1437,15 +1404,15 @@ test("BlockingMutation - debounce: onSuccess callbacks from last call only", asy
14371404 successResults.push(`${value}->${result}`);
14381405 });
14391406 },
1440 async refetch() {},
1407
14411408 debounceMs: 50,
14421409 });
14431410
14441411 // Make three rapid calls with different onSuccess callbacks
14451412 await Promise.all([
1446 mutation.runAndReturn("a"),
1447 mutation.runAndReturn("b"),
1448 mutation.runAndReturn("c"),
1413 mutation.runAsPromise("a"),
1414 mutation.runAsPromise("b"),
1415 mutation.runAsPromise("c"),
14491416 ]);
14501417
14511418 await delay(20);
test/debounced.test.ts+39-64
......@@ -89,7 +89,7 @@ test("DebouncedMutation - basic mutation success with debounce", async () => {
8989 },
9090 });
9191
92 const result = await mutation.runAndReturn(5);
92 const result = await mutation.runAsPromise(5);
9393 await delay(20); // Wait for refetch
9494
9595 assertEquals(result, 5);
......@@ -116,7 +116,6 @@ test("DebouncedMutation - run() catches errors", async () => {
116116 },
117117 describe: "failing mutation",
118118 describeResult: "Success",
119 async refetch() {},
120119 });
121120
122121 mutation.run(5);
......@@ -144,11 +143,10 @@ test("DebouncedMutation - runAndReturn() rejects on error", async () => {
144143 },
145144 describe: "failing mutation",
146145 describeResult: "Success",
147 async refetch() {},
148146 });
149147
150148 await assertRejects(
151 () => mutation.runAndReturn(5),
149 () => mutation.runAsPromise(5),
152150 Error,
153151 "commit failed",
154152 );
......@@ -181,13 +179,12 @@ test("DebouncedMutation - debounce batches rapid calls", async () => {
181179 },
182180 describe: "increment counter",
183181 describeResult: "Success",
184 async refetch() {},
185182 });
186183
187184 // Rapid calls within debounce window
188 const promise1 = mutation.runAndReturn(1);
189 const promise2 = mutation.runAndReturn(2);
190 const promise3 = mutation.runAndReturn(3);
185 const promise1 = mutation.runAsPromise(1);
186 const promise2 = mutation.runAsPromise(2);
187 const promise3 = mutation.runAsPromise(3);
191188
192189 // Optimistic updates should be applied immediately
193190 assertEquals(testStore.get("counter"), 6);
......@@ -223,17 +220,16 @@ test("DebouncedMutation - debounce resets timer on each call", async () => {
223220 },
224221 describe: "increment counter",
225222 describeResult: "Success",
226 async refetch() {},
227223 });
228224
229225 // First call
230 const promise1 = mutation.runAndReturn(1);
226 const promise1 = mutation.runAsPromise(1);
231227
232228 // Wait less than debounce time
233229 await delay(15);
234230
235231 // Second call should reset the timer
236 const promise2 = mutation.runAndReturn(2);
232 const promise2 = mutation.runAsPromise(2);
237233
238234 // Wait less than debounce time again
239235 await delay(15);
......@@ -242,7 +238,7 @@ test("DebouncedMutation - debounce resets timer on each call", async () => {
242238 assertEquals(commitCallCount, 0);
243239
244240 // Third call
245 const promise3 = mutation.runAndReturn(3);
241 const promise3 = mutation.runAsPromise(3);
246242
247243 // Wait for all to complete
248244 await Promise.all([promise1, promise2, promise3]);
......@@ -274,15 +270,14 @@ test("DebouncedMutation - debounce separates batches after timeout", async () =>
274270 },
275271 describe: "increment counter",
276272 describeResult: "Success",
277 async refetch() {},
278273 });
279274
280275 // First batch
281 await mutation.runAndReturn(1);
276 await mutation.runAsPromise(1);
282277 await delay(50); // Wait for first batch to complete
283278
284279 // Second batch (after timeout)
285 await mutation.runAndReturn(2);
280 await mutation.runAsPromise(2);
286281 await delay(50);
287282
288283 // Two separate commits
......@@ -319,10 +314,9 @@ test("DebouncedMutation - throttle commits immediately on first call", async ()
319314 },
320315 describe: "increment counter",
321316 describeResult: "Success",
322 async refetch() {},
323317 });
324318
325 await mutation.runAndReturn(5);
319 await mutation.runAsPromise(5);
326320
327321 // First call should commit immediately (within a small tolerance)
328322 assertEquals(commitTime < 20, true);
......@@ -352,19 +346,18 @@ test("DebouncedMutation - throttle batches calls within time window", async () =
352346 },
353347 describe: "increment counter",
354348 describeResult: "Success",
355 async refetch() {},
356349 });
357350
358351 // First call commits immediately
359 const promise1 = mutation.runAndReturn(1);
352 const promise1 = mutation.runAsPromise(1);
360353 await delay(5);
361354
362355 // Second call within throttle window - should batch
363 const promise2 = mutation.runAndReturn(2);
356 const promise2 = mutation.runAsPromise(2);
364357 await delay(5);
365358
366359 // Third call within throttle window - should batch with second
367 const promise3 = mutation.runAndReturn(3);
360 const promise3 = mutation.runAsPromise(3);
368361
369362 // Wait for first to complete
370363 await promise1;
......@@ -403,11 +396,10 @@ test("DebouncedMutation - throttle allows new batch after time window", async ()
403396 },
404397 describe: "increment counter",
405398 describeResult: "Success",
406 async refetch() {},
407399 });
408400
409401 // First call
410 await mutation.runAndReturn(1);
402 await mutation.runAsPromise(1);
411403 await delay(10);
412404
413405 assertEquals(commitCallCount, 1);
......@@ -416,7 +408,7 @@ test("DebouncedMutation - throttle allows new batch after time window", async ()
416408 await delay(60);
417409
418410 // Second call should commit immediately
419 await mutation.runAndReturn(2);
411 await mutation.runAsPromise(2);
420412 await delay(10);
421413
422414 assertEquals(commitCallCount, 2);
......@@ -447,12 +439,11 @@ test("DebouncedMutation - skips commit when value unchanged", async () => {
447439 },
448440 describe: "increment counter",
449441 describeResult: "Success",
450 async refetch() {},
451442 });
452443
453444 // +5 and -5 cancel out
454 const promise1 = mutation.runAndReturn(5);
455 const promise2 = mutation.runAndReturn(-5);
445 const promise1 = mutation.runAsPromise(5);
446 const promise2 = mutation.runAsPromise(-5);
456447
457448 const [result1, result2] = await Promise.all([promise1, promise2]);
458449
......@@ -507,11 +498,10 @@ test("DebouncedMutation - uses deepEquals for comparison", async () => {
507498 },
508499 describe: "set count",
509500 describeResult: "Success",
510 async refetch() {},
511501 });
512502
513503 // Set to same value (different object reference but same content)
514 await mutation.runAndReturn(0);
504 await mutation.runAsPromise(0);
515505 await delay(30);
516506
517507 // Should skip commit because value is deeply equal
......@@ -559,10 +549,9 @@ test("DebouncedMutation - custom deepEquals function", async () => {
559549 },
560550 describe: "failing mutation",
561551 describeResult: "Success",
562 async refetch() {},
563552 });
564553
565 await mutation.runAndReturn(5).catch(() => {
554 await mutation.runAsPromise(5).catch(() => {
566555 // Expected to fail due to commit error
567556 });
568557 await delay(30);
......@@ -593,11 +582,10 @@ test("DebouncedMutation - rollback on commit error", async () => {
593582 },
594583 describe: "failing mutation",
595584 describeResult: "Success",
596 async refetch() {},
597585 });
598586
599587 // Optimistic update applied
600 const promise = mutation.runAndReturn(5);
588 const promise = mutation.runAsPromise(5);
601589 assertEquals(testStore.get("counter"), 15);
602590
603591 await assertRejects(() => promise, Error, "commit failed");
......@@ -626,13 +614,12 @@ test("DebouncedMutation - error event includes error details", async () => {
626614 },
627615 describe: "failing mutation",
628616 describeResult: "Success",
629 async refetch() {},
630617 });
631618
632619 const key = mutation.key([5]);
633620 mutation.subscribe(key, tracker.callback);
634621
635 await assertRejects(() => mutation.runAndReturn(5));
622 await assertRejects(() => mutation.runAsPromise(5));
636623 await delay(30);
637624
638625 // Should have error in events
......@@ -660,7 +647,6 @@ test("DebouncedMutation - key() returns JSON stringified key", () => {
660647 },
661648 describe: "test mutation",
662649 describeResult: "Success",
663 async refetch() {},
664650 });
665651
666652 assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id"));
......@@ -681,7 +667,6 @@ test("DebouncedMutation - key() can return array", () => {
681667 },
682668 describe: "test mutation",
683669 describeResult: "Success",
684 async refetch() {},
685670 });
686671
687672 assertEquals(
......@@ -712,12 +697,11 @@ test("DebouncedMutation - different keys create separate batches", async () => {
712697 },
713698 describe: "test mutation",
714699 describeResult: "Success",
715 async refetch() {},
716700 });
717701
718702 // Two different keys
719 const promise1 = mutation.runAndReturn("a", 5);
720 const promise2 = mutation.runAndReturn("b", 10);
703 const promise1 = mutation.runAsPromise("a", 5);
704 const promise2 = mutation.runAsPromise("b", 10);
721705
722706 await Promise.all([promise1, promise2]);
723707 await delay(30);
......@@ -747,7 +731,6 @@ test("DebouncedMutation - describe() with string", () => {
747731 },
748732 describe: "update counter",
749733 describeResult: "Success",
750 async refetch() {},
751734 });
752735
753736 assertEquals(mutation.describe(5), "update counter");
......@@ -768,7 +751,6 @@ test("DebouncedMutation - describe() with function", () => {
768751 },
769752 describe: ({ args }) => `increment by ${args[0]}`,
770753 describeResult: "Success",
771 async refetch() {},
772754 });
773755
774756 assertEquals(mutation.describe(5), "increment by 5");
......@@ -796,12 +778,11 @@ test("DebouncedMutation - all pending promises resolve with same result", async
796778 },
797779 describe: "increment counter",
798780 describeResult: "Success",
799 async refetch() {},
800781 });
801782
802 const promise1 = mutation.runAndReturn(1);
803 const promise2 = mutation.runAndReturn(2);
804 const promise3 = mutation.runAndReturn(3);
783 const promise1 = mutation.runAsPromise(1);
784 const promise2 = mutation.runAsPromise(2);
785 const promise3 = mutation.runAsPromise(3);
805786
806787 const [result1, result2, result3] = await Promise.all([
807788 promise1,
......@@ -833,12 +814,11 @@ test("DebouncedMutation - all pending promises reject with same error", async ()
833814 },
834815 describe: "increment counter",
835816 describeResult: "Success",
836 async refetch() {},
837817 });
838818
839 const promise1 = mutation.runAndReturn(1);
840 const promise2 = mutation.runAndReturn(2);
841 const promise3 = mutation.runAndReturn(3);
819 const promise1 = mutation.runAsPromise(1);
820 const promise2 = mutation.runAsPromise(2);
821 const promise3 = mutation.runAsPromise(3);
842822
843823 const errors: Error[] = [];
844824 await Promise.all([
......@@ -880,7 +860,7 @@ test("DebouncedMutation - handles empty getValue result", async () => {
880860 describeResult: "Success",
881861 });
882862
883 const result = await mutation.runAndReturn(5);
863 const result = await mutation.runAsPromise(5);
884864 await delay(30);
885865
886866 assertEquals(commitCallCount, 1);
......@@ -906,15 +886,14 @@ test("DebouncedMutation - channel cleanup after idle with no listeners", async (
906886 },
907887 describe: "test mutation",
908888 describeResult: "Success",
909 async refetch() {},
910889 });
911890
912891 // Run mutation without subscribing
913 await mutation.runAndReturn(5);
892 await mutation.runAsPromise(5);
914893 await delay(30);
915894
916895 // Run another mutation - should work fine (channel recreated if needed)
917 const result = await mutation.runAndReturn(3);
896 const result = await mutation.runAsPromise(3);
918897 await delay(30);
919898
920899 assertEquals(result, 3);
......@@ -943,10 +922,9 @@ test("DebouncedMutation - default time is 200ms", async () => {
943922 },
944923 describe: "test mutation",
945924 describeResult: "Success",
946 async refetch() {},
947925 });
948926
949 await mutation.runAndReturn(5);
927 await mutation.runAsPromise(5);
950928
951929 // Should commit after ~200ms (with some tolerance)
952930 assertEquals(commitTime !== null, true);
......@@ -977,10 +955,9 @@ test("DebouncedMutation - context is passed to getValue", async () => {
977955 },
978956 describe: "test mutation",
979957 describeResult: "Success",
980 async refetch() {},
981958 });
982959
983 await mutation.runAndReturn(5);
960 await mutation.runAsPromise(5);
984961 await delay(30);
985962
986963 assertEquals(receivedUserId, "test-user");
......@@ -1007,10 +984,9 @@ test("DebouncedMutation - context is passed to commit", async () => {
1007984 },
1008985 describe: "test mutation",
1009986 describeResult: "Success",
1010 async refetch() {},
1011987 });
1012988
1013 await mutation.runAndReturn(5);
989 await mutation.runAsPromise(5);
1014990 await delay(30);
1015991
1016992 assertEquals(receivedUserId, "test-user");
......@@ -1037,12 +1013,11 @@ test("DebouncedMutation - first args are used for commit", async () => {
10371013 },
10381014 describe: "test mutation",
10391015 describeResult: "Success",
1040 async refetch() {},
10411016 });
10421017
1043 mutation.runAndReturn("first", 1);
1044 mutation.runAndReturn("second", 2);
1045 await mutation.runAndReturn("third", 3);
1018 mutation.runAsPromise("first", 1);
1019 mutation.runAsPromise("second", 2);
1020 await mutation.runAsPromise("third", 3);
10461021 await delay(10);
10471022
10481023 // Should use first args