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 @@...@@ -1,6 +1,6 @@
1{1{
2 "name": "@clo/react-mutation",2 "name": "@clo/react-mutation",
3 "version": "1.0.0-beta.5",3 "version": "1.0.0-beta.6",
4 "exports": {4 "exports": {
5 ".": "./src/mod.ts",5 ".": "./src/mod.ts",
6 "./tanstack-query.ts": "./src/tanstack-query.ts",6 "./tanstack-query.ts": "./src/tanstack-query.ts",
package-lock.json+15
...@@ -19,6 +19,7 @@...@@ -19,6 +19,7 @@
19 "@vitejs/plugin-react": "^5.1.1",19 "@vitejs/plugin-react": "^5.1.1",
20 "react": "^19.2.4",20 "react": "^19.2.4",
21 "react-dom": "^19.2.4",21 "react-dom": "^19.2.4",
22 "typescript": "^5.9.3",
22 "vite": "^7.2.4",23 "vite": "^7.2.4",
23 "vitest": "^4.0.18"24 "vitest": "^4.0.18"
24 },25 },
...@@ -1998,6 +1999,20 @@...@@ -1998,6 +1999,20 @@
1998 "node": ">=14.0.0"1999 "node": ">=14.0.0"
1999 }2000 }
2000 },2001 },
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 },
2001 "node_modules/undici-types": {2016 "node_modules/undici-types": {
2002 "version": "7.16.0",2017 "version": "7.16.0",
2003 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",2018 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
package.json+1
...@@ -23,6 +23,7 @@...@@ -23,6 +23,7 @@
23 "@vitejs/plugin-react": "^5.1.1",23 "@vitejs/plugin-react": "^5.1.1",
24 "react": "^19.2.4",24 "react": "^19.2.4",
25 "react-dom": "^19.2.4",25 "react-dom": "^19.2.4",
26 "typescript": "^5.9.3",
26 "vite": "^7.2.4",27 "vite": "^7.2.4",
27 "vitest": "^4.0.18"28 "vitest": "^4.0.18"
28 },29 },
readme.md+4-3
...@@ -15,9 +15,10 @@ The primary gains React Mutation provides are...@@ -15,9 +15,10 @@ The primary gains React Mutation provides are
15- **Automatic result handling**. If a `useMutate` hook does not observe15- **Automatic result handling**. If a `useMutate` hook does not observe
16 `isError`, unhandled errors will be propagated to a global handler, which can16 `isError`, unhandled errors will be propagated to a global handler, which can
17 display a UI toast. Otherwise, the component can display the error locally.17 display a UI toast. Otherwise, the component can display the error locally.
18- Optimistic helpers allow defining rollbacks and refetching logic independant18- **Optimistic helpers with built-in rollbacks** make it super easy to alter the
19 of the actual mutation. The [built in helpers for React Query](#react-query-optimistic-helpers)19 UI without worrying about bugged error states. The
20 shows this power in more detail.20 [built in helpers for React Query](#react-query-optimistic-helpers) shows
21 this power in more detail.
21- Easy debouncing and batching utilities.22- Easy debouncing and batching utilities.
2223
23## Usage24## Usage
src/blocking.ts+54-40
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import type { MutationClient, MutationClientFromConfig } from "./client.ts";1import type { MutationClient, MutationClientFromConfig } from "./client.ts";
2import type { MutationClientConfig } from "./client.ts";2import type { MutationClientConfig } from "./client.ts";
3import type { Mutation, MutationEvent } from "./types.ts";3import type { Mutation, MutationEvent, RunOptions } from "./types.ts";
4import { message as errMessage } from "@clo/lib/error.ts";4import { message as errMessage } from "@clo/lib/error.ts";
55
6/**6/**
...@@ -45,12 +45,6 @@ export interface MutationOptions<...@@ -45,12 +45,6 @@ export interface MutationOptions<
45 optimistic: (45 optimistic: (
46 context: OptimisticContext<Args, Result, Config>,46 context: OptimisticContext<Args, Result, Config>,
47 ) => void;47 ) => 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>;
54 /**48 /**
55 * If the optimistic updator function is perfect, then this may be set to false.49 * If the optimistic updator function is perfect, then this may be set to false.
56 * @default true50 * @default true
...@@ -231,26 +225,56 @@ export class BlockingMutation<...@@ -231,26 +225,56 @@ export class BlockingMutation<
231225
232 /** Calling the mutation in a global scope. Errors are turned into UI toasts. */226 /** Calling the mutation in a global scope. Errors are turned into UI toasts. */
233 run(...args: Args) {227 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> {
234 if (!this.#client.enabled) {233 if (!this.#client.enabled) {
235 throw new Error(234 throw new Error(
236 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",235 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",
237 );236 );
238 }237 }
239 this.runAndReturn(...args).then((result) => {238
240 const message = this.describeResult(args, result);239 const args = array.slice() as Args;
241 if (message && this.#client.reportSuccess) {240 const { onSuccess, onSuccessDataOnly, onError, onSettled } = args
242 this.#client.reportSuccess(message);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 }
243 }258 }
244 }).catch((error) => {259 }).catch((error) => {
245 const message = `Failed to ${this.describe(...args)}: ${260 // Call user handlers
246 errMessage(error)261 onError?.(error);
247 }`;262 onSettled?.({ status: "error", error });
248 this.#client.reportError(message, 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 }
249 });271 });
272
273 return promise;
250 }274 }
251275
252 /** Calls the mutation, treating the errors as promise rejection. */276 /** Calls the mutation, treating the errors as promise rejection. */
253 runAndReturn(...args: Args): Promise<Result> {277 runAsPromise(...args: Args): Promise<Result> {
254 if (!this.#client.enabled) {278 if (!this.#client.enabled) {
255 throw new Error(279 throw new Error(
256 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",280 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",
...@@ -366,23 +390,19 @@ export class BlockingMutation<...@@ -366,23 +390,19 @@ export class BlockingMutation<
366 this.#notify(channel, "refetching", result);390 this.#notify(channel, "refetching", result);
367 // Call refetch and all refetch callbacks in parallel391 // Call refetch and all refetch callbacks in parallel
368 const refetchCallbacks = channel.refetches.splice(0);392 const refetchCallbacks = channel.refetches.splice(0);
369 Promise.allSettled([393 Promise.allSettled(refetchCallbacks.map((cb) => cb())).then(
370 this.#options.refetch?.({394 (results) => {
371 ...this.#client.context,395 // Report any errors from refetch or callbacks
372 args,396 results.forEach((result) => {
373 }),397 if (result.status === "rejected") {
374 ...refetchCallbacks.map((cb) => cb()),398 const message = `Failed to refetch after ${
375 ]).then((results) => {399 this.describe(...args)
376 // Report any errors from refetch or callbacks400 }: ${errMessage(result.reason)}`;
377 results.forEach((result) => {401 this.#client.reportError(message, result.reason);
378 if (result.status === "rejected") {402 }
379 const message = `Failed to refetch after ${403 });
380 this.describe(...args)404 },
381 }: ${errMessage(result.reason)}`;405 ).finally(() => {
382 this.#client.reportError(message, result.reason);
383 }
384 });
385 }).finally(() => {
386 this.#executeNext(key, channel);406 this.#executeNext(key, channel);
387 });407 });
388 } else {408 } else {
...@@ -410,13 +430,7 @@ export class BlockingMutation<...@@ -410,13 +430,7 @@ export class BlockingMutation<
410 this.#notify(channel, "refetching", null, error);430 this.#notify(channel, "refetching", null, error);
411 // Call refetch and all refetch callbacks in parallel431 // Call refetch and all refetch callbacks in parallel
412 const refetchCallbacks = channel.refetches.splice(0);432 const refetchCallbacks = channel.refetches.splice(0);
413 Promise.allSettled([433 Promise.allSettled(refetchCallbacks.map((cb) => cb())).then((results) => {
414 this.#options.refetch?.({
415 ...this.#client.context,
416 args,
417 }),
418 ...refetchCallbacks.map((cb) => cb()),
419 ]).then((results) => {
420 // Report any errors from refetch or callbacks434 // Report any errors from refetch or callbacks
421 results.forEach((result) => {435 results.forEach((result) => {
422 if (result.status === "rejected") {436 if (result.status === "rejected") {
src/debounced.ts+49-16
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import type { MutationClient, MutationClientFromConfig } from "./client.ts";1import type { MutationClient, MutationClientFromConfig } from "./client.ts";
2import type { MutationClientConfig } from "./client.ts";2import type { MutationClientConfig } from "./client.ts";
3import type { Mutation, MutationEvent } from "./types.ts";3import type { Mutation, MutationEvent, RunOptions } from "./types.ts";
4import { message as errMessage } from "@clo/lib/error.ts";4import { message as errMessage } from "@clo/lib/error.ts";
55
6export interface DebouncedMutationOptions<6export interface DebouncedMutationOptions<
...@@ -293,8 +293,42 @@ export class DebouncedMutation<...@@ -293,8 +293,42 @@ export class DebouncedMutation<
293 });293 });
294 }294 }
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
296 /** Calls the mutation, treating the errors as promise rejection. */330 /** Calls the mutation, treating the errors as promise rejection. */
297 runAndReturn(...args: Args): Promise<Result> {331 runAsPromise(...args: Args): Promise<Result> {
298 if (!this.#client.enabled) {332 if (!this.#client.enabled) {
299 throw new Error(333 throw new Error(
300 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",334 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",
...@@ -546,20 +580,19 @@ export class DebouncedMutation<...@@ -546,20 +580,19 @@ export class DebouncedMutation<
546 channel.status = "refetching";580 channel.status = "refetching";
547 this.#notify(channel, "refetching", null, error);581 this.#notify(channel, "refetching", null, error);
548 // Call refetch and all refetch callbacks in parallel582 // Call refetch and all refetch callbacks in parallel
549 Promise.allSettled([583 Promise.allSettled(refetchCallbacks.map((cb) => cb())).then(
550 this.#options.refetch?.(),584 (results) => {
551 ...refetchCallbacks.map((cb) => cb()),585 // Report any errors from refetch or callbacks
552 ]).then((results) => {586 results.forEach((result) => {
553 // Report any errors from refetch or callbacks587 if (result.status === "rejected") {
554 results.forEach((result) => {588 const message = `Failed to refetch after ${
555 if (result.status === "rejected") {589 this.describe(...firstArgs)
556 const message = `Failed to refetch after ${590 }: ${errMessage(result.reason)}`;
557 this.describe(...firstArgs)591 this.#client.reportError(message, result.reason);
558 }: ${errMessage(result.reason)}`;592 }
559 this.#client.reportError(message, result.reason);593 });
560 }594 },
561 });595 ).finally(() => {
562 }).finally(() => {
563 // Check if new calls came in during the commit596 // Check if new calls came in during the commit
564 if (channel.pending.length > 0) {597 if (channel.pending.length > 0) {
565 // There are pending calls that need to be committed598 // There are pending calls that need to be committed
src/react.ts+63-17
...@@ -8,7 +8,7 @@ import {...@@ -8,7 +8,7 @@ import {
8 useState,8 useState,
9} from "react";9} from "react";
10import { message as errMessage } from "@clo/lib/error.ts";10import { message as errMessage } from "@clo/lib/error.ts";
11import type { Mutation } from "./types.ts";11import type { Mutation, RunOptions } from "./types.ts";
12import { jsx } from "react/jsx-runtime";12import { jsx } from "react/jsx-runtime";
1313
14/**14/**
...@@ -41,7 +41,10 @@ export type UseMutateResult<Args extends unknown[], Result> =...@@ -41,7 +41,10 @@ export type UseMutateResult<Args extends unknown[], Result> =
4141
42export interface UseMutateResultBase<Args extends unknown[], Result> {42export interface UseMutateResultBase<Args extends unknown[], Result> {
43 run: (...args: Args) => void;43 run: (...args: Args) => void;
44 runWithResult: (...args: Args) => Promise<Result>;44 runWithOptions: (
45 options: RunOptions<Result>,
46 ...args: Args
47 ) => Promise<Result>;
45 clear: () => void;48 clear: () => void;
46}49}
4750
...@@ -220,7 +223,7 @@ class Observer<Args extends unknown[], Result> {...@@ -220,7 +223,7 @@ class Observer<Args extends unknown[], Result> {
220 this.watched.has("error") || this.watched.has("errorMessage");223 this.watched.has("error") || this.watched.has("errorMessage");
221 const watchesSuccess = this.watched.has("isSuccess") ||224 const watchesSuccess = this.watched.has("isSuccess") ||
222 this.watched.has("result");225 this.watched.has("result");
223 const promise = mutation.runAndReturn(...args)226 const promise = mutation.runAsPromise(...args)
224 .then((result) => {227 .then((result) => {
225 if (!watchesSuccess && mutation.describeResult) {228 if (!watchesSuccess && mutation.describeResult) {
226 const message = mutation.describeResult(args, result);229 const message = mutation.describeResult(args, result);
...@@ -240,12 +243,63 @@ class Observer<Args extends unknown[], Result> {...@@ -240,12 +243,63 @@ class Observer<Args extends unknown[], Result> {
240 return promise;243 return promise;
241 }244 }
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
243 binding: UseMutateResult<Args, Result> = ((self: this) => ({297 binding: UseMutateResult<Args, Result> = ((self: this) => ({
244 run(...args) {298 run(...args) {
245 return self.run(...args);299 return self.run(...args);
246 },300 },
247 runWithResult(...args) {301 runWithOptions(options, ...args) {
248 return self.run(...args);302 return self.runWithOptions(options, ...args);
249 },303 },
250 clear() {304 clear() {
251 self.setState({305 self.setState({
...@@ -394,9 +448,6 @@ function GenericMutationButton<...@@ -394,9 +448,6 @@ function GenericMutationButton<
394 const localHook = useMutate("subscribe" in mutation ? mutation : null);448 const localHook = useMutate("subscribe" in mutation ? mutation : null);
395 const state = "subscribe" in mutation ? localHook : mutation;449 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
400 // NOTE: the JSR has trouble with JSX syntax for some reason.451 // NOTE: the JSR has trouble with JSX syntax for some reason.
401 return jsx(452 return jsx(
402 Component,453 Component,
...@@ -407,15 +458,10 @@ function GenericMutationButton<...@@ -407,15 +458,10 @@ function GenericMutationButton<
407 if (e.defaultPrevented) return;458 if (e.defaultPrevented) return;
408 const computedArgs = typeof args === "function" ? args(e) : args;459 const computedArgs = typeof args === "function" ? args(e) : args;
409 if (!computedArgs || e.defaultPrevented) return;460 if (!computedArgs || e.defaultPrevented) return;
410 state.runWithResult(...computedArgs)461 state.runWithOptions(
411 .then((result) => {462 { onSuccess, onError, onSettled },
412 onSuccess?.(result);463 ...computedArgs,
413 onSettled?.({ status: "success", result });464 );
414 })
415 .catch((error) => {
416 onError?.(error);
417 onSettled?.({ status: "error", error });
418 });
419 }, [state]),465 }, [state]),
420 isPending: state.isPending,466 isPending: state.isPending,
421 } satisfies Parameters<typeof Component>[0],467 } satisfies Parameters<typeof Component>[0],
src/tanstack-query.ts+34-1
...@@ -322,6 +322,39 @@ class TanstackQueryOptimisticHelpers {...@@ -322,6 +322,39 @@ class TanstackQueryOptimisticHelpers {
322 objArrayRemove<322 objArrayRemove<
323 Data extends object,323 Data extends object,
324 const Path extends AllObjectPaths<Data>,324 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>,
325 >(358 >(
326 queryKey: QueryKeyAndFn<Data>,359 queryKey: QueryKeyAndFn<Data>,
327 path: Path,360 path: Path,
...@@ -335,7 +368,7 @@ class TanstackQueryOptimisticHelpers {...@@ -335,7 +368,7 @@ class TanstackQueryOptimisticHelpers {
335 const { value: original, exists } = getPath(prev, path);368 const { value: original, exists } = getPath(prev, path);
336 if (!exists || !Array.isArray(original)) return;369 if (!exists || !Array.isArray(original)) return;
337370
338 const newArray = original.filter((item, index) => !filter(item, index));371 const newArray = original.filter(filter);
339 this.#set(372 this.#set(
340 queryKey,373 queryKey,
341 (obj) => obj ? setPath(obj, path, newArray as any) : obj,374 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
src/types.ts+19-2
...@@ -3,8 +3,10 @@ import type { MutationClient } from "./client.ts";...@@ -3,8 +3,10 @@ import type { MutationClient } from "./client.ts";
3export interface Mutation<Args extends unknown[], Result> {3export interface Mutation<Args extends unknown[], Result> {
4 /** Calling the mutation. Errors are turned into UI toasts. */4 /** Calling the mutation. Errors are turned into UI toasts. */
5 run(...args: Args): void;5 run(...args: Args): void;
6 /** Calls the mutation, treating the errors as promise rejection. */6 /** Calls the mutation with custom handlers that can suppress global handlers. */
7 runAndReturn(...args: Args): Promise<Result>;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
9 /** Returns the concurrency key used for a given set of arguments */11 /** Returns the concurrency key used for a given set of arguments */
10 key(args: Args): string;12 key(args: Args): string;
...@@ -18,6 +20,21 @@ export interface Mutation<Args extends unknown[], Result> {...@@ -18,6 +20,21 @@ export interface Mutation<Args extends unknown[], Result> {
18 client: MutationClient<object, object>;20 client: MutationClient<object, object>;
19}21}
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
21export interface MutationEvent<Result> {38export interface MutationEvent<Result> {
22 status: "idle" | "waiting" | "mutating" | "refetching";39 status: "idle" | "waiting" | "mutating" | "refetching";
23 result: Result | null;40 result: Result | null;
test/blocking.test.ts+127-160
...@@ -57,16 +57,15 @@ test("BlockingMutation - basic mutation success", async () => {...@@ -57,16 +57,15 @@ test("BlockingMutation - basic mutation success", async () => {
57 },57 },
58 describe: "test mutation",58 describe: "test mutation",
59 describeResult: "Success",59 describeResult: "Success",
60 optimistic() {60 optimistic({ onRefetch }) {
61 // Empty optimistic update61 onRefetch(async () => {
62 },62 refetchCallCount++;
63 async refetch() {63 await delay(5);
64 refetchCallCount++;64 });
65 await delay(5);
66 },65 },
67 });66 });
6867
69 const result = await mutation.runAndReturn("test");68 const result = await mutation.runAsPromise("test");
70 // Wait for refetch to complete69 // Wait for refetch to complete
71 await delay(20);70 await delay(20);
7271
...@@ -85,7 +84,6 @@ test("BlockingMutation - run() catches errors", async () => {...@@ -85,7 +84,6 @@ test("BlockingMutation - run() catches errors", async () => {
85 describe: "failing mutation",84 describe: "failing mutation",
86 describeResult: "Success",85 describeResult: "Success",
87 optimistic() {},86 optimistic() {},
88 async refetch() {},
89 });87 });
9088
91 mutation.run("test");89 mutation.run("test");
...@@ -105,11 +103,10 @@ test("BlockingMutation - runAndReturn() rejects on error", async () => {...@@ -105,11 +103,10 @@ test("BlockingMutation - runAndReturn() rejects on error", async () => {
105 describe: "failing mutation",103 describe: "failing mutation",
106 describeResult: "Success",104 describeResult: "Success",
107 optimistic() {},105 optimistic() {},
108 async refetch() {},
109 });106 });
110107
111 await assertRejects(108 await assertRejects(
112 () => mutation.runAndReturn("test"),109 () => mutation.runAsPromise("test"),
113 Error,110 Error,
114 "mutation failed",111 "mutation failed",
115 );112 );
...@@ -130,10 +127,9 @@ test("BlockingMutation - optimistic updates are applied immediately", async () =...@@ -130,10 +127,9 @@ test("BlockingMutation - optimistic updates are applied immediately", async () =
130 const [key, value] = args;127 const [key, value] = args;
131 helpers.setValue(key, value);128 helpers.setValue(key, value);
132 },129 },
133 async refetch() {},
134 });130 });
135131
136 const promise = mutation.runAndReturn("key1", "value1");132 const promise = mutation.runAsPromise("key1", "value1");
137133
138 // Optimistic update should be applied synchronously134 // Optimistic update should be applied synchronously
139 assertEquals(testStore.get("key1"), "value1");135 assertEquals(testStore.get("key1"), "value1");
...@@ -158,10 +154,9 @@ test("BlockingMutation - rollback on error", async () => {...@@ -158,10 +154,9 @@ test("BlockingMutation - rollback on error", async () => {
158 const [key, value] = args;154 const [key, value] = args;
159 helpers.setValue(key, value);155 helpers.setValue(key, value);
160 },156 },
161 async refetch() {},
162 });157 });
163158
164 await assertRejects(() => mutation.runAndReturn("key1", "value1"));159 await assertRejects(() => mutation.runAsPromise("key1", "value1"));
165160
166 // Optimistic update should be rolled back161 // Optimistic update should be rolled back
167 assertEquals(testStore.has("key1"), false);162 assertEquals(testStore.has("key1"), false);
...@@ -182,10 +177,9 @@ test("BlockingMutation - onSuccess callback is called", async () => {...@@ -182,10 +177,9 @@ test("BlockingMutation - onSuccess callback is called", async () => {
182 successResults.push(result);177 successResults.push(result);
183 });178 });
184 },179 },
185 async refetch() {},
186 });180 });
187181
188 await mutation.runAndReturn("test");182 await mutation.runAsPromise("test");
189183
190 assertEquals(successResults, ["result-test"]);184 assertEquals(successResults, ["result-test"]);
191});185});
...@@ -204,7 +198,7 @@ test("BlockingMutation - mutations with same key execute serially", async () =>...@@ -204,7 +198,7 @@ test("BlockingMutation - mutations with same key execute serially", async () =>
204 describe: "test mutation",198 describe: "test mutation",
205 describeResult: "Success",199 describeResult: "Success",
206 optimistic() {},200 optimistic() {},
207 async refetch() {},201
208 refetchOnSuccess: false,202 refetchOnSuccess: false,
209 key() {203 key() {
210 return "same-key";204 return "same-key";
...@@ -212,8 +206,8 @@ test("BlockingMutation - mutations with same key execute serially", async () =>...@@ -212,8 +206,8 @@ test("BlockingMutation - mutations with same key execute serially", async () =>
212 });206 });
213207
214 // Start two mutations with the same key208 // Start two mutations with the same key
215 const promise1 = mutation.runAndReturn("1");209 const promise1 = mutation.runAsPromise("1");
216 const promise2 = mutation.runAndReturn("2");210 const promise2 = mutation.runAsPromise("2");
217211
218 await Promise.all([promise1, promise2]);212 await Promise.all([promise1, promise2]);
219 await delay(10);213 await delay(10);
...@@ -236,7 +230,7 @@ test("BlockingMutation - mutations with different keys execute in parallel", asy...@@ -236,7 +230,7 @@ test("BlockingMutation - mutations with different keys execute in parallel", asy
236 describe: "test mutation",230 describe: "test mutation",
237 describeResult: "Success",231 describeResult: "Success",
238 optimistic() {},232 optimistic() {},
239 async refetch() {},233
240 key({ args }) {234 key({ args }) {
241 const [id] = args;235 const [id] = args;
242 return id;236 return id;
...@@ -244,8 +238,8 @@ test("BlockingMutation - mutations with different keys execute in parallel", asy...@@ -244,8 +238,8 @@ test("BlockingMutation - mutations with different keys execute in parallel", asy
244 });238 });
245239
246 // Start two mutations with different keys240 // Start two mutations with different keys
247 const promise1 = mutation.runAndReturn("key1");241 const promise1 = mutation.runAsPromise("key1");
248 const promise2 = mutation.runAndReturn("key2");242 const promise2 = mutation.runAsPromise("key2");
249243
250 await Promise.all([promise1, promise2]);244 await Promise.all([promise1, promise2]);
251245
...@@ -263,7 +257,7 @@ test("BlockingMutation - key() returns JSON stringified key", () => {...@@ -263,7 +257,7 @@ test("BlockingMutation - key() returns JSON stringified key", () => {
263 describe: "test mutation",257 describe: "test mutation",
264 describeResult: "Success",258 describeResult: "Success",
265 optimistic() {},259 optimistic() {},
266 async refetch() {},260
267 key({ args }) {261 key({ args }) {
268 const [id] = args;262 const [id] = args;
269 return id;263 return id;
...@@ -283,7 +277,6 @@ test("BlockingMutation - key() defaults to 'shared' when no key function", () =>...@@ -283,7 +277,6 @@ test("BlockingMutation - key() defaults to 'shared' when no key function", () =>
283 describe: "test mutation",277 describe: "test mutation",
284 describeResult: "Success",278 describeResult: "Success",
285 optimistic() {},279 optimistic() {},
286 async refetch() {},
287 });280 });
288281
289 assertEquals(mutation.key(["test-id"]), JSON.stringify("shared"));282 assertEquals(mutation.key(["test-id"]), JSON.stringify("shared"));
...@@ -299,7 +292,7 @@ test("BlockingMutation - key() can return array", () => {...@@ -299,7 +292,7 @@ test("BlockingMutation - key() can return array", () => {
299 describe: "test mutation",292 describe: "test mutation",
300 describeResult: "Success",293 describeResult: "Success",
301 optimistic() {},294 optimistic() {},
302 async refetch() {},295
303 key({ args }) {296 key({ args }) {
304 const [userId, itemId] = args;297 const [userId, itemId] = args;
305 return [userId, itemId];298 return [userId, itemId];
...@@ -322,7 +315,6 @@ test("BlockingMutation - describe() with string", () => {...@@ -322,7 +315,6 @@ test("BlockingMutation - describe() with string", () => {
322 describe: "create item",315 describe: "create item",
323 describeResult: "Success",316 describeResult: "Success",
324 optimistic() {},317 optimistic() {},
325 async refetch() {},
326 });318 });
327319
328 assertEquals(mutation.describe("test"), "create item");320 assertEquals(mutation.describe("test"), "create item");
...@@ -339,8 +331,8 @@ test("BlockingMutation - describe() with function", () => {...@@ -339,8 +331,8 @@ test("BlockingMutation - describe() with function", () => {
339 const [id] = args;331 const [id] = args;
340 return `delete item ${id}`;332 return `delete item ${id}`;
341 },333 },
334 describeResult: null,
342 optimistic() {},335 optimistic() {},
343 async refetch() {},
344 });336 });
345337
346 assertEquals(mutation.describe("123"), "delete item 123");338 assertEquals(mutation.describe("123"), "delete item 123");
...@@ -358,7 +350,7 @@ test("BlockingMutation - describe() receives context", () => {...@@ -358,7 +350,7 @@ test("BlockingMutation - describe() receives context", () => {
358 return `user ${userId} editing item ${id}`;350 return `user ${userId} editing item ${id}`;
359 },351 },
360 optimistic() {},352 optimistic() {},
361 async refetch() {},353 describeResult: null,
362 });354 });
363355
364 assertEquals(356 assertEquals(
...@@ -378,16 +370,17 @@ test("BlockingMutation - subscribe() tracks mutation events", async () => {...@@ -378,16 +370,17 @@ test("BlockingMutation - subscribe() tracks mutation events", async () => {
378 },370 },
379 describe: "test mutation",371 describe: "test mutation",
380 describeResult: "Success",372 describeResult: "Success",
381 optimistic() {},373 optimistic({ onRefetch }) {
382 async refetch() {374 onRefetch(async () => {
383 await delay(5);375 await delay(5);
376 });
384 },377 },
385 });378 });
386379
387 const key = mutation.key(["test"]);380 const key = mutation.key(["test"]);
388 mutation.subscribe(key, tracker.callback);381 mutation.subscribe(key, tracker.callback);
389382
390 await mutation.runAndReturn("test");383 await mutation.runAsPromise("test");
391 // Wait for refetch to complete384 // Wait for refetch to complete
392 await delay(20);385 await delay(20);
393386
...@@ -409,7 +402,7 @@ test("BlockingMutation - unsubscribe stops receiving events", async () => {...@@ -409,7 +402,7 @@ test("BlockingMutation - unsubscribe stops receiving events", async () => {
409 describe: "test mutation",402 describe: "test mutation",
410 describeResult: "Success",403 describeResult: "Success",
411 optimistic() {},404 optimistic() {},
412 async refetch() {},405
413 refetchOnSuccess: false,406 refetchOnSuccess: false,
414 });407 });
415408
...@@ -418,7 +411,7 @@ test("BlockingMutation - unsubscribe stops receiving events", async () => {...@@ -418,7 +411,7 @@ test("BlockingMutation - unsubscribe stops receiving events", async () => {
418411
419 unsubscribe();412 unsubscribe();
420413
421 await mutation.runAndReturn("test");414 await mutation.runAsPromise("test");
422 await delay(10);415 await delay(10);
423416
424 // Should not have received any events417 // Should not have received any events
...@@ -435,14 +428,15 @@ test("BlockingMutation - refetchOnSuccess can be disabled", async () => {...@@ -435,14 +428,15 @@ test("BlockingMutation - refetchOnSuccess can be disabled", async () => {
435 },428 },
436 describe: "test mutation",429 describe: "test mutation",
437 describeResult: "Success",430 describeResult: "Success",
438 optimistic() {},431 optimistic({ onRefetch }) {
439 async refetch() {432 onRefetch(async () => {
440 refetchCallCount++;433 refetchCallCount++;
434 });
441 },435 },
442 refetchOnSuccess: false,436 refetchOnSuccess: false,
443 });437 });
444438
445 await mutation.runAndReturn("test");439 await mutation.runAsPromise("test");
446440
447 assertEquals(refetchCallCount, 0);441 assertEquals(refetchCallCount, 0);
448});442});
...@@ -457,13 +451,14 @@ test("BlockingMutation - refetch is called on error", async () => {...@@ -457,13 +451,14 @@ test("BlockingMutation - refetch is called on error", async () => {
457 },451 },
458 describe: "failing mutation",452 describe: "failing mutation",
459 describeResult: "Success",453 describeResult: "Success",
460 optimistic() {},454 optimistic({ onRefetch }) {
461 async refetch() {455 onRefetch(async () => {
462 refetchCallCount++;456 refetchCallCount++;
457 });
463 },458 },
464 });459 });
465460
466 await assertRejects(() => mutation.runAndReturn("test"));461 await assertRejects(() => mutation.runAsPromise("test"));
467462
468 assertEquals(refetchCallCount, 1);463 assertEquals(refetchCallCount, 1);
469});464});
...@@ -485,15 +480,15 @@ test("BlockingMutation - queued mutations are cancelled on error", async () => {...@@ -485,15 +480,15 @@ test("BlockingMutation - queued mutations are cancelled on error", async () => {
485 describe: "test mutation",480 describe: "test mutation",
486 describeResult: "Success",481 describeResult: "Success",
487 optimistic() {},482 optimistic() {},
488 async refetch() {},483
489 key() {484 key() {
490 return "same-key";485 return "same-key";
491 },486 },
492 });487 });
493488
494 const promise1 = mutation.runAndReturn("1");489 const promise1 = mutation.runAsPromise("1");
495 const promise2 = mutation.runAndReturn("2");490 const promise2 = mutation.runAsPromise("2");
496 const promise3 = mutation.runAndReturn("3");491 const promise3 = mutation.runAsPromise("3");
497492
498 await assertRejects(() => promise1, Error, "first mutation failed");493 await assertRejects(() => promise1, Error, "first mutation failed");
499 await assertRejects(() => promise2, Error, "first mutation failed");494 await assertRejects(() => promise2, Error, "first mutation failed");
...@@ -518,10 +513,9 @@ test("BlockingMutation - rollbacks are called in reverse order on error", async...@@ -518,10 +513,9 @@ test("BlockingMutation - rollbacks are called in reverse order on error", async
518 onRestore(() => rollbackOrder.push(2));513 onRestore(() => rollbackOrder.push(2));
519 onRestore(() => rollbackOrder.push(3));514 onRestore(() => rollbackOrder.push(3));
520 },515 },
521 async refetch() {},
522 });516 });
523517
524 await assertRejects(() => mutation.runAndReturn("test"));518 await assertRejects(() => mutation.runAsPromise("test"));
525519
526 // Rollbacks should be called in reverse order520 // Rollbacks should be called in reverse order
527 assertEquals(rollbackOrder, [3, 2, 1]);521 assertEquals(rollbackOrder, [3, 2, 1]);
...@@ -544,17 +538,17 @@ test("BlockingMutation - multiple mutations: rollbacks only affect failed mutati...@@ -544,17 +538,17 @@ test("BlockingMutation - multiple mutations: rollbacks only affect failed mutati
544 optimistic({ args: [id], onRestore }) {538 optimistic({ args: [id], onRestore }) {
545 onRestore(() => rollbackOrder.push(`rollback-${id}`));539 onRestore(() => rollbackOrder.push(`rollback-${id}`));
546 },540 },
547 async refetch() {},541
548 key() {542 key() {
549 return "same-key";543 return "same-key";
550 },544 },
551 });545 });
552546
553 // First mutation succeeds547 // First mutation succeeds
554 await mutation.runAndReturn("success");548 await mutation.runAsPromise("success");
555549
556 // Second mutation fails550 // Second mutation fails
557 await assertRejects(() => mutation.runAndReturn("fail"));551 await assertRejects(() => mutation.runAsPromise("fail"));
558552
559 // Only the failed mutation's rollback should be called553 // Only the failed mutation's rollback should be called
560 // And all rollbacks from queued items554 // And all rollbacks from queued items
...@@ -574,10 +568,9 @@ test("BlockingMutation - onRestore throws error if called after optimistic phase...@@ -574,10 +568,9 @@ test("BlockingMutation - onRestore throws error if called after optimistic phase
574 optimistic({ onRestore }) {568 optimistic({ onRestore }) {
575 capturedOnRestore = onRestore;569 capturedOnRestore = onRestore;
576 },570 },
577 async refetch() {},
578 });571 });
579572
580 await mutation.runAndReturn("test");573 await mutation.runAsPromise("test");
581574
582 // Calling onRestore after the optimistic phase should throw575 // Calling onRestore after the optimistic phase should throw
583 let error: Error | null = null;576 let error: Error | null = null;
...@@ -606,10 +599,9 @@ test("BlockingMutation - onSuccess throws error if called after optimistic phase...@@ -606,10 +599,9 @@ test("BlockingMutation - onSuccess throws error if called after optimistic phase
606 optimistic({ onSuccess }) {599 optimistic({ onSuccess }) {
607 capturedOnSuccess = onSuccess;600 capturedOnSuccess = onSuccess;
608 },601 },
609 async refetch() {},
610 });602 });
611603
612 await mutation.runAndReturn("test");604 await mutation.runAsPromise("test");
613605
614 // Calling onSuccess after the optimistic phase should throw606 // Calling onSuccess after the optimistic phase should throw
615 let error: Error | null = null;607 let error: Error | null = null;
...@@ -637,11 +629,10 @@ test("BlockingMutation - error during optimistic update is rejected immediately"...@@ -637,11 +629,10 @@ test("BlockingMutation - error during optimistic update is rejected immediately"
637 optimistic() {629 optimistic() {
638 throw new Error("optimistic update failed");630 throw new Error("optimistic update failed");
639 },631 },
640 async refetch() {},
641 });632 });
642633
643 await assertRejects(634 await assertRejects(
644 () => mutation.runAndReturn("test"),635 () => mutation.runAsPromise("test"),
645 Error,636 Error,
646 "optimistic update failed",637 "optimistic update failed",
647 );638 );
...@@ -662,10 +653,9 @@ test("BlockingMutation - error during optimistic update rolls back registered ca...@@ -662,10 +653,9 @@ test("BlockingMutation - error during optimistic update rolls back registered ca
662 onRestore(() => rollbackOrder.push(2));653 onRestore(() => rollbackOrder.push(2));
663 throw new Error("optimistic update failed");654 throw new Error("optimistic update failed");
664 },655 },
665 async refetch() {},
666 });656 });
667657
668 await assertRejects(() => mutation.runAndReturn("test"));658 await assertRejects(() => mutation.runAsPromise("test"));
669659
670 // Rollbacks should be called even though optimistic update failed660 // Rollbacks should be called even though optimistic update failed
671 // Note: during optimistic error, rollbacks are executed in the order they were added661 // 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...@@ -681,14 +671,15 @@ test("BlockingMutation - refetch errors are reported but don't fail mutation", a
681 },671 },
682 describe: "test mutation",672 describe: "test mutation",
683 describeResult: "Success",673 describeResult: "Success",
684 optimistic() {},674 optimistic({ onRefetch }) {
685 async refetch() {675 onRefetch(async () => {
686 throw new Error("refetch failed");676 throw new Error("refetch failed");
677 });
687 },678 },
688 });679 });
689680
690 // Mutation should still succeed681 // Mutation should still succeed
691 const result = await mutation.runAndReturn("test");682 const result = await mutation.runAsPromise("test");
692 assertEquals(result, "test");683 assertEquals(result, "test");
693684
694 // But refetch error should be reported685 // But refetch error should be reported
...@@ -712,39 +703,14 @@ test("BlockingMutation - optimistic function receives args and helpers", async (...@@ -712,39 +703,14 @@ test("BlockingMutation - optimistic function receives args and helpers", async (
712 receivedArgs = args;703 receivedArgs = args;
713 receivedHelpers = helpers;704 receivedHelpers = helpers;
714 },705 },
715 async refetch() {},
716 });706 });
717707
718 await mutation.runAndReturn("test");708 await mutation.runAsPromise("test");
719709
720 assertEquals(receivedArgs, ["test"]);710 assertEquals(receivedArgs, ["test"]);
721 assertEquals(typeof receivedHelpers, "object");711 assertEquals(typeof receivedHelpers, "object");
722});712});
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
748test("BlockingMutation - notifies error on mutation failure", async () => {714test("BlockingMutation - notifies error on mutation failure", async () => {
749 const { client } = createTestClient();715 const { client } = createTestClient();
750 const tracker = createEventTracker<string>();716 const tracker = createEventTracker<string>();
...@@ -757,13 +723,12 @@ test("BlockingMutation - notifies error on mutation failure", async () => {...@@ -757,13 +723,12 @@ test("BlockingMutation - notifies error on mutation failure", async () => {
757 describe: "failing mutation",723 describe: "failing mutation",
758 describeResult: "Success",724 describeResult: "Success",
759 optimistic() {},725 optimistic() {},
760 async refetch() {},
761 });726 });
762727
763 const key = mutation.key(["test"]);728 const key = mutation.key(["test"]);
764 mutation.subscribe(key, tracker.callback);729 mutation.subscribe(key, tracker.callback);
765730
766 await assertRejects(() => mutation.runAndReturn("test"));731 await assertRejects(() => mutation.runAsPromise("test"));
767732
768 // Should have error event733 // Should have error event
769 const errorEvents = tracker.events.filter((e) =>734 const errorEvents = tracker.events.filter((e) =>
...@@ -786,7 +751,7 @@ test("BlockingMutation - multiple subscribers receive events", async () => {...@@ -786,7 +751,7 @@ test("BlockingMutation - multiple subscribers receive events", async () => {
786 describe: "test mutation",751 describe: "test mutation",
787 describeResult: "Success",752 describeResult: "Success",
788 optimistic() {},753 optimistic() {},
789 async refetch() {},754
790 refetchOnSuccess: false,755 refetchOnSuccess: false,
791 });756 });
792757
...@@ -794,7 +759,7 @@ test("BlockingMutation - multiple subscribers receive events", async () => {...@@ -794,7 +759,7 @@ test("BlockingMutation - multiple subscribers receive events", async () => {
794 mutation.subscribe(key, tracker1.callback);759 mutation.subscribe(key, tracker1.callback);
795 mutation.subscribe(key, tracker2.callback);760 mutation.subscribe(key, tracker2.callback);
796761
797 await mutation.runAndReturn("test");762 await mutation.runAsPromise("test");
798 await delay(10);763 await delay(10);
799764
800 // Both subscribers should receive events765 // Both subscribers should receive events
...@@ -817,11 +782,11 @@ test("BlockingMutation - onSuccess is called before mutation resolves", async ()...@@ -817,11 +782,11 @@ test("BlockingMutation - onSuccess is called before mutation resolves", async ()
817 callOrder.push("onSuccess");782 callOrder.push("onSuccess");
818 });783 });
819 },784 },
820 async refetch() {},785
821 refetchOnSuccess: false,786 refetchOnSuccess: false,
822 });787 });
823788
824 const promise = mutation.runAndReturn("test");789 const promise = mutation.runAsPromise("test");
825 promise.then(() => {790 promise.then(() => {
826 callOrder.push("then");791 callOrder.push("then");
827 });792 });
...@@ -845,15 +810,12 @@ test("BlockingMutation - result is passed to notification on success", async ()...@@ -845,15 +810,12 @@ test("BlockingMutation - result is passed to notification on success", async ()
845 describe: "test mutation",810 describe: "test mutation",
846 describeResult: "Success",811 describeResult: "Success",
847 optimistic() {},812 optimistic() {},
848 async refetch() {
849 await delay(5);
850 },
851 });813 });
852814
853 const key = mutation.key(["test"]);815 const key = mutation.key(["test"]);
854 mutation.subscribe(key, tracker.callback);816 mutation.subscribe(key, tracker.callback);
855817
856 await mutation.runAndReturn("test");818 await mutation.runAsPromise("test");
857 await delay(20);819 await delay(20);
858820
859 // Should have refetching event with result821 // Should have refetching event with result
...@@ -876,16 +838,16 @@ test("BlockingMutation - channel is reused for same key", async () => {...@@ -876,16 +838,16 @@ test("BlockingMutation - channel is reused for same key", async () => {
876 describe: "test mutation",838 describe: "test mutation",
877 describeResult: "Success",839 describeResult: "Success",
878 optimistic() {},840 optimistic() {},
879 async refetch() {},841
880 refetchOnSuccess: false,842 refetchOnSuccess: false,
881 });843 });
882844
883 // First mutation845 // First mutation
884 await mutation.runAndReturn("first");846 await mutation.runAsPromise("first");
885 await delay(5);847 await delay(5);
886848
887 // Second mutation with same key849 // Second mutation with same key
888 await mutation.runAndReturn("second");850 await mutation.runAsPromise("second");
889 await delay(5);851 await delay(5);
890852
891 assertEquals(events, ["mutate-first", "mutate-second"]);853 assertEquals(events, ["mutate-first", "mutate-second"]);
...@@ -902,7 +864,7 @@ test("BlockingMutation - empty queue after all mutations complete", async () =>...@@ -902,7 +864,7 @@ test("BlockingMutation - empty queue after all mutations complete", async () =>
902 describe: "test mutation",864 describe: "test mutation",
903 describeResult: "Success",865 describeResult: "Success",
904 optimistic() {},866 optimistic() {},
905 async refetch() {},867
906 refetchOnSuccess: false,868 refetchOnSuccess: false,
907 key() {869 key() {
908 return "test-key";870 return "test-key";
...@@ -910,15 +872,15 @@ test("BlockingMutation - empty queue after all mutations complete", async () =>...@@ -910,15 +872,15 @@ test("BlockingMutation - empty queue after all mutations complete", async () =>
910 });872 });
911873
912 // Run multiple mutations874 // Run multiple mutations
913 await mutation.runAndReturn("1");875 await mutation.runAsPromise("1");
914 await mutation.runAndReturn("2");876 await mutation.runAsPromise("2");
915 await mutation.runAndReturn("3");877 await mutation.runAsPromise("3");
916 await delay(10);878 await delay(10);
917879
918 // All mutations should have completed880 // All mutations should have completed
919 // (We can't directly check the queue, but we can verify by running another mutation)881 // (We can't directly check the queue, but we can verify by running another mutation)
920 const start = Date.now();882 const start = Date.now();
921 await mutation.runAndReturn("4");883 await mutation.runAsPromise("4");
922 const duration = Date.now() - start;884 const duration = Date.now() - start;
923885
924 // Should execute immediately, not be queued (< 10ms if not queued)886 // Should execute immediately, not be queued (< 10ms if not queued)
...@@ -940,11 +902,11 @@ test("BlockingMutation - multiple onSuccess callbacks are all called", async ()...@@ -940,11 +902,11 @@ test("BlockingMutation - multiple onSuccess callbacks are all called", async ()
940 onSuccess((result) => results.push(`second-${result}`));902 onSuccess((result) => results.push(`second-${result}`));
941 onSuccess((result) => results.push(`third-${result}`));903 onSuccess((result) => results.push(`third-${result}`));
942 },904 },
943 async refetch() {},905
944 refetchOnSuccess: false,906 refetchOnSuccess: false,
945 });907 });
946908
947 await mutation.runAndReturn("test");909 await mutation.runAsPromise("test");
948910
949 assertEquals(results, ["first-test", "second-test", "third-test"]);911 assertEquals(results, ["first-test", "second-test", "third-test"]);
950});912});
...@@ -959,14 +921,15 @@ test("BlockingMutation - refetchOnSuccess false skips refetch", async () => {...@@ -959,14 +921,15 @@ test("BlockingMutation - refetchOnSuccess false skips refetch", async () => {
959 },921 },
960 describe: "test mutation",922 describe: "test mutation",
961 describeResult: "Success",923 describeResult: "Success",
962 optimistic() {},924 optimistic({ onRefetch }) {
963 async refetch() {925 onRefetch(async () => {
964 refetchCalled = true;926 refetchCalled = true;
927 });
965 },928 },
966 refetchOnSuccess: false,929 refetchOnSuccess: false,
967 });930 });
968931
969 await mutation.runAndReturn("test");932 await mutation.runAsPromise("test");
970 await delay(10);933 await delay(10);
971934
972 // Refetch should not have been called935 // Refetch should not have been called
...@@ -982,14 +945,15 @@ test("BlockingMutation - refetch error after mutation failure is reported", asyn...@@ -982,14 +945,15 @@ test("BlockingMutation - refetch error after mutation failure is reported", asyn
982 },945 },
983 describe: "failing mutation",946 describe: "failing mutation",
984 describeResult: "Success",947 describeResult: "Success",
985 optimistic() {},948 optimistic({ onRefetch }) {
986 async refetch() {949 onRefetch(async () => {
987 throw new Error("refetch also failed");950 throw new Error("refetch also failed");
951 });
988 },952 },
989 });953 });
990954
991 await assertRejects(955 await assertRejects(
992 () => mutation.runAndReturn("test"),956 () => mutation.runAsPromise("test"),
993 Error,957 Error,
994 "mutation failed",958 "mutation failed",
995 );959 );
...@@ -1026,11 +990,11 @@ test("BlockingMutation - debounce: basic debounced execution", async () => {...@@ -1026,11 +990,11 @@ test("BlockingMutation - debounce: basic debounced execution", async () => {
1026 const [key, value] = args;990 const [key, value] = args;
1027 helpers.setValue(key, value);991 helpers.setValue(key, value);
1028 },992 },
1029 async refetch() {},993
1030 debounceMs: 50,994 debounceMs: 50,
1031 });995 });
1032996
1033 const promise = mutation.runAndReturn("key1", "value1");997 const promise = mutation.runAsPromise("key1", "value1");
1034998
1035 // Optimistic update should be applied immediately999 // Optimistic update should be applied immediately
1036 assertEquals(testStore.get("key1"), "value1");1000 assertEquals(testStore.get("key1"), "value1");
...@@ -1063,14 +1027,14 @@ test("BlockingMutation - debounce: last call wins with multiple rapid calls", as...@@ -1063,14 +1027,14 @@ test("BlockingMutation - debounce: last call wins with multiple rapid calls", as
1063 const [key, value] = args;1027 const [key, value] = args;
1064 helpers.setValue(key, value);1028 helpers.setValue(key, value);
1065 },1029 },
1066 async refetch() {},1030
1067 debounceMs: 50,1031 debounceMs: 50,
1068 });1032 });
10691033
1070 // Make three rapid calls1034 // Make three rapid calls
1071 const promise1 = mutation.runAndReturn("key1", "a");1035 const promise1 = mutation.runAsPromise("key1", "a");
1072 const promise2 = mutation.runAndReturn("key1", "b");1036 const promise2 = mutation.runAsPromise("key1", "b");
1073 const promise3 = mutation.runAndReturn("key1", "c");1037 const promise3 = mutation.runAsPromise("key1", "c");
10741038
1075 // Last optimistic update should be applied1039 // Last optimistic update should be applied
1076 assertEquals(testStore.get("key1"), "c");1040 assertEquals(testStore.get("key1"), "c");
...@@ -1109,17 +1073,17 @@ test("BlockingMutation - debounce: optimistic rollback and reapply", async () =>...@@ -1109,17 +1073,17 @@ test("BlockingMutation - debounce: optimistic rollback and reapply", async () =>
1109 // Add a second value to test multiple rollbacks1073 // Add a second value to test multiple rollbacks
1110 helpers.setValue(`${key}-2`, `${value}-2`);1074 helpers.setValue(`${key}-2`, `${value}-2`);
1111 },1075 },
1112 async refetch() {},1076
1113 debounceMs: 50,1077 debounceMs: 50,
1114 });1078 });
11151079
1116 // First call sets two values1080 // First call sets two values
1117 mutation.runAndReturn("key1", "a");1081 mutation.runAsPromise("key1", "a");
1118 assertEquals(testStore.get("key1"), "a");1082 assertEquals(testStore.get("key1"), "a");
1119 assertEquals(testStore.get("key1-2"), "a-2");1083 assertEquals(testStore.get("key1-2"), "a-2");
11201084
1121 // Second call should rollback first call's optimistic and apply its own1085 // 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");
1123 assertEquals(testStore.get("key1"), "b");1087 assertEquals(testStore.get("key1"), "b");
1124 assertEquals(testStore.get("key1-2"), "b-2");1088 assertEquals(testStore.get("key1-2"), "b-2");
11251089
...@@ -1144,16 +1108,16 @@ test("BlockingMutation - debounce: timer reset behavior", async () => {...@@ -1144,16 +1108,16 @@ test("BlockingMutation - debounce: timer reset behavior", async () => {
1144 describe: "debounced mutation",1108 describe: "debounced mutation",
1145 describeResult: "Success",1109 describeResult: "Success",
1146 optimistic() {},1110 optimistic() {},
1147 async refetch() {},1111
1148 debounceMs: 100,1112 debounceMs: 100,
1149 });1113 });
11501114
1151 // Call at t=01115 // Call at t=0
1152 const promise1 = mutation.runAndReturn("first");1116 const promise1 = mutation.runAsPromise("first");
11531117
1154 // Call at t=50 (should reset timer)1118 // Call at t=50 (should reset timer)
1155 await delay(50);1119 await delay(50);
1156 const promise2 = mutation.runAndReturn("second");1120 const promise2 = mutation.runAsPromise("second");
11571121
1158 // At t=100, mutation should NOT have executed yet1122 // At t=100, mutation should NOT have executed yet
1159 await delay(50);1123 await delay(50);
...@@ -1180,18 +1144,18 @@ test("BlockingMutation - debounce: integration with blocking queue", async () =>...@@ -1180,18 +1144,18 @@ test("BlockingMutation - debounce: integration with blocking queue", async () =>
1180 describe: "debounced mutation",1144 describe: "debounced mutation",
1181 describeResult: "Success",1145 describeResult: "Success",
1182 optimistic() {},1146 optimistic() {},
1183 async refetch() {},1147
1184 debounceMs: 30,1148 debounceMs: 30,
1185 key: () => "shared",1149 key: () => "shared",
1186 });1150 });
11871151
1188 // Start a debounced call that will enter queue first1152 // Start a debounced call that will enter queue first
1189 const promise1 = mutation.runAndReturn("first");1153 const promise1 = mutation.runAsPromise("first");
11901154
1191 // While it's waiting in debounce, fire more debounced calls1155 // While it's waiting in debounce, fire more debounced calls
1192 await delay(10);1156 await delay(10);
1193 const promise2 = mutation.runAndReturn("second");1157 const promise2 = mutation.runAsPromise("second");
1194 const promise3 = mutation.runAndReturn("third");1158 const promise3 = mutation.runAsPromise("third");
11951159
1196 // Wait for all to complete1160 // Wait for all to complete
1197 await Promise.all([promise1, promise2, promise3]);1161 await Promise.all([promise1, promise2, promise3]);
...@@ -1220,13 +1184,13 @@ test("BlockingMutation - debounce: error during optimistic update", async () =>...@@ -1220,13 +1184,13 @@ test("BlockingMutation - debounce: error during optimistic update", async () =>
1220 }1184 }
1221 helpers.setValue("key", value);1185 helpers.setValue("key", value);
1222 },1186 },
1223 async refetch() {},1187
1224 debounceMs: 50,1188 debounceMs: 50,
1225 });1189 });
12261190
1227 // Call that throws during optimistic1191 // Call that throws during optimistic
1228 await assertRejects(1192 await assertRejects(
1229 () => mutation.runAndReturn("error"),1193 () => mutation.runAsPromise("error"),
1230 Error,1194 Error,
1231 "optimistic error",1195 "optimistic error",
1232 );1196 );
...@@ -1235,7 +1199,7 @@ test("BlockingMutation - debounce: error during optimistic update", async () =>...@@ -1235,7 +1199,7 @@ test("BlockingMutation - debounce: error during optimistic update", async () =>
1235 assertEquals(testStore.has("key"), false);1199 assertEquals(testStore.has("key"), false);
12361200
1237 // Subsequent successful call should work1201 // Subsequent successful call should work
1238 const promise = mutation.runAndReturn("good");1202 const promise = mutation.runAsPromise("good");
1239 assertEquals(testStore.get("key"), "good");1203 assertEquals(testStore.get("key"), "good");
1240 await promise;1204 await promise;
1241});1205});
...@@ -1251,10 +1215,12 @@ test("BlockingMutation - debounce: status transitions", async () => {...@@ -1251,10 +1215,12 @@ test("BlockingMutation - debounce: status transitions", async () => {
1251 },1215 },
1252 describe: "debounced mutation",1216 describe: "debounced mutation",
1253 describeResult: "Success",1217 describeResult: "Success",
1254 optimistic() {},1218 optimistic({ onRefetch }) {
1255 async refetch() {1219 onRefetch(async () => {
1256 await delay(10);1220 await delay(10);
1221 });
1257 },1222 },
1223
1258 debounceMs: 50,1224 debounceMs: 50,
1259 });1225 });
12601226
...@@ -1262,7 +1228,7 @@ test("BlockingMutation - debounce: status transitions", async () => {...@@ -1262,7 +1228,7 @@ test("BlockingMutation - debounce: status transitions", async () => {
1262 const unsubscribe = mutation.subscribe(key, callback);1228 const unsubscribe = mutation.subscribe(key, callback);
12631229
1264 // First call should transition to waiting1230 // First call should transition to waiting
1265 mutation.runAndReturn("test");1231 mutation.runAsPromise("test");
1266 await delay(10);1232 await delay(10);
1267 assertEquals(events[events.length - 1].status, "waiting");1233 assertEquals(events[events.length - 1].status, "waiting");
12681234
...@@ -1291,19 +1257,20 @@ test("BlockingMutation - debounce: debounced call executes after queue error", a...@@ -1291,19 +1257,20 @@ test("BlockingMutation - debounce: debounced call executes after queue error", a
1291 },1257 },
1292 describe: "debounced mutation",1258 describe: "debounced mutation",
1293 describeResult: "Success",1259 describeResult: "Success",
1294 optimistic() {},1260 optimistic({ onRefetch }) {
1295 async refetch() {1261 onRefetch(async () => {
1296 await delay(10);1262 await delay(10);
1263 });
1297 },1264 },
1298 debounceMs: 50,1265 debounceMs: 50,
1299 key: () => "shared",1266 key: () => "shared",
1300 });1267 });
13011268
1302 // Start a call that will fail (enters debounce)1269 // Start a call that will fail (enters debounce)
1303 const promise1 = mutation.runAndReturn("fail");1270 const promise1 = mutation.runAsPromise("fail");
13041271
1305 // Immediately override with a successful call (last call wins)1272 // Immediately override with a successful call (last call wins)
1306 const promise2 = mutation.runAndReturn("success");1273 const promise2 = mutation.runAsPromise("success");
13071274
1308 // Both promises should resolve with the same successful result1275 // Both promises should resolve with the same successful result
1309 // (because debouncing causes "last call wins")1276 // (because debouncing causes "last call wins")
...@@ -1327,20 +1294,20 @@ test("BlockingMutation - debounce: all promises resolve together", async () => {...@@ -1327,20 +1294,20 @@ test("BlockingMutation - debounce: all promises resolve together", async () => {
1327 describe: "debounced mutation",1294 describe: "debounced mutation",
1328 describeResult: "Success",1295 describeResult: "Success",
1329 optimistic() {},1296 optimistic() {},
1330 async refetch() {},1297
1331 debounceMs: 50,1298 debounceMs: 50,
1332 });1299 });
13331300
1334 // Create three rapid calls1301 // Create three rapid calls
1335 const promise1 = mutation.runAndReturn("id", "a").then((result) => {1302 const promise1 = mutation.runAsPromise("id", "a").then((result) => {
1336 resolvedAt.push(Date.now());1303 resolvedAt.push(Date.now());
1337 return result;1304 return result;
1338 });1305 });
1339 const promise2 = mutation.runAndReturn("id", "b").then((result) => {1306 const promise2 = mutation.runAsPromise("id", "b").then((result) => {
1340 resolvedAt.push(Date.now());1307 resolvedAt.push(Date.now());
1341 return result;1308 return result;
1342 });1309 });
1343 const promise3 = mutation.runAndReturn("id", "c").then((result) => {1310 const promise3 = mutation.runAsPromise("id", "c").then((result) => {
1344 resolvedAt.push(Date.now());1311 resolvedAt.push(Date.now());
1345 return result;1312 return result;
1346 });1313 });
...@@ -1367,7 +1334,7 @@ test("BlockingMutation - debounce: cleanup on channel deletion", async () => {...@@ -1367,7 +1334,7 @@ test("BlockingMutation - debounce: cleanup on channel deletion", async () => {
1367 describe: "debounced mutation",1334 describe: "debounced mutation",
1368 describeResult: "Success",1335 describeResult: "Success",
1369 optimistic() {},1336 optimistic() {},
1370 async refetch() {},1337
1371 debounceMs: 100,1338 debounceMs: 100,
1372 });1339 });
13731340
...@@ -1377,7 +1344,7 @@ test("BlockingMutation - debounce: cleanup on channel deletion", async () => {...@@ -1377,7 +1344,7 @@ test("BlockingMutation - debounce: cleanup on channel deletion", async () => {
1377 const unsubscribe = mutation.subscribe(key, () => {});1344 const unsubscribe = mutation.subscribe(key, () => {});
13781345
1379 // Start a debounced call1346 // Start a debounced call
1380 mutation.runAndReturn("test");1347 mutation.runAsPromise("test");
1381 await delay(10);1348 await delay(10);
13821349
1383 // Unsubscribe while debounce is pending1350 // Unsubscribe while debounce is pending
...@@ -1403,16 +1370,16 @@ test("BlockingMutation - debounce: multiple keys debounce independently", async...@@ -1403,16 +1370,16 @@ test("BlockingMutation - debounce: multiple keys debounce independently", async
1403 describe: "debounced mutation",1370 describe: "debounced mutation",
1404 describeResult: "Success",1371 describeResult: "Success",
1405 optimistic() {},1372 optimistic() {},
1406 async refetch() {},1373
1407 debounceMs: 50,1374 debounceMs: 50,
1408 key: ({ args }) => args[0],1375 key: ({ args }) => args[0],
1409 });1376 });
14101377
1411 // Rapid calls to different keys1378 // Rapid calls to different keys
1412 const promise1a = mutation.runAndReturn("key1");1379 const promise1a = mutation.runAsPromise("key1");
1413 const promise1b = mutation.runAndReturn("key1");1380 const promise1b = mutation.runAsPromise("key1");
1414 const promise2a = mutation.runAndReturn("key2");1381 const promise2a = mutation.runAsPromise("key2");
1415 const promise2b = mutation.runAndReturn("key2");1382 const promise2b = mutation.runAsPromise("key2");
14161383
1417 await Promise.all([promise1a, promise1b, promise2a, promise2b]);1384 await Promise.all([promise1a, promise1b, promise2a, promise2b]);
14181385
...@@ -1437,15 +1404,15 @@ test("BlockingMutation - debounce: onSuccess callbacks from last call only", asy...@@ -1437,15 +1404,15 @@ test("BlockingMutation - debounce: onSuccess callbacks from last call only", asy
1437 successResults.push(`${value}->${result}`);1404 successResults.push(`${value}->${result}`);
1438 });1405 });
1439 },1406 },
1440 async refetch() {},1407
1441 debounceMs: 50,1408 debounceMs: 50,
1442 });1409 });
14431410
1444 // Make three rapid calls with different onSuccess callbacks1411 // Make three rapid calls with different onSuccess callbacks
1445 await Promise.all([1412 await Promise.all([
1446 mutation.runAndReturn("a"),1413 mutation.runAsPromise("a"),
1447 mutation.runAndReturn("b"),1414 mutation.runAsPromise("b"),
1448 mutation.runAndReturn("c"),1415 mutation.runAsPromise("c"),
1449 ]);1416 ]);
14501417
1451 await delay(20);1418 await delay(20);
test/debounced.test.ts+39-64
...@@ -89,7 +89,7 @@ test("DebouncedMutation - basic mutation success with debounce", async () => {...@@ -89,7 +89,7 @@ test("DebouncedMutation - basic mutation success with debounce", async () => {
89 },89 },
90 });90 });
9191
92 const result = await mutation.runAndReturn(5);92 const result = await mutation.runAsPromise(5);
93 await delay(20); // Wait for refetch93 await delay(20); // Wait for refetch
9494
95 assertEquals(result, 5);95 assertEquals(result, 5);
...@@ -116,7 +116,6 @@ test("DebouncedMutation - run() catches errors", async () => {...@@ -116,7 +116,6 @@ test("DebouncedMutation - run() catches errors", async () => {
116 },116 },
117 describe: "failing mutation",117 describe: "failing mutation",
118 describeResult: "Success",118 describeResult: "Success",
119 async refetch() {},
120 });119 });
121120
122 mutation.run(5);121 mutation.run(5);
...@@ -144,11 +143,10 @@ test("DebouncedMutation - runAndReturn() rejects on error", async () => {...@@ -144,11 +143,10 @@ test("DebouncedMutation - runAndReturn() rejects on error", async () => {
144 },143 },
145 describe: "failing mutation",144 describe: "failing mutation",
146 describeResult: "Success",145 describeResult: "Success",
147 async refetch() {},
148 });146 });
149147
150 await assertRejects(148 await assertRejects(
151 () => mutation.runAndReturn(5),149 () => mutation.runAsPromise(5),
152 Error,150 Error,
153 "commit failed",151 "commit failed",
154 );152 );
...@@ -181,13 +179,12 @@ test("DebouncedMutation - debounce batches rapid calls", async () => {...@@ -181,13 +179,12 @@ test("DebouncedMutation - debounce batches rapid calls", async () => {
181 },179 },
182 describe: "increment counter",180 describe: "increment counter",
183 describeResult: "Success",181 describeResult: "Success",
184 async refetch() {},
185 });182 });
186183
187 // Rapid calls within debounce window184 // Rapid calls within debounce window
188 const promise1 = mutation.runAndReturn(1);185 const promise1 = mutation.runAsPromise(1);
189 const promise2 = mutation.runAndReturn(2);186 const promise2 = mutation.runAsPromise(2);
190 const promise3 = mutation.runAndReturn(3);187 const promise3 = mutation.runAsPromise(3);
191188
192 // Optimistic updates should be applied immediately189 // Optimistic updates should be applied immediately
193 assertEquals(testStore.get("counter"), 6);190 assertEquals(testStore.get("counter"), 6);
...@@ -223,17 +220,16 @@ test("DebouncedMutation - debounce resets timer on each call", async () => {...@@ -223,17 +220,16 @@ test("DebouncedMutation - debounce resets timer on each call", async () => {
223 },220 },
224 describe: "increment counter",221 describe: "increment counter",
225 describeResult: "Success",222 describeResult: "Success",
226 async refetch() {},
227 });223 });
228224
229 // First call225 // First call
230 const promise1 = mutation.runAndReturn(1);226 const promise1 = mutation.runAsPromise(1);
231227
232 // Wait less than debounce time228 // Wait less than debounce time
233 await delay(15);229 await delay(15);
234230
235 // Second call should reset the timer231 // Second call should reset the timer
236 const promise2 = mutation.runAndReturn(2);232 const promise2 = mutation.runAsPromise(2);
237233
238 // Wait less than debounce time again234 // Wait less than debounce time again
239 await delay(15);235 await delay(15);
...@@ -242,7 +238,7 @@ test("DebouncedMutation - debounce resets timer on each call", async () => {...@@ -242,7 +238,7 @@ test("DebouncedMutation - debounce resets timer on each call", async () => {
242 assertEquals(commitCallCount, 0);238 assertEquals(commitCallCount, 0);
243239
244 // Third call240 // Third call
245 const promise3 = mutation.runAndReturn(3);241 const promise3 = mutation.runAsPromise(3);
246242
247 // Wait for all to complete243 // Wait for all to complete
248 await Promise.all([promise1, promise2, promise3]);244 await Promise.all([promise1, promise2, promise3]);
...@@ -274,15 +270,14 @@ test("DebouncedMutation - debounce separates batches after timeout", async () =>...@@ -274,15 +270,14 @@ test("DebouncedMutation - debounce separates batches after timeout", async () =>
274 },270 },
275 describe: "increment counter",271 describe: "increment counter",
276 describeResult: "Success",272 describeResult: "Success",
277 async refetch() {},
278 });273 });
279274
280 // First batch275 // First batch
281 await mutation.runAndReturn(1);276 await mutation.runAsPromise(1);
282 await delay(50); // Wait for first batch to complete277 await delay(50); // Wait for first batch to complete
283278
284 // Second batch (after timeout)279 // Second batch (after timeout)
285 await mutation.runAndReturn(2);280 await mutation.runAsPromise(2);
286 await delay(50);281 await delay(50);
287282
288 // Two separate commits283 // Two separate commits
...@@ -319,10 +314,9 @@ test("DebouncedMutation - throttle commits immediately on first call", async ()...@@ -319,10 +314,9 @@ test("DebouncedMutation - throttle commits immediately on first call", async ()
319 },314 },
320 describe: "increment counter",315 describe: "increment counter",
321 describeResult: "Success",316 describeResult: "Success",
322 async refetch() {},
323 });317 });
324318
325 await mutation.runAndReturn(5);319 await mutation.runAsPromise(5);
326320
327 // First call should commit immediately (within a small tolerance)321 // First call should commit immediately (within a small tolerance)
328 assertEquals(commitTime < 20, true);322 assertEquals(commitTime < 20, true);
...@@ -352,19 +346,18 @@ test("DebouncedMutation - throttle batches calls within time window", async () =...@@ -352,19 +346,18 @@ test("DebouncedMutation - throttle batches calls within time window", async () =
352 },346 },
353 describe: "increment counter",347 describe: "increment counter",
354 describeResult: "Success",348 describeResult: "Success",
355 async refetch() {},
356 });349 });
357350
358 // First call commits immediately351 // First call commits immediately
359 const promise1 = mutation.runAndReturn(1);352 const promise1 = mutation.runAsPromise(1);
360 await delay(5);353 await delay(5);
361354
362 // Second call within throttle window - should batch355 // Second call within throttle window - should batch
363 const promise2 = mutation.runAndReturn(2);356 const promise2 = mutation.runAsPromise(2);
364 await delay(5);357 await delay(5);
365358
366 // Third call within throttle window - should batch with second359 // Third call within throttle window - should batch with second
367 const promise3 = mutation.runAndReturn(3);360 const promise3 = mutation.runAsPromise(3);
368361
369 // Wait for first to complete362 // Wait for first to complete
370 await promise1;363 await promise1;
...@@ -403,11 +396,10 @@ test("DebouncedMutation - throttle allows new batch after time window", async ()...@@ -403,11 +396,10 @@ test("DebouncedMutation - throttle allows new batch after time window", async ()
403 },396 },
404 describe: "increment counter",397 describe: "increment counter",
405 describeResult: "Success",398 describeResult: "Success",
406 async refetch() {},
407 });399 });
408400
409 // First call401 // First call
410 await mutation.runAndReturn(1);402 await mutation.runAsPromise(1);
411 await delay(10);403 await delay(10);
412404
413 assertEquals(commitCallCount, 1);405 assertEquals(commitCallCount, 1);
...@@ -416,7 +408,7 @@ test("DebouncedMutation - throttle allows new batch after time window", async ()...@@ -416,7 +408,7 @@ test("DebouncedMutation - throttle allows new batch after time window", async ()
416 await delay(60);408 await delay(60);
417409
418 // Second call should commit immediately410 // Second call should commit immediately
419 await mutation.runAndReturn(2);411 await mutation.runAsPromise(2);
420 await delay(10);412 await delay(10);
421413
422 assertEquals(commitCallCount, 2);414 assertEquals(commitCallCount, 2);
...@@ -447,12 +439,11 @@ test("DebouncedMutation - skips commit when value unchanged", async () => {...@@ -447,12 +439,11 @@ test("DebouncedMutation - skips commit when value unchanged", async () => {
447 },439 },
448 describe: "increment counter",440 describe: "increment counter",
449 describeResult: "Success",441 describeResult: "Success",
450 async refetch() {},
451 });442 });
452443
453 // +5 and -5 cancel out444 // +5 and -5 cancel out
454 const promise1 = mutation.runAndReturn(5);445 const promise1 = mutation.runAsPromise(5);
455 const promise2 = mutation.runAndReturn(-5);446 const promise2 = mutation.runAsPromise(-5);
456447
457 const [result1, result2] = await Promise.all([promise1, promise2]);448 const [result1, result2] = await Promise.all([promise1, promise2]);
458449
...@@ -507,11 +498,10 @@ test("DebouncedMutation - uses deepEquals for comparison", async () => {...@@ -507,11 +498,10 @@ test("DebouncedMutation - uses deepEquals for comparison", async () => {
507 },498 },
508 describe: "set count",499 describe: "set count",
509 describeResult: "Success",500 describeResult: "Success",
510 async refetch() {},
511 });501 });
512502
513 // Set to same value (different object reference but same content)503 // Set to same value (different object reference but same content)
514 await mutation.runAndReturn(0);504 await mutation.runAsPromise(0);
515 await delay(30);505 await delay(30);
516506
517 // Should skip commit because value is deeply equal507 // Should skip commit because value is deeply equal
...@@ -559,10 +549,9 @@ test("DebouncedMutation - custom deepEquals function", async () => {...@@ -559,10 +549,9 @@ test("DebouncedMutation - custom deepEquals function", async () => {
559 },549 },
560 describe: "failing mutation",550 describe: "failing mutation",
561 describeResult: "Success",551 describeResult: "Success",
562 async refetch() {},
563 });552 });
564553
565 await mutation.runAndReturn(5).catch(() => {554 await mutation.runAsPromise(5).catch(() => {
566 // Expected to fail due to commit error555 // Expected to fail due to commit error
567 });556 });
568 await delay(30);557 await delay(30);
...@@ -593,11 +582,10 @@ test("DebouncedMutation - rollback on commit error", async () => {...@@ -593,11 +582,10 @@ test("DebouncedMutation - rollback on commit error", async () => {
593 },582 },
594 describe: "failing mutation",583 describe: "failing mutation",
595 describeResult: "Success",584 describeResult: "Success",
596 async refetch() {},
597 });585 });
598586
599 // Optimistic update applied587 // Optimistic update applied
600 const promise = mutation.runAndReturn(5);588 const promise = mutation.runAsPromise(5);
601 assertEquals(testStore.get("counter"), 15);589 assertEquals(testStore.get("counter"), 15);
602590
603 await assertRejects(() => promise, Error, "commit failed");591 await assertRejects(() => promise, Error, "commit failed");
...@@ -626,13 +614,12 @@ test("DebouncedMutation - error event includes error details", async () => {...@@ -626,13 +614,12 @@ test("DebouncedMutation - error event includes error details", async () => {
626 },614 },
627 describe: "failing mutation",615 describe: "failing mutation",
628 describeResult: "Success",616 describeResult: "Success",
629 async refetch() {},
630 });617 });
631618
632 const key = mutation.key([5]);619 const key = mutation.key([5]);
633 mutation.subscribe(key, tracker.callback);620 mutation.subscribe(key, tracker.callback);
634621
635 await assertRejects(() => mutation.runAndReturn(5));622 await assertRejects(() => mutation.runAsPromise(5));
636 await delay(30);623 await delay(30);
637624
638 // Should have error in events625 // Should have error in events
...@@ -660,7 +647,6 @@ test("DebouncedMutation - key() returns JSON stringified key", () => {...@@ -660,7 +647,6 @@ test("DebouncedMutation - key() returns JSON stringified key", () => {
660 },647 },
661 describe: "test mutation",648 describe: "test mutation",
662 describeResult: "Success",649 describeResult: "Success",
663 async refetch() {},
664 });650 });
665651
666 assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id"));652 assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id"));
...@@ -681,7 +667,6 @@ test("DebouncedMutation - key() can return array", () => {...@@ -681,7 +667,6 @@ test("DebouncedMutation - key() can return array", () => {
681 },667 },
682 describe: "test mutation",668 describe: "test mutation",
683 describeResult: "Success",669 describeResult: "Success",
684 async refetch() {},
685 });670 });
686671
687 assertEquals(672 assertEquals(
...@@ -712,12 +697,11 @@ test("DebouncedMutation - different keys create separate batches", async () => {...@@ -712,12 +697,11 @@ test("DebouncedMutation - different keys create separate batches", async () => {
712 },697 },
713 describe: "test mutation",698 describe: "test mutation",
714 describeResult: "Success",699 describeResult: "Success",
715 async refetch() {},
716 });700 });
717701
718 // Two different keys702 // Two different keys
719 const promise1 = mutation.runAndReturn("a", 5);703 const promise1 = mutation.runAsPromise("a", 5);
720 const promise2 = mutation.runAndReturn("b", 10);704 const promise2 = mutation.runAsPromise("b", 10);
721705
722 await Promise.all([promise1, promise2]);706 await Promise.all([promise1, promise2]);
723 await delay(30);707 await delay(30);
...@@ -747,7 +731,6 @@ test("DebouncedMutation - describe() with string", () => {...@@ -747,7 +731,6 @@ test("DebouncedMutation - describe() with string", () => {
747 },731 },
748 describe: "update counter",732 describe: "update counter",
749 describeResult: "Success",733 describeResult: "Success",
750 async refetch() {},
751 });734 });
752735
753 assertEquals(mutation.describe(5), "update counter");736 assertEquals(mutation.describe(5), "update counter");
...@@ -768,7 +751,6 @@ test("DebouncedMutation - describe() with function", () => {...@@ -768,7 +751,6 @@ test("DebouncedMutation - describe() with function", () => {
768 },751 },
769 describe: ({ args }) => `increment by ${args[0]}`,752 describe: ({ args }) => `increment by ${args[0]}`,
770 describeResult: "Success",753 describeResult: "Success",
771 async refetch() {},
772 });754 });
773755
774 assertEquals(mutation.describe(5), "increment by 5");756 assertEquals(mutation.describe(5), "increment by 5");
...@@ -796,12 +778,11 @@ test("DebouncedMutation - all pending promises resolve with same result", async...@@ -796,12 +778,11 @@ test("DebouncedMutation - all pending promises resolve with same result", async
796 },778 },
797 describe: "increment counter",779 describe: "increment counter",
798 describeResult: "Success",780 describeResult: "Success",
799 async refetch() {},
800 });781 });
801782
802 const promise1 = mutation.runAndReturn(1);783 const promise1 = mutation.runAsPromise(1);
803 const promise2 = mutation.runAndReturn(2);784 const promise2 = mutation.runAsPromise(2);
804 const promise3 = mutation.runAndReturn(3);785 const promise3 = mutation.runAsPromise(3);
805786
806 const [result1, result2, result3] = await Promise.all([787 const [result1, result2, result3] = await Promise.all([
807 promise1,788 promise1,
...@@ -833,12 +814,11 @@ test("DebouncedMutation - all pending promises reject with same error", async ()...@@ -833,12 +814,11 @@ test("DebouncedMutation - all pending promises reject with same error", async ()
833 },814 },
834 describe: "increment counter",815 describe: "increment counter",
835 describeResult: "Success",816 describeResult: "Success",
836 async refetch() {},
837 });817 });
838818
839 const promise1 = mutation.runAndReturn(1);819 const promise1 = mutation.runAsPromise(1);
840 const promise2 = mutation.runAndReturn(2);820 const promise2 = mutation.runAsPromise(2);
841 const promise3 = mutation.runAndReturn(3);821 const promise3 = mutation.runAsPromise(3);
842822
843 const errors: Error[] = [];823 const errors: Error[] = [];
844 await Promise.all([824 await Promise.all([
...@@ -880,7 +860,7 @@ test("DebouncedMutation - handles empty getValue result", async () => {...@@ -880,7 +860,7 @@ test("DebouncedMutation - handles empty getValue result", async () => {
880 describeResult: "Success",860 describeResult: "Success",
881 });861 });
882862
883 const result = await mutation.runAndReturn(5);863 const result = await mutation.runAsPromise(5);
884 await delay(30);864 await delay(30);
885865
886 assertEquals(commitCallCount, 1);866 assertEquals(commitCallCount, 1);
...@@ -906,15 +886,14 @@ test("DebouncedMutation - channel cleanup after idle with no listeners", async (...@@ -906,15 +886,14 @@ test("DebouncedMutation - channel cleanup after idle with no listeners", async (
906 },886 },
907 describe: "test mutation",887 describe: "test mutation",
908 describeResult: "Success",888 describeResult: "Success",
909 async refetch() {},
910 });889 });
911890
912 // Run mutation without subscribing891 // Run mutation without subscribing
913 await mutation.runAndReturn(5);892 await mutation.runAsPromise(5);
914 await delay(30);893 await delay(30);
915894
916 // Run another mutation - should work fine (channel recreated if needed)895 // Run another mutation - should work fine (channel recreated if needed)
917 const result = await mutation.runAndReturn(3);896 const result = await mutation.runAsPromise(3);
918 await delay(30);897 await delay(30);
919898
920 assertEquals(result, 3);899 assertEquals(result, 3);
...@@ -943,10 +922,9 @@ test("DebouncedMutation - default time is 200ms", async () => {...@@ -943,10 +922,9 @@ test("DebouncedMutation - default time is 200ms", async () => {
943 },922 },
944 describe: "test mutation",923 describe: "test mutation",
945 describeResult: "Success",924 describeResult: "Success",
946 async refetch() {},
947 });925 });
948926
949 await mutation.runAndReturn(5);927 await mutation.runAsPromise(5);
950928
951 // Should commit after ~200ms (with some tolerance)929 // Should commit after ~200ms (with some tolerance)
952 assertEquals(commitTime !== null, true);930 assertEquals(commitTime !== null, true);
...@@ -977,10 +955,9 @@ test("DebouncedMutation - context is passed to getValue", async () => {...@@ -977,10 +955,9 @@ test("DebouncedMutation - context is passed to getValue", async () => {
977 },955 },
978 describe: "test mutation",956 describe: "test mutation",
979 describeResult: "Success",957 describeResult: "Success",
980 async refetch() {},
981 });958 });
982959
983 await mutation.runAndReturn(5);960 await mutation.runAsPromise(5);
984 await delay(30);961 await delay(30);
985962
986 assertEquals(receivedUserId, "test-user");963 assertEquals(receivedUserId, "test-user");
...@@ -1007,10 +984,9 @@ test("DebouncedMutation - context is passed to commit", async () => {...@@ -1007,10 +984,9 @@ test("DebouncedMutation - context is passed to commit", async () => {
1007 },984 },
1008 describe: "test mutation",985 describe: "test mutation",
1009 describeResult: "Success",986 describeResult: "Success",
1010 async refetch() {},
1011 });987 });
1012988
1013 await mutation.runAndReturn(5);989 await mutation.runAsPromise(5);
1014 await delay(30);990 await delay(30);
1015991
1016 assertEquals(receivedUserId, "test-user");992 assertEquals(receivedUserId, "test-user");
...@@ -1037,12 +1013,11 @@ test("DebouncedMutation - first args are used for commit", async () => {...@@ -1037,12 +1013,11 @@ test("DebouncedMutation - first args are used for commit", async () => {
1037 },1013 },
1038 describe: "test mutation",1014 describe: "test mutation",
1039 describeResult: "Success",1015 describeResult: "Success",
1040 async refetch() {},
1041 });1016 });
10421017
1043 mutation.runAndReturn("first", 1);1018 mutation.runAsPromise("first", 1);
1044 mutation.runAndReturn("second", 2);1019 mutation.runAsPromise("second", 2);
1045 await mutation.runAndReturn("third", 3);1020 await mutation.runAsPromise("third", 3);
1046 await delay(10);1021 await delay(10);
10471022
1048 // Should use first args1023 // Should use first args