authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-28 17:08:11-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-28 18:51:56-08:00
logc6028dcd497631ff2d5a5a6c24b74b41f3ad21f4
tree2dbbc9080d6a5266291a4fccc0383e7958816f52
parent331a3e7e7454101d1933ba3d28996ad554a0bf6a
signaturelock-open Commit is signed but in an unrecognized format.

feat: rc 3


17 files changed, 3914 insertions(+), 3617 deletions(-)

example/src/App.tsx+4
......@@ -69,6 +69,10 @@ const mutIncrement = mutationClient.defineDebounced({
6969 },
7070
7171 describe: "update counter",
72 describeResult: ({ initial, current }) => {
73 const delta = current - initial;
74 return `Counter updated by ${delta > 0 ? '+' : ''}${delta}`;
75 },
7276});
7377
7478function CustomButton(
jsr.json+1-1
......@@ -1,6 +1,6 @@
11{
22 "name": "@clo/react-mutation",
3 "version": "1.0.0-beta.2",
3 "version": "1.0.0-beta.3",
44 "exports": {
55 ".": "./src/mod.ts",
66 "./tanstack-query.ts": "./src/tanstack-query.ts",
readme.md+72-4
......@@ -15,7 +15,7 @@ The primary gains React Mutation provides are
1515 display a UI toast. Otherwise, the component can display the error locally.
1616- Optimistic helpers allow defining rollbacks and refetching logic independant
1717 of the actual mutation. The [built in helpers for React Query](#React-Query-Optimistic-Helpers) show this power in more detail.
18- Batched Mutations are just so awesome to use.
18- Debounced Mutations are just so awesome to use.
1919
2020## Usage
2121
......@@ -79,7 +79,7 @@ const queryItemList = queryOptions({ ... });
7979const queryItem = (id: string) => queryOptions({ ... });
8080
8181// The convention is to name handlers starting with `mut`
82const mutDeleteItem = mutations.defineQueued({
82const mutDeleteItem = mutations.defineBlocking({
8383 // `mutate` comes first, is only worried about syncing with the backend.
8484 async mutate(id: string) {
8585 const response = await fetch(`/items/${id}`, { method: "delete" });
......@@ -122,7 +122,7 @@ export function Example({ id }: { id: string }) {
122122
123123### Debounced Mutations
124124
125A debounced mutation is defined with `mutations.defineBatched`.
125A debounced mutation is defined with `mutations.defineDebounced`.
126126
127127```tsx
128128const mutSetItemName = mutationClient.defineDebounced({
......@@ -178,4 +178,72 @@ function Item({ id }: { id: string }) {
178178}
179179```
180180
181###
181### Optimistic Updates
182
183The `optimistic` function is given an object with the following APIs
184
185- All values from `MutationClient`'s `context`, spread. With React Query this is `get` and `client`.
186- `helpers` - is the return type of `getOptimisticHelpers` (see next section)
187- `args` - which is the arguments passed to the mutator
188- `onSuccess` - add a callback to update queries after a success
189- `onRestore` - add a callback to revert your optimistic update
190- `onRefetch` - add a callback to fetch data after a success
191
192### React Query Optimistic Helpers
193
194When using React Query, you can opt into some incredible helpers for making it
195very easy to write Optimistic Updates. Our setup at work is with this client
196configuration.
197
198```ts
199import { MutationClient } from "@clo/react-mutation";
200import {
201 boundQueryClientGet,
202 queryClientOptimisticHelpers,
203} from "@clo/react-mutation/tanstack-query.ts";
204import { isServer } from "@tanstack/react-query";
205import { getQueryClient, makeNewQueryClient } from "./react-query-client";
206
207const client = isServer ? makeNewQueryClient() : getQueryClient();
208export const mutations = new MutationClient({
209 enabled: !isServer,
210 context: {
211 client,
212 get: boundQueryClientGet(client),
213 },
214 getOptimisticHelpers: queryClientOptimisticHelpers(client),
215 reportError(message) {
216 showAlert(message, "error");
217 },
218 reportSuccess(message: string) {
219 showAlert(message, "success");
220 },
221});
222```
223
224Within optimistic updates, a `helpers` object is provided with many useful
225helper functions. All helper functions take a `QueryKeyAndFn` (return type of
226TanStack Query's `queryOptions`), and will track every query touched to
227automatically implement `onRefetch` and `onRestore` callbacks. The current list of them is:
228
229- `set` - overwrite an entire query
230- `updateExisting` - overwrite an entire query only if it exists
231- `removeQuery` - delete a query, but restore and refetch when rolled back.
232- For queries that resolve to arrays:
233 - `arrayPush` - add items to the end
234 - `arrayUnshift` - add items to the start
235 - `arrayRemove` - remove items by a `filter` function
236 - `arrayUpdate` - update items by a `filter` + `update` function
237 - `arrayInsertIndex` - insert an item at an index
238- **experimental**: Queries that are complex options. Each function takes a type-safe
239 json path to evaluate, but this system has type bugs.
240 - `objSet` - set a property
241 - `objSetMany` - set many properties at once
242 - `objIncrement` - increment a number
243 - `objDecrement` - decrement a number
244 - `objToggle` - toggle a boolean
245 - `objArrayPush` - add items to the end of an array
246 - `objArrayUnshift` - add items to the start of an array
247 - `objArrayRemove` - remove items from array by `filter`
248 - `objArrayUpdate` - update items in array by `filter` + `update`
249 - `objArrayInsertIndex` - insert an item in an array at an index
src/batch.ts deleted-486
......@@ -1,486 +0,0 @@
1import type { MutationClient, MutationClientFromConfig } from "./client.ts";
2import type { MutationClientConfig } from "./client.ts";
3import type { Mutation, MutationEvent } from "./types.ts";
4import { message as errMessage } from "@clo/lib/error.ts";
5
6export interface BatchMutationOptions<
7 Args extends unknown[],
8 Result,
9 Optimistic,
10 Config extends MutationClientConfig,
11> {
12 /**
13 * Update the UI for one call to the optimistic function.
14 * A rest params type is used to allow type inference. Place this function first to
15 * ensure TypeScript correctly infers the argument type for the rest of the functions.
16 */
17 optimistic: (context: BatchOptimisticContext<Config>, ...args: Args) => void;
18 /**
19 * Retrieve the current/optimistic value of the mutation. When this returns
20 * the same thing as when the mutation started, it means that `mutate` does
21 * not need to be called since the data is the same.
22 *
23 * Don't snapshot unrelated state that this mutation isn't concerned with.
24 */
25 getValue: (context: Config["context"], ...args: Args) => Optimistic;
26
27 /**
28 * @default "debounce"
29 */
30 mode?: "debounce" | "throttle";
31 /**
32 * Milliseconds
33 * @default 200
34 */
35 time?: number;
36
37 /** A key to associate batch items. For example, returning a user ID */
38 key: (
39 context: Config["context"] & { args: NoInfer<Args> },
40 ) => string | string[];
41
42 /**
43 * Commit the optimistic state. Throw on failure.
44 */
45 commit: (
46 context: BatchCommitContext<NoInfer<Args>, Optimistic, Config>,
47 ) => Promise<Result>;
48 /**
49 * Used in error messages and debug tools.
50 * "Failed to {action}"
51 */
52 describe:
53 | string
54 | ((
55 context: BatchCommitContext<NoInfer<Args>, Optimistic, Config>,
56 ) => string);
57 /**
58 * Used in success messages.
59 * Phrase it as a complete success message, e.g., "Renamed item successfully"
60 * Set to null to suppress success reporting.
61 */
62 describeResult?:
63 | string
64 | ((
65 context: BatchCommitContext<NoInfer<Args>, Optimistic, Config> & { result: Result },
66 ) => string)
67 | null;
68 /**
69 * Refetch all of the data this mutation could have affected.
70 */
71 refetch?: () => Promise<void>;
72}
73
74export type BatchOptimisticContext<Config extends MutationClientConfig> =
75 & Config["context"]
76 & {
77 /** Add an event listener to roll back the update */
78 onRestore: (cb: () => void) => void;
79 helpers: Config["optimisticHelpers"];
80 };
81
82export type BatchCommitContext<
83 Args,
84 Optimistic,
85 Config extends MutationClientConfig,
86> = Config["context"] & {
87 /** One of the arguments. Use this only to extract the shared key */
88 args: Args;
89 /** The initial snapshot */
90 initial: Optimistic;
91 /** The compared snapshot */
92 current: Optimistic;
93};
94
95interface BatchChannel<Args extends unknown[], Result, Optimistic> {
96 listeners: Set<(update: MutationEvent<Result>) => void>;
97 status: "idle" | "waiting" | "mutating" | "refetching";
98
99 // Snapshot before first call in current batch
100 initial: Optimistic | null;
101 // First args in batch (used for commit/describe/getValue)
102 firstArgs: Args | null;
103 rollbacks: Array<() => void>;
104 refetches: Array<() => Promise<void>>;
105 timer: ReturnType<typeof setTimeout> | null;
106
107 // Track last commit time for throttle mode
108 lastCommitTime: number;
109
110 // Pending promises from callers in current batch
111 pending: Array<{
112 args: Args;
113 resolve: (result: Result) => void;
114 reject: (error: unknown) => void;
115 reportSuccessGlobally?: boolean;
116 }>;
117}
118
119export class BatchMutation<
120 Args extends unknown[],
121 Result,
122 Optimistic,
123 Config extends MutationClientConfig,
124> implements Mutation<Args, Result> {
125 #options: BatchMutationOptions<Args, Result, Optimistic, Config>;
126 #client: MutationClientFromConfig<Config>;
127 #channels: Map<string, BatchChannel<Args, Result, Optimistic>> = new Map();
128 client: MutationClientFromConfig<Config>;
129
130 constructor(
131 client: MutationClient<Config["context"], Config["optimisticHelpers"]>,
132 options: BatchMutationOptions<Args, Result, Optimistic, Config>,
133 ) {
134 this.#options = options;
135 this.#client = client;
136 this.client = client;
137 }
138
139 key(args: Args): string {
140 const k = this.#options.key({ ...this.#client.context, args });
141 return JSON.stringify(k);
142 }
143
144 #getOrPutChannel(key: string): BatchChannel<Args, Result, Optimistic> {
145 let channel = this.#channels.get(key);
146 if (!channel) {
147 channel = {
148 listeners: new Set(),
149 status: "idle",
150 initial: null,
151 firstArgs: null,
152 rollbacks: [],
153 refetches: [],
154 timer: null,
155 lastCommitTime: 0,
156 pending: [],
157 };
158 this.#channels.set(key, channel);
159 }
160 return channel;
161 }
162
163 subscribe(
164 key: string,
165 cb: (update: MutationEvent<Result>) => void,
166 ): () => void {
167 const channel = this.#getOrPutChannel(key);
168 channel.listeners.add(cb);
169 return () => channel?.listeners.delete(cb);
170 }
171
172 #notify(
173 channel: BatchChannel<Args, Result, Optimistic>,
174 status: MutationEvent<Result>["status"],
175 result: Result | null = null,
176 error: unknown = null,
177 ) {
178 const event: MutationEvent<Result> = { status, result, error };
179 channel.listeners.forEach((cb) => cb(event));
180 }
181
182 #setIdle(key: string, channel: BatchChannel<Args, Result, Optimistic>) {
183 channel.status = "idle";
184 this.#notify(channel, "idle", null, null);
185 // Clean up the channel if there are no listeners
186 if (channel.listeners.size === 0) {
187 this.#channels.delete(key);
188 }
189 }
190
191 #resetBatchState(channel: BatchChannel<Args, Result, Optimistic>) {
192 channel.initial = null;
193 channel.firstArgs = null;
194 channel.rollbacks = [];
195 channel.refetches = [];
196 channel.pending = [];
197 if (channel.timer !== null) {
198 clearTimeout(channel.timer);
199 channel.timer = null;
200 }
201 }
202
203 describe(...args: Args): string {
204 const { describe } = this.#options;
205 if (typeof describe === "function") {
206 // For describe, we need initial/current but may not have them yet
207 // Use placeholder values when called outside of commit context
208 return describe({
209 ...this.#client.context,
210 args,
211 initial: null as unknown as Optimistic,
212 current: null as unknown as Optimistic,
213 });
214 }
215 return describe;
216 }
217
218 // Not available for batched mutations - success reporting happens during commit
219 describeResult: undefined = undefined;
220
221 #describeResult(args: Args, initial: Optimistic, current: Optimistic, result: Result): string | undefined {
222 const { describeResult } = this.#options;
223 if (describeResult === null || describeResult === undefined) return undefined;
224 return typeof describeResult === "function"
225 ? describeResult({
226 ...this.#client.context,
227 args,
228 initial,
229 current,
230 result,
231 })
232 : describeResult;
233 }
234
235 /** Calling the mutation in a global scope. Errors and successes are turned into UI toasts. */
236 run(...args: Args): void {
237 this.#runAndReturn(args, true).catch((error) => {
238 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
239 this.#client.reportError(message, error);
240 });
241 }
242
243 /** Calls the mutation, treating the errors as promise rejection. */
244 runAndReturn(...args: Args): Promise<Result> {
245 return this.#runAndReturn(args, false);
246 }
247
248 #runAndReturn(args: Args, reportSuccessGlobally: boolean): Promise<Result> {
249 const key = this.key(args);
250 const channel = this.#getOrPutChannel(key);
251
252 // If this is the first call in the batch, take a snapshot
253 if (channel.initial === null) {
254 channel.initial = this.#options.getValue(this.#client.context, ...args);
255 channel.firstArgs = args;
256 }
257
258 // Apply optimistic update
259 let expired = false;
260 const onRestore = (cb: () => void) => {
261 if (expired) {
262 throw new Error(
263 "Can only call onRestore from within the optimistic update function.",
264 );
265 }
266 channel.rollbacks.push(cb);
267 };
268 const onRefetch = (cb: () => Promise<void>) => {
269 if (expired) {
270 throw new Error(
271 "Can only call onRefetch from within the optimistic update function.",
272 );
273 }
274 channel.refetches.push(cb);
275 };
276
277 try {
278 this.#options.optimistic(
279 {
280 ...this.#client.context,
281 onRestore,
282 helpers: this.#client.getOptimisticHelpers({
283 onRestore,
284 onRefetch,
285 }),
286 },
287 ...args,
288 );
289 } catch (error) {
290 expired = true;
291 // Rollback just this call's rollbacks
292 // We don't know how many were added, so we can't do partial rollback easily
293 // For simplicity, rollback everything and reject
294 let next;
295 while ((next = channel.rollbacks.pop())) next();
296 this.#resetBatchState(channel);
297 return Promise.reject(error);
298 }
299 expired = true;
300
301 // Create promise for this caller
302 const { promise, resolve, reject } = Promise.withResolvers<Result>();
303 channel.pending.push({ args, resolve, reject, reportSuccessGlobally });
304
305 // Set status to waiting and notify
306 if (channel.status === "idle") {
307 channel.status = "waiting";
308 this.#notify(channel, "waiting");
309 }
310
311 // Schedule commit based on mode
312 this.#scheduleCommit(key, channel);
313
314 return promise;
315 }
316
317 #scheduleCommit(
318 key: string,
319 channel: BatchChannel<Args, Result, Optimistic>,
320 ) {
321 const time = this.#options.time ?? 200;
322
323 if (this.#options.mode !== "throttle") {
324 // Debounce: reset timer on each call
325 if (channel.timer !== null) {
326 clearTimeout(channel.timer);
327 }
328 channel.timer = setTimeout(() => this.#commit(key, channel), time);
329 } else {
330 // Throttle: commit immediately if enough time passed, otherwise wait
331 // Use status to track if a commit is in progress
332 if (channel.timer === null && channel.status === "waiting") {
333 const elapsed = Date.now() - channel.lastCommitTime;
334 if (elapsed >= time) {
335 // Enough time has passed, commit immediately
336 this.#commit(key, channel);
337 } else {
338 // Wait for remaining time
339 channel.timer = setTimeout(
340 () => this.#commit(key, channel),
341 time - elapsed,
342 );
343 }
344 }
345 // If timer exists or commit is in progress, do nothing - will commit when ready
346 }
347 }
348
349 #commit(key: string, channel: BatchChannel<Args, Result, Optimistic>) {
350 // Clear timer
351 if (channel.timer !== null) {
352 clearTimeout(channel.timer);
353 channel.timer = null;
354 }
355
356 // Safety check
357 if (channel.firstArgs === null || channel.initial === null) {
358 this.#setIdle(key, channel);
359 return;
360 }
361
362 const firstArgs = channel.firstArgs;
363 const initial = channel.initial;
364 const pendingItems = [...channel.pending];
365 const rollbacks = [...channel.rollbacks];
366 const refetchCallbacks = [...channel.refetches];
367
368 // Get current snapshot
369 const current = this.#options.getValue(
370 this.#client.context,
371 ...firstArgs,
372 );
373
374 // Check if anything changed
375 if (this.#client.deepEquals(initial, current)) {
376 // No change - resolve all pending with a null result and reset
377 pendingItems.forEach(({ resolve }) => resolve(null as Result));
378 this.#resetBatchState(channel);
379 this.#setIdle(key, channel);
380 return;
381 }
382
383 // Set status to mutating
384 channel.status = "mutating";
385 this.#notify(channel, "mutating");
386
387 // Clear batch state before async operation (but keep rollbacks/refetches for error case)
388 channel.initial = null;
389 channel.firstArgs = null;
390 channel.pending = [];
391 channel.rollbacks = [];
392 channel.refetches = [];
393
394 // Call commit
395 this.#options
396 .commit({
397 ...this.#client.context,
398 args: firstArgs,
399 initial,
400 current,
401 })
402 .then((result) => {
403 // Success - rollbacks are discarded (optimistic was correct)
404 // Resolve all pending promises
405 pendingItems.forEach(({ resolve }) => resolve(result));
406
407 // Report success globally if any of the pending items requested it
408 const shouldReportSuccess = pendingItems.some((item) => item.reportSuccessGlobally);
409 if (shouldReportSuccess) {
410 const message = this.#describeResult(firstArgs, initial, current, result);
411 if (message && this.#client.reportSuccess) {
412 this.#client.reportSuccess(message);
413 }
414 }
415
416 // Record commit time for throttle mode
417 channel.lastCommitTime = Date.now();
418
419 // Refetch
420 channel.status = "refetching";
421 this.#notify(channel, "refetching", result);
422 // Call refetch and all refetch callbacks in parallel
423 Promise.allSettled([
424 this.#options.refetch?.(),
425 ...refetchCallbacks.map((cb) => cb()),
426 ]).then((results) => {
427 // Report any errors from refetch or callbacks
428 results.forEach((result) => {
429 if (result.status === "rejected") {
430 const message = `Failed to refetch after ${this.describe(...firstArgs)}: ${errMessage(result.reason)}`;
431 this.#client.reportError(message, result.reason);
432 }
433 });
434 }).finally(() => {
435 // Check if new calls came in during the commit
436 if (channel.pending.length > 0) {
437 // There are pending calls that need to be committed
438 channel.status = "waiting";
439 this.#notify(channel, "waiting");
440 this.#scheduleCommit(key, channel);
441 } else {
442 this.#setIdle(key, channel);
443 }
444 });
445 })
446 .catch((error) => {
447 // Error - call all rollbacks in reverse order
448 let next;
449 const rollbacksCopy = [...rollbacks];
450 while ((next = rollbacksCopy.pop())) next();
451
452 // Reject all pending promises
453 pendingItems.forEach(({ reject }) => reject(error));
454
455 // Notify listeners of the error
456 this.#notify(channel, "mutating", null, error);
457
458 // Refetch to restore correct state
459 channel.status = "refetching";
460 this.#notify(channel, "refetching", null, error);
461 // Call refetch and all refetch callbacks in parallel
462 Promise.allSettled([
463 this.#options.refetch?.(),
464 ...refetchCallbacks.map((cb) => cb()),
465 ]).then((results) => {
466 // Report any errors from refetch or callbacks
467 results.forEach((result) => {
468 if (result.status === "rejected") {
469 const message = `Failed to refetch after ${this.describe(...firstArgs)}: ${errMessage(result.reason)}`;
470 this.#client.reportError(message, result.reason);
471 }
472 });
473 }).finally(() => {
474 // Check if new calls came in during the commit
475 if (channel.pending.length > 0) {
476 // There are pending calls that need to be committed
477 channel.status = "waiting";
478 this.#notify(channel, "waiting");
479 this.#scheduleCommit(key, channel);
480 } else {
481 this.#setIdle(key, channel);
482 }
483 });
484 });
485 }
486}
src/blocking.ts created+361
......@@ -0,0 +1,361 @@
1import type { MutationClient, MutationClientFromConfig } from "./client.ts";
2import type { MutationClientConfig } from "./client.ts";
3import type { Mutation, MutationEvent } from "./types.ts";
4import { message as errMessage } from "@clo/lib/error.ts";
5
6/**
7 * Argument to `defineBlocking`.
8 * @template Args - the parameters to the mutation
9 * @template Result - the result of the API call
10 * @template Config - global values and helpers from `MutationContext`
11 */
12export interface BlockingMutationOptions<
13 Args extends unknown[],
14 Result,
15 Config extends MutationClientConfig,
16> {
17 /**
18 * This function is only responsible for performing the underlying API call,
19 * syncronizing the optimistic state with reality. Throw on failure. A rest
20 * params type is used to allow type inference. Place this function first to
21 * ensure TypeScript correctly infers the argument type for the rest of the
22 * functions.
23 *
24 * In practice, optimistic context is never needed in this function, but it
25 * is provided as the `this` value if you truly desire it.
26 */
27 mutate: (this: Config["context"], ...args: Args) => Promise<Result>;
28 /**
29 * Used in error messages and debug tools.
30 * Phrase it considering the template `Failed to ${describe(...)}`
31 */
32 describe: string | ((context: Config["context"] & { args: Args }) => string);
33 /**
34 * Used in success messages.
35 * Phrase it as a complete success message, e.g., "Deleted item successfully"
36 */
37 describeResult: string | ((context: Config["context"] & { args: Args; result: Result }) => string);
38 /**
39 * Specifying the optimistic strategy is required. To disable, pass an empty
40 * function with a comment to document why it isn't needed.
41 */
42 optimistic: (context: BlockingOptimisticContext<Args, Result, Config>) => void;
43 /**
44 * Refetch all of the data this mutation could have affected.
45 * Normally, optimistic helpers will perform
46 * This is called automatically on errors.
47 */
48 refetch?: (context: Config["context"] & { args: Args }) => Promise<void>;
49 /**
50 * If the optimistic updator function is perfect, then this may be set to false.
51 * @default true
52 */
53 refetchOnSuccess?: boolean;
54 /**
55 * A key to associate related items. For example, returning a user ID. If
56 * specifying, then all mutations of the same key will evaluate in serial,
57 * but optimistic updates will apply instantly.
58 */
59 key?: (context: Config["context"] & { args: Args }) => string | string[];
60}
61
62export type BlockingOptimisticContext<
63 Args extends unknown[],
64 Result,
65 Config extends MutationClientConfig,
66> = Config["context"] & {
67 args: Args;
68 helpers: Config["optimisticHelpers"];
69 /** Add an event listener to roll back the update */
70 onRestore: (cb: () => void) => void;
71 /** Add an event listener to apply `Result` to the store. */
72 onSuccess: (cb: (result: Result) => void) => void;
73};
74
75interface BlockingChannel<Args extends unknown[], Result, OptimisticHelpers> {
76 listeners: Set<(update: MutationEvent<Result>) => void>;
77 status: "idle" | "mutating" | "refetching";
78 rollbacks: Array<() => void>;
79 refetches: Array<() => Promise<void>>;
80 queue: Array<Item<Args, Result>>;
81 // Shared optimistic helpers instance for the channel
82 helpers: OptimisticHelpers | null;
83}
84
85interface Item<Args extends unknown[], Result> {
86 args: Args;
87 rollbacks: number;
88 onSuccess: Array<(result: Result) => void>;
89 resolve: (result: Result) => void;
90 reject: (error: unknown) => void;
91}
92
93export class BlockingMutation<
94 Args extends unknown[],
95 Result,
96 Config extends MutationClientConfig,
97> implements Mutation<Args, Result> {
98 #options: BlockingMutationOptions<Args, Result, Config>;
99 #client: MutationClientFromConfig<Config>;
100 #channels: Map<string, BlockingChannel<Args, Result, Config["optimisticHelpers"]>> = new Map();
101 client: MutationClientFromConfig<Config>;
102
103 constructor(
104 client: MutationClient<Config["context"], Config["optimisticHelpers"]>,
105 options: BlockingMutationOptions<Args, Result, Config>,
106 ) {
107 this.#options = options;
108 this.#client = client;
109 this.client = client;
110 }
111
112 key(args: Args) {
113 const k = this.#options.key?.({ ...this.#client.context, args }) ??
114 "shared";
115 return JSON.stringify(k);
116 }
117
118 #getOrPutChannel(key: string) {
119 let channel = this.#channels.get(key);
120 if (!channel) {
121 const rollbacks: Array<() => []> = [];
122 channel = {
123 listeners: new Set(),
124 status: "idle",
125 rollbacks,
126 refetches: [],
127 queue: [],
128 helpers: null,
129 };
130 this.#channels.set(key, channel);
131 }
132 return channel;
133 }
134
135 subscribe(
136 key: string,
137 cb: (update: MutationEvent<Result>) => void,
138 ): () => void {
139 const channel = this.#getOrPutChannel(key);
140 channel.listeners.add(cb);
141 return () => channel?.listeners.delete(cb);
142 }
143
144 #notify(
145 channel: BlockingChannel<Args, Result, Config["optimisticHelpers"]>,
146 status: MutationEvent<Result>["status"],
147 result: Result | null = null,
148 error: unknown = null,
149 ) {
150 const event: MutationEvent<Result> = { status, result, error };
151 channel.listeners.forEach((cb) => cb(event));
152 }
153
154 #setIdle(key: string, channel: BlockingChannel<Args, Result, Config["optimisticHelpers"]>) {
155 channel.status = "idle";
156 // Discard any unconsumed refetch callbacks
157 channel.refetches = [];
158 this.#notify(channel, "idle", null, null);
159 // Clean up the channel if there are no listeners
160 if (channel.listeners.size === 0) {
161 this.#channels.delete(key);
162 }
163 }
164
165 describe(...args: Args): string {
166 const { describe } = this.#options;
167 return typeof describe === "function"
168 ? describe({ ...this.#client.context, args })
169 : describe;
170 }
171
172 describeResult(args: Args, result: Result): string {
173 const { describeResult } = this.#options;
174 return typeof describeResult === "function"
175 ? describeResult({ ...this.#client.context, args, result })
176 : describeResult;
177 }
178
179 /** Calling the mutation in a global scope. Errors are turned into UI toasts. */
180 run(...args: Args) {
181 if (!this.#client.enabled) {
182 throw new Error(
183 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",
184 );
185 }
186 this.runAndReturn(...args).then((result) => {
187 const message = this.describeResult(args, result);
188 if (message && this.#client.reportSuccess) {
189 this.#client.reportSuccess(message);
190 }
191 }).catch((error) => {
192 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
193 this.#client.reportError(message, error);
194 });
195 }
196
197 /** Calls the mutation, treating the errors as promise rejection. */
198 runAndReturn(...args: Args): Promise<Result> {
199 if (!this.#client.enabled) {
200 throw new Error(
201 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",
202 );
203 }
204 const key = this.key(args);
205 const channel = this.#getOrPutChannel(key);
206
207 // Create shared optimistic helpers instance for the channel if it doesn't exist
208 if (channel.helpers === null) {
209 const onRefetch = (cb: () => Promise<void>) => {
210 channel.refetches.push(cb);
211 };
212
213 channel.helpers = this.#client.getOptimisticHelpers({
214 onRestore: (cb: () => void) => {
215 channel.rollbacks.push(cb);
216 },
217 onRefetch,
218 });
219 }
220
221 const onSuccess: Array<(result: Result) => void> = [];
222 let expired = false;
223 let rollbacks = 0;
224 const onRestore = (cb: () => void) => {
225 if (expired) {
226 throw new Error(
227 "Can only call onRestore from within the optimistic update function.",
228 );
229 }
230 channel.rollbacks.push(cb);
231 rollbacks += 1;
232 };
233
234 try {
235 this.#options.optimistic({
236 args,
237 helpers: channel.helpers,
238 onRestore,
239 onSuccess(cb) {
240 if (expired) {
241 throw new Error(
242 "Can only call onSuccess from within the optimistic update function.",
243 );
244 }
245 onSuccess.push(cb);
246 },
247 });
248 } catch (error) {
249 expired = true;
250 let next;
251 while (
252 next =
253 channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0]
254 ) {
255 next();
256 }
257 return Promise.reject(error);
258 }
259 expired = true;
260
261 const { promise, resolve, reject } = Promise.withResolvers<Result>();
262 channel.queue.push({
263 args,
264 rollbacks,
265 onSuccess,
266 resolve,
267 reject,
268 });
269
270 if (channel.status === "idle") {
271 this.#executeNext(key, channel);
272 }
273
274 return promise;
275 }
276
277 #executeNext(key: string, channel: BlockingChannel<Args, Result, Config["optimisticHelpers"]>) {
278 const item = channel.queue.shift();
279 if (!item) {
280 this.#setIdle(key, channel);
281 return;
282 }
283
284 const { args, onSuccess, resolve, reject } = item;
285 channel.status = "mutating";
286 this.#notify(channel, "mutating");
287
288 this.#options.mutate.call(this.#client.context, ...args).then((result) => {
289 // remove rollbacks and apply optimistic success handlers
290 channel.rollbacks.splice(0, item.rollbacks);
291 onSuccess.forEach((cb) => cb(result));
292
293 if (this.#options.refetchOnSuccess !== false) {
294 channel.status = "refetching";
295 this.#notify(channel, "refetching", result);
296 // Call refetch and all refetch callbacks in parallel
297 const refetchCallbacks = channel.refetches.splice(0);
298 Promise.allSettled([
299 this.#options.refetch?.({
300 ...this.#client.context,
301 args,
302 }),
303 ...refetchCallbacks.map((cb) => cb()),
304 ]).then((results) => {
305 // Report any errors from refetch or callbacks
306 results.forEach((result) => {
307 if (result.status === "rejected") {
308 const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`;
309 this.#client.reportError(message, result.reason);
310 }
311 });
312 }).finally(() => {
313 this.#executeNext(key, channel);
314 });
315 } else {
316 // Discard refetch callbacks if refetchOnSuccess is false
317 channel.refetches = [];
318 this.#executeNext(key, channel);
319 }
320 resolve(result);
321 }, (error) => {
322 // if an error happens, then every rollback is called in reverse order
323 let next;
324 while (next = channel.rollbacks.pop()) next();
325
326 // Cancel all remaining items in the channel
327 const remainingItems = channel.queue.splice(0);
328 remainingItems.forEach((queuedItem) => {
329 queuedItem.reject(error);
330 });
331
332 // Notify listeners of the error
333 this.#notify(channel, "mutating", null, error);
334
335 // Refetch to restore correct state
336 channel.status = "refetching";
337 this.#notify(channel, "refetching", null, error);
338 // Call refetch and all refetch callbacks in parallel
339 const refetchCallbacks = channel.refetches.splice(0);
340 Promise.allSettled([
341 this.#options.refetch?.({
342 ...this.#client.context,
343 args,
344 }),
345 ...refetchCallbacks.map((cb) => cb()),
346 ]).then((results) => {
347 // Report any errors from refetch or callbacks
348 results.forEach((result) => {
349 if (result.status === "rejected") {
350 const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`;
351 this.#client.reportError(message, result.reason);
352 }
353 });
354 }).finally(() => {
355 this.#setIdle(key, channel);
356 });
357
358 reject(error);
359 });
360 }
361}
src/client.ts+18-10
......@@ -1,5 +1,5 @@
1import { BatchMutation, type BatchMutationOptions } from "./batch.ts";
2import { type MutationOptions, QueuedMutation } from "./queued.ts";
1import { DebouncedMutation, type DebouncedMutationOptions } from "./debounced.ts";
2import { type BlockingMutationOptions, BlockingMutation } from "./blocking.ts";
33import type { Mutation } from "./types.ts";
44
55export interface MutationClientConfig {
......@@ -24,11 +24,17 @@ export interface MutationClientOptions<
2424 reportError: (message: string, error: unknown) => void;
2525 reportSuccess?: (message: string) => void;
2626 /**
27 * Compare two values for deep equality. Used by BatchMutation to determine
27 * Compare two values for deep equality. Used by DebouncedMutation to determine
2828 * if the optimistic state has changed from the initial snapshot.
2929 * @default JSON.stringify based comparison
3030 */
3131 deepEquals?: (a: unknown, b: unknown) => boolean;
32 /**
33 * When false, all mutation run functions will throw an error.
34 * Useful for preventing mutations during SSR.
35 * @default true
36 */
37 enabled?: boolean;
3238}
3339
3440export interface OptimisticEvents {
......@@ -45,6 +51,7 @@ export class MutationClient<
4551 reportError: (message: string, error: unknown) => void;
4652 reportSuccess?: (message: string) => void;
4753 deepEquals: (a: unknown, b: unknown) => boolean;
54 enabled: boolean;
4855
4956 constructor(options: MutationClientOptions<Context, OptimisticHelpers>) {
5057 this.context = options.context;
......@@ -52,21 +59,22 @@ export class MutationClient<
5259 this.reportError = options.reportError;
5360 this.reportSuccess = options.reportSuccess;
5461 this.deepEquals = options.deepEquals ?? defaultDeepEquals;
62 this.enabled = options.enabled ?? true;
5563 }
5664
5765 /**
58 * Define a queued mutation. A mutation blocks the UI until it is complete.
66 * Define a blocking mutation. A mutation blocks the UI until it is complete.
5967 * You press a button, a pending state appears, then it completes. This works
6068 * great for forms, and is similar to React Query's mutation system.
6169 */
6270 defineBlocking<const Args extends unknown[], Result>(
63 options: MutationOptions<
71 options: BlockingMutationOptions<
6472 Args,
6573 Result,
6674 { context: Context; optimisticHelpers: OptimisticHelpers }
6775 >,
6876 ): Mutation<Args, Result> {
69 return new QueuedMutation<
77 return new BlockingMutation<
7078 Args,
7179 Result,
7280 { context: Context; optimisticHelpers: OptimisticHelpers }
......@@ -74,21 +82,21 @@ export class MutationClient<
7482 }
7583
7684 /**
77 * Define a batched mutation. Each call to the mutation applies new optimistic
85 * Define a debounced mutation. Each call to the mutation applies new optimistic
7886 * state, and after a debounce or throttle, the new optimistic state is
79 * committed to the API. UI never shows a pending state for batches. This
87 * committed to the API. UI never shows a pending state for debounced mutations. This
8088 * works great for auto-saving input fields, follow buttons, and is preferred
8189 * whenever possible.
8290 */
8391 defineDebounced<const Args extends unknown[], Result, Optimistic>(
84 options: BatchMutationOptions<
92 options: DebouncedMutationOptions<
8593 Args,
8694 Result,
8795 Optimistic,
8896 { context: Context; optimisticHelpers: OptimisticHelpers }
8997 >,
9098 ): Mutation<Args, Result> {
91 return new BatchMutation<
99 return new DebouncedMutation<
92100 Args,
93101 Result,
94102 Optimistic,
src/debounced.ts created+562
......@@ -0,0 +1,562 @@
1import type { MutationClient, MutationClientFromConfig } from "./client.ts";
2import type { MutationClientConfig } from "./client.ts";
3import type { Mutation, MutationEvent } from "./types.ts";
4import { message as errMessage } from "@clo/lib/error.ts";
5
6export interface DebouncedMutationOptions<
7 Args extends unknown[],
8 Result,
9 Optimistic,
10 Config extends MutationClientConfig,
11> {
12 /**
13 * Update the UI for one call to the optimistic function.
14 * A rest params type is used to allow type inference. Place this function first to
15 * ensure TypeScript correctly infers the argument type for the rest of the functions.
16 */
17 optimistic: (context: DebouncedOptimisticContext<Config>, ...args: Args) => void;
18 /**
19 * Retrieve the current/optimistic value of the mutation. When this returns
20 * the same thing as when the mutation started, it means that `mutate` does
21 * not need to be called since the data is the same.
22 *
23 * Don't snapshot unrelated state that this mutation isn't concerned with.
24 */
25 getValue: (context: Config["context"] & { args: Args }) => Optimistic;
26
27 /**
28 * @default "debounce"
29 */
30 mode?: "debounce" | "throttle";
31 /**
32 * Milliseconds
33 * @default 200
34 */
35 time?: number;
36
37 /** A key to associate debounced items. For example, returning a user ID */
38 key: (
39 context: Config["context"] & { args: NoInfer<Args> },
40 ) => string | string[];
41
42 /**
43 * Commit the optimistic state. Throw on failure.
44 */
45 commit: (
46 context: DebouncedCommitContext<NoInfer<Args>, Optimistic, Config>,
47 ) => Promise<Result>;
48 /**
49 * Used in error messages and debug tools.
50 * "Failed to {action}"
51 */
52 describe:
53 | string
54 | ((
55 context: DebouncedCommitContext<NoInfer<Args>, Optimistic, Config>,
56 ) => string);
57 /**
58 * Used in success messages.
59 * Phrase it as a complete success message, e.g., "Renamed item successfully"
60 */
61 describeResult:
62 | string
63 | ((
64 context: DebouncedCommitContext<NoInfer<Args>, Optimistic, Config> & {
65 result: Result;
66 },
67 ) => string);
68 /**
69 * Refetch all of the data this mutation could have affected.
70 */
71 refetch?: () => Promise<void>;
72}
73
74export type DebouncedOptimisticContext<Config extends MutationClientConfig> =
75 & Config["context"]
76 & {
77 /** Add an event listener to roll back the update */
78 onRestore: (cb: () => void) => void;
79 helpers: Config["optimisticHelpers"];
80 };
81
82export type DebouncedCommitContext<
83 Args,
84 Optimistic,
85 Config extends MutationClientConfig,
86> = Config["context"] & {
87 /** One of the arguments. Use this only to extract the shared key */
88 args: Args;
89 /** The initial snapshot */
90 initial: Optimistic;
91 /** The compared snapshot */
92 current: Optimistic;
93};
94
95interface DebouncedChannel<
96 Args extends unknown[],
97 Result,
98 Optimistic,
99 OptimisticHelpers,
100> {
101 listeners: Set<(update: MutationEvent<Result>) => void>;
102 status: "idle" | "waiting" | "mutating" | "refetching";
103
104 // Snapshot before first call in current debounced run
105 initial: Optimistic | null;
106 // First args in debounced run (used for commit/describe/getValue)
107 firstArgs: Args | null;
108 rollbacks: Array<() => void>;
109 refetches: Array<() => Promise<void>>;
110 timer: ReturnType<typeof setTimeout> | null;
111
112 // Track last commit time for throttle mode
113 lastCommitTime: number;
114
115 // Shared optimistic helpers instance for the current debounced run
116 helpers: OptimisticHelpers | null;
117
118 // Pending promises from callers in current debounced run
119 pending: Array<{
120 args: Args;
121 resolve: (result: Result) => void;
122 reject: (error: unknown) => void;
123 reportSuccessGlobally?: boolean;
124 }>;
125}
126
127export class DebouncedMutation<
128 Args extends unknown[],
129 Result,
130 Optimistic,
131 Config extends MutationClientConfig,
132> implements Mutation<Args, Result> {
133 #options: DebouncedMutationOptions<Args, Result, Optimistic, Config>;
134 #client: MutationClientFromConfig<Config>;
135 #channels: Map<
136 string,
137 DebouncedChannel<Args, Result, Optimistic, Config["optimisticHelpers"]>
138 > = new Map();
139 client: MutationClientFromConfig<Config>;
140
141 constructor(
142 client: MutationClient<Config["context"], Config["optimisticHelpers"]>,
143 options: DebouncedMutationOptions<Args, Result, Optimistic, Config>,
144 ) {
145 this.#options = options;
146 this.#client = client;
147 this.client = client;
148 }
149
150 key(args: Args): string {
151 const k = this.#options.key({ ...this.#client.context, args });
152 return JSON.stringify(k);
153 }
154
155 #getOrPutChannel(
156 key: string,
157 ): DebouncedChannel<Args, Result, Optimistic, Config["optimisticHelpers"]> {
158 let channel = this.#channels.get(key);
159 if (!channel) {
160 channel = {
161 listeners: new Set(),
162 status: "idle",
163 initial: null,
164 firstArgs: null,
165 rollbacks: [],
166 refetches: [],
167 timer: null,
168 lastCommitTime: 0,
169 helpers: null,
170 pending: [],
171 };
172 this.#channels.set(key, channel);
173 }
174 return channel;
175 }
176
177 subscribe(
178 key: string,
179 cb: (update: MutationEvent<Result>) => void,
180 ): () => void {
181 const channel = this.#getOrPutChannel(key);
182 channel.listeners.add(cb);
183 return () => channel?.listeners.delete(cb);
184 }
185
186 #notify(
187 channel: DebouncedChannel<
188 Args,
189 Result,
190 Optimistic,
191 Config["optimisticHelpers"]
192 >,
193 status: MutationEvent<Result>["status"],
194 result: Result | null = null,
195 error: unknown = null,
196 ) {
197 const event: MutationEvent<Result> = { status, result, error };
198 channel.listeners.forEach((cb) => cb(event));
199 }
200
201 #setIdle(
202 key: string,
203 channel: DebouncedChannel<
204 Args,
205 Result,
206 Optimistic,
207 Config["optimisticHelpers"]
208 >,
209 ) {
210 channel.status = "idle";
211 this.#notify(channel, "idle", null, null);
212 // Clean up the channel if there are no listeners
213 if (channel.listeners.size === 0) {
214 this.#channels.delete(key);
215 }
216 }
217
218 #resetDebouncedState(
219 channel: DebouncedChannel<
220 Args,
221 Result,
222 Optimistic,
223 Config["optimisticHelpers"]
224 >,
225 ) {
226 channel.initial = null;
227 channel.firstArgs = null;
228 channel.rollbacks = [];
229 channel.refetches = [];
230 channel.helpers = null;
231 channel.pending = [];
232 if (channel.timer !== null) {
233 clearTimeout(channel.timer);
234 channel.timer = null;
235 }
236 }
237
238 describe(...args: Args): string {
239 const { describe } = this.#options;
240 if (typeof describe === "function") {
241 // For describe, we need initial/current but may not have them yet
242 // Use placeholder values when called outside of commit context
243 return describe({
244 ...this.#client.context,
245 args,
246 initial: null as unknown as Optimistic,
247 current: null as unknown as Optimistic,
248 });
249 }
250 return describe;
251 }
252
253 // Not available for debounced mutations - success reporting happens during commit
254 describeResult: undefined = undefined;
255
256 #describeResult(
257 args: Args,
258 initial: Optimistic,
259 current: Optimistic,
260 result: Result,
261 ): string {
262 const { describeResult } = this.#options;
263 return typeof describeResult === "function"
264 ? describeResult({
265 ...this.#client.context,
266 args,
267 initial,
268 current,
269 result,
270 })
271 : describeResult;
272 }
273
274 /** Calling the mutation in a global scope. Errors and successes are turned into UI toasts. */
275 run(...args: Args): void {
276 if (!this.#client.enabled) {
277 throw new Error(
278 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",
279 );
280 }
281 this.#runAndReturn(args, true).catch((error) => {
282 const message = `Failed to ${this.describe(...args)}: ${
283 errMessage(error)
284 }`;
285 this.#client.reportError(message, error);
286 });
287 }
288
289 /** Calls the mutation, treating the errors as promise rejection. */
290 runAndReturn(...args: Args): Promise<Result> {
291 if (!this.#client.enabled) {
292 throw new Error(
293 "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?",
294 );
295 }
296 return this.#runAndReturn(args, false);
297 }
298
299 #runAndReturn(args: Args, reportSuccessGlobally: boolean): Promise<Result> {
300 const key = this.key(args);
301 const channel = this.#getOrPutChannel(key);
302
303 // If this is the first call in the debounced run, take a snapshot and create shared helpers
304 if (channel.initial === null) {
305 channel.initial = this.#options.getValue({ ...this.#client.context, args });
306 channel.firstArgs = args;
307
308 // Create shared onRefetch handler for the debounced run
309 const onRefetch = (cb: () => Promise<void>) => {
310 channel.refetches.push(cb);
311 };
312
313 // Create shared optimistic helpers instance for this debounced run
314 channel.helpers = this.#client.getOptimisticHelpers({
315 onRestore: (cb: () => void) => {
316 channel.rollbacks.push(cb);
317 },
318 onRefetch,
319 });
320 }
321
322 // Apply optimistic update
323 let expired = false;
324 const onRestore = (cb: () => void) => {
325 if (expired) {
326 throw new Error(
327 "Can only call onRestore from within the optimistic update function.",
328 );
329 }
330 channel.rollbacks.push(cb);
331 };
332
333 try {
334 this.#options.optimistic(
335 {
336 ...this.#client.context,
337 onRestore,
338 helpers: channel.helpers!,
339 },
340 ...args,
341 );
342 } catch (error) {
343 expired = true;
344 // We don't know how many were added, so we can't do partial rollback easily
345 // For simplicity, rollback everything and reject
346 let next;
347 while ((next = channel.rollbacks.pop())) next();
348 this.#resetDebouncedState(channel);
349 return Promise.reject(error);
350 }
351 expired = true;
352
353 // Create promise for this caller
354 const { promise, resolve, reject } = Promise.withResolvers<Result>();
355 channel.pending.push({ args, resolve, reject, reportSuccessGlobally });
356
357 // Set status to waiting and notify
358 if (channel.status === "idle") {
359 channel.status = "waiting";
360 this.#notify(channel, "waiting");
361 }
362
363 // Schedule commit based on mode
364 this.#scheduleCommit(key, channel);
365
366 return promise;
367 }
368
369 #scheduleCommit(
370 key: string,
371 channel: DebouncedChannel<
372 Args,
373 Result,
374 Optimistic,
375 Config["optimisticHelpers"]
376 >,
377 ) {
378 const time = this.#options.time ?? 200;
379
380 if (this.#options.mode !== "throttle") {
381 // Debounce: reset timer on each call
382 if (channel.timer !== null) {
383 clearTimeout(channel.timer);
384 }
385 channel.timer = setTimeout(() => this.#commit(key, channel), time);
386 } else {
387 // Throttle: commit immediately if enough time passed, otherwise wait
388 // Use status to track if a commit is in progress
389 if (channel.timer === null && channel.status === "waiting") {
390 const elapsed = Date.now() - channel.lastCommitTime;
391 if (elapsed >= time) {
392 // Enough time has passed, commit immediately
393 this.#commit(key, channel);
394 } else {
395 // Wait for remaining time
396 channel.timer = setTimeout(
397 () => this.#commit(key, channel),
398 time - elapsed,
399 );
400 }
401 }
402 // If timer exists or commit is in progress, do nothing - will commit when ready
403 }
404 }
405
406 #commit(
407 key: string,
408 channel: DebouncedChannel<
409 Args,
410 Result,
411 Optimistic,
412 Config["optimisticHelpers"]
413 >,
414 ) {
415 // Clear timer
416 if (channel.timer !== null) {
417 clearTimeout(channel.timer);
418 channel.timer = null;
419 }
420
421 // Safety check
422 if (channel.firstArgs === null || channel.initial === null) {
423 this.#setIdle(key, channel);
424 return;
425 }
426
427 const firstArgs = channel.firstArgs;
428 const initial = channel.initial;
429 const pendingItems = [...channel.pending];
430 const rollbacks = [...channel.rollbacks];
431 const refetchCallbacks = [...channel.refetches];
432
433 // Get current snapshot
434 const current = this.#options.getValue({
435 ...this.#client.context,
436 args: firstArgs,
437 });
438
439 // Check if anything changed
440 if (this.#client.deepEquals(initial, current)) {
441 // No change - resolve all pending with a null result and reset
442 pendingItems.forEach(({ resolve }) => resolve(null as Result));
443 this.#resetDebouncedState(channel);
444 this.#setIdle(key, channel);
445 return;
446 }
447
448 // Set status to mutating
449 channel.status = "mutating";
450 this.#notify(channel, "mutating");
451
452 // Clear debounced state before async operation (but keep rollbacks/refetches for error case)
453 channel.initial = null;
454 channel.firstArgs = null;
455 channel.pending = [];
456 channel.rollbacks = [];
457 channel.refetches = [];
458
459 // Call commit
460 this.#options
461 .commit({
462 ...this.#client.context,
463 args: firstArgs,
464 initial,
465 current,
466 })
467 .then((result) => {
468 // Success - rollbacks are discarded (optimistic was correct)
469 // Resolve all pending promises
470 pendingItems.forEach(({ resolve }) => resolve(result));
471
472 // Report success globally if any of the pending items requested it
473 const shouldReportSuccess = pendingItems.some((item) =>
474 item.reportSuccessGlobally
475 );
476 if (shouldReportSuccess) {
477 const message = this.#describeResult(
478 firstArgs,
479 initial,
480 current,
481 result,
482 );
483 if (message && this.#client.reportSuccess) {
484 this.#client.reportSuccess(message);
485 }
486 }
487
488 // Record commit time for throttle mode
489 channel.lastCommitTime = Date.now();
490
491 // Refetch
492 channel.status = "refetching";
493 this.#notify(channel, "refetching", result);
494 // Call refetch and all refetch callbacks in parallel
495 Promise.allSettled([
496 this.#options.refetch?.(),
497 ...refetchCallbacks.map((cb) => cb()),
498 ]).then((results) => {
499 // Report any errors from refetch or callbacks
500 results.forEach((result) => {
501 if (result.status === "rejected") {
502 const message = `Failed to refetch after ${
503 this.describe(...firstArgs)
504 }: ${errMessage(result.reason)}`;
505 this.#client.reportError(message, result.reason);
506 }
507 });
508 }).finally(() => {
509 // Check if new calls came in during the commit
510 if (channel.pending.length > 0) {
511 // There are pending calls that need to be committed
512 channel.status = "waiting";
513 this.#notify(channel, "waiting");
514 this.#scheduleCommit(key, channel);
515 } else {
516 this.#setIdle(key, channel);
517 }
518 });
519 })
520 .catch((error) => {
521 // Error - call all rollbacks in reverse order
522 let next;
523 const rollbacksCopy = [...rollbacks];
524 while ((next = rollbacksCopy.pop())) next();
525
526 // Reject all pending promises
527 pendingItems.forEach(({ reject }) => reject(error));
528
529 // Notify listeners of the error
530 this.#notify(channel, "mutating", null, error);
531
532 // Refetch to restore correct state
533 channel.status = "refetching";
534 this.#notify(channel, "refetching", null, error);
535 // Call refetch and all refetch callbacks in parallel
536 Promise.allSettled([
537 this.#options.refetch?.(),
538 ...refetchCallbacks.map((cb) => cb()),
539 ]).then((results) => {
540 // Report any errors from refetch or callbacks
541 results.forEach((result) => {
542 if (result.status === "rejected") {
543 const message = `Failed to refetch after ${
544 this.describe(...firstArgs)
545 }: ${errMessage(result.reason)}`;
546 this.#client.reportError(message, result.reason);
547 }
548 });
549 }).finally(() => {
550 // Check if new calls came in during the commit
551 if (channel.pending.length > 0) {
552 // There are pending calls that need to be committed
553 channel.status = "waiting";
554 this.#notify(channel, "waiting");
555 this.#scheduleCommit(key, channel);
556 } else {
557 this.#setIdle(key, channel);
558 }
559 });
560 });
561 }
562}
src/mod.ts+10-6
......@@ -1,9 +1,12 @@
1export type { MutationOptions, OptimisticContext } from "./queued.ts";
21export type {
3 BatchCommitContext,
4 BatchMutationOptions,
5 BatchOptimisticContext,
6} from "./batch.ts";
2 BlockingMutationOptions,
3 BlockingOptimisticContext,
4} from "./blocking.ts";
5export type {
6 DebouncedCommitContext,
7 DebouncedMutationOptions,
8 DebouncedOptimisticContext,
9} from "./debounced.ts";
710export {
811 MutationClient,
912 type MutationClientConfig,
......@@ -13,6 +16,7 @@ export {
1316export type { Mutation, MutationEvent } from "./types.ts";
1417export {
1518 createMutationButton,
19 type MutationButtonComponent,
1620 type MutationButtonProps,
1721 useMutate,
1822 type UseMutateError,
......@@ -20,4 +24,4 @@ export {
2024 type UseMutateResult,
2125 type UseMutateResultBase,
2226 type UseMutateSuccess,
23} from "./react.tsx";
27} from "./react.ts";
src/queued.ts deleted-347
......@@ -1,347 +0,0 @@
1import type { MutationClient, MutationClientFromConfig } from "./client.ts";
2import type { MutationClientConfig } from "./client.ts";
3import type { Mutation, MutationEvent } from "./types.ts";
4import { message as errMessage } from "@clo/lib/error.ts";
5
6/**
7 * Argument to `defineMutation`.
8 * @template Args - the parameters to the mutation
9 * @template Result - the result of the API call
10 * @template Config - global values and helpers from `MutationContext`
11 */
12export interface MutationOptions<
13 Args extends unknown[],
14 Result,
15 Config extends MutationClientConfig,
16> {
17 /**
18 * This function is only responsible for performing the underlying API call,
19 * syncronizing the optimistic state with reality. Throw on failure. A rest
20 * params type is used to allow type inference. Place this function first to
21 * ensure TypeScript correctly infers the argument type for the rest of the
22 * functions.
23 *
24 * In practice, optimistic context is never needed in this function, but it
25 * is provided as the `this` value if you truly desire it.
26 */
27 mutate: (this: Config["context"], ...args: Args) => Promise<Result>;
28 /**
29 * Used in error messages and debug tools.
30 * Phrase it considering the template `Failed to ${describe(...)}`
31 */
32 describe: string | ((context: Config["context"] & { args: Args }) => string);
33 /**
34 * Used in success messages.
35 * Phrase it as a complete success message, e.g., "Deleted item successfully"
36 * Set to null to suppress success reporting.
37 */
38 describeResult?: string | ((context: Config["context"] & { args: Args; result: Result }) => string) | null;
39 /**
40 * Specifying the optimistic strategy is required. To disable, pass an empty
41 * function with a comment to document why it isn't needed.
42 */
43 optimistic: (context: OptimisticContext<Args, Result, Config>) => void;
44 /**
45 * Refetch all of the data this mutation could have affected.
46 * Normally, optimistic helpers will perform
47 * This is called automatically on errors.
48 */
49 refetch?: (context: Config["context"] & { args: Args }) => Promise<void>;
50 /**
51 * If the optimistic updator function is perfect, then this may be set to false.
52 * @default true
53 */
54 refetchOnSuccess?: boolean;
55 /**
56 * A key to associate related items. For example, returning a user ID. If
57 * specifying, then all mutations of the same key will evaluate in serial,
58 * but optimistic updates will apply instantly.
59 */
60 key?: (context: Config["context"] & { args: Args }) => string | string[];
61}
62
63export type OptimisticContext<
64 Args extends unknown[],
65 Result,
66 Config extends MutationClientConfig,
67> = Config["context"] & {
68 args: Args;
69 helpers: Config["optimisticHelpers"];
70 /** Add an event listener to roll back the update */
71 onRestore: (cb: () => void) => void;
72 /** Add an event listener to apply `Result` to the store. */
73 onSuccess: (cb: (result: Result) => void) => void;
74};
75
76interface Channel<Args extends unknown[], Result> {
77 listeners: Set<(update: MutationEvent<Result>) => void>;
78 status: "idle" | "mutating" | "refetching";
79 rollbacks: Array<() => void>;
80 refetches: Array<() => Promise<void>>;
81 queue: Array<Item<Args, Result>>;
82}
83
84interface Item<Args extends unknown[], Result> {
85 args: Args;
86 rollbacks: number;
87 onSuccess: Array<(result: Result) => void>;
88 resolve: (result: Result) => void;
89 reject: (error: unknown) => void;
90}
91
92export class QueuedMutation<
93 Args extends unknown[],
94 Result,
95 Config extends MutationClientConfig,
96> implements Mutation<Args, Result> {
97 #options: MutationOptions<Args, Result, Config>;
98 #client: MutationClientFromConfig<Config>;
99 #queues: Map<string, Channel<Args, Result>> = new Map();
100 client: MutationClientFromConfig<Config>;
101
102 constructor(
103 client: MutationClient<Config["context"], Config["optimisticHelpers"]>,
104 options: MutationOptions<Args, Result, Config>,
105 ) {
106 this.#options = options;
107 this.#client = client;
108 this.client = client;
109 }
110
111 key(args: Args) {
112 const k = this.#options.key?.({ ...this.#client.context, args }) ??
113 "shared";
114 return JSON.stringify(k);
115 }
116
117 #getOrPutChannel(key: string) {
118 let channel = this.#queues.get(key);
119 if (!channel) {
120 const rollbacks: Array<() => []> = [];
121 channel = {
122 listeners: new Set(),
123 status: "idle",
124 rollbacks,
125 refetches: [],
126 queue: [],
127 };
128 this.#queues.set(key, channel);
129 }
130 return channel;
131 }
132
133 subscribe(
134 key: string,
135 cb: (update: MutationEvent<Result>) => void,
136 ): () => void {
137 const channel = this.#getOrPutChannel(key);
138 channel.listeners.add(cb);
139 return () => channel?.listeners.delete(cb);
140 }
141
142 #notify(
143 channel: Channel<Args, Result>,
144 status: MutationEvent<Result>["status"],
145 result: Result | null = null,
146 error: unknown = null,
147 ) {
148 const event: MutationEvent<Result> = { status, result, error };
149 channel.listeners.forEach((cb) => cb(event));
150 }
151
152 #setIdle(key: string, channel: Channel<Args, Result>) {
153 channel.status = "idle";
154 // Discard any unconsumed refetch callbacks
155 channel.refetches = [];
156 this.#notify(channel, "idle", null, null);
157 // Clean up the channel if there are no listeners
158 if (channel.listeners.size === 0) {
159 this.#queues.delete(key);
160 }
161 }
162
163 describe(...args: Args): string {
164 const { describe } = this.#options;
165 return typeof describe === "function"
166 ? describe({ ...this.#client.context, args })
167 : describe;
168 }
169
170 describeResult(args: Args, result: Result): string | undefined {
171 const { describeResult } = this.#options;
172 if (describeResult === null || describeResult === undefined) return undefined;
173 return typeof describeResult === "function"
174 ? describeResult({ ...this.#client.context, args, result })
175 : describeResult;
176 }
177
178 /** Calling the mutation in a global scope. Errors are turned into UI toasts. */
179 run(...args: Args) {
180 this.runAndReturn(...args).then((result) => {
181 const message = this.describeResult(args, result);
182 if (message && this.#client.reportSuccess) {
183 this.#client.reportSuccess(message);
184 }
185 }).catch((error) => {
186 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
187 this.#client.reportError(message, error);
188 });
189 }
190
191 /** Calls the mutation, treating the errors as promise rejection. */
192 runAndReturn(...args: Args): Promise<Result> {
193 const key = this.key(args);
194 const channel = this.#getOrPutChannel(key);
195
196 const onSuccess: Array<(result: Result) => void> = [];
197 let expired = false;
198 let rollbacks = 0;
199 const onRestore = (cb: () => void) => {
200 if (expired) {
201 throw new Error(
202 "Can only call onRestore from within the optimistic update function.",
203 );
204 }
205 channel.rollbacks.push(cb);
206 rollbacks += 1;
207 };
208 const onRefetch = (cb: () => Promise<void>) => {
209 if (expired) {
210 throw new Error(
211 "Can only call onRefetch from within the optimistic update function.",
212 );
213 }
214 channel.refetches.push(cb);
215 };
216
217 try {
218 this.#options.optimistic({
219 args,
220 helpers: this.#client.getOptimisticHelpers({
221 onRestore,
222 onRefetch,
223 }),
224 onRestore,
225 onSuccess(cb) {
226 if (expired) {
227 throw new Error(
228 "Can only call onSuccess from within the optimistic update function.",
229 );
230 }
231 onSuccess.push(cb);
232 },
233 });
234 } catch (error) {
235 expired = true;
236 let next;
237 while (
238 next =
239 channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0]
240 ) {
241 next();
242 }
243 return Promise.reject(error);
244 }
245 expired = true;
246
247 const { promise, resolve, reject } = Promise.withResolvers<Result>();
248 channel.queue.push({
249 args,
250 rollbacks,
251 onSuccess,
252 resolve,
253 reject,
254 });
255
256 if (channel.status === "idle") {
257 this.#executeNext(key, channel);
258 }
259
260 return promise;
261 }
262
263 #executeNext(key: string, channel: Channel<Args, Result>) {
264 const item = channel.queue.shift();
265 if (!item) {
266 this.#setIdle(key, channel);
267 return;
268 }
269
270 const { args, onSuccess, resolve, reject } = item;
271 channel.status = "mutating";
272 this.#notify(channel, "mutating");
273
274 this.#options.mutate.call(this.#client.context, ...args).then((result) => {
275 // remove rollbacks and apply optimistic success handlers
276 channel.rollbacks.splice(0, item.rollbacks);
277 onSuccess.forEach((cb) => cb(result));
278
279 if (this.#options.refetchOnSuccess !== false) {
280 channel.status = "refetching";
281 this.#notify(channel, "refetching", result);
282 // Call refetch and all refetch callbacks in parallel
283 const refetchCallbacks = channel.refetches.splice(0);
284 Promise.allSettled([
285 this.#options.refetch?.({
286 ...this.#client.context,
287 args,
288 }),
289 ...refetchCallbacks.map((cb) => cb()),
290 ]).then((results) => {
291 // Report any errors from refetch or callbacks
292 results.forEach((result) => {
293 if (result.status === "rejected") {
294 const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`;
295 this.#client.reportError(message, result.reason);
296 }
297 });
298 }).finally(() => {
299 this.#executeNext(key, channel);
300 });
301 } else {
302 // Discard refetch callbacks if refetchOnSuccess is false
303 channel.refetches = [];
304 this.#executeNext(key, channel);
305 }
306 resolve(result);
307 }, (error) => {
308 // if an error happens, then every rollback is called in reverse order
309 let next;
310 while (next = channel.rollbacks.pop()) next();
311
312 // Cancel all remaining items in the queue
313 const remainingItems = channel.queue.splice(0);
314 remainingItems.forEach((queuedItem) => {
315 queuedItem.reject(error);
316 });
317
318 // Notify listeners of the error
319 this.#notify(channel, "mutating", null, error);
320
321 // Refetch to restore correct state
322 channel.status = "refetching";
323 this.#notify(channel, "refetching", null, error);
324 // Call refetch and all refetch callbacks in parallel
325 const refetchCallbacks = channel.refetches.splice(0);
326 Promise.allSettled([
327 this.#options.refetch?.({
328 ...this.#client.context,
329 args,
330 }),
331 ...refetchCallbacks.map((cb) => cb()),
332 ]).then((results) => {
333 // Report any errors from refetch or callbacks
334 results.forEach((result) => {
335 if (result.status === "rejected") {
336 const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`;
337 this.#client.reportError(message, result.reason);
338 }
339 });
340 }).finally(() => {
341 this.#setIdle(key, channel);
342 });
343
344 reject(error);
345 });
346 }
347}
src/react.ts created+423
......@@ -0,0 +1,423 @@
1import {
2 type FC,
3 type MouseEvent,
4 type MouseEventHandler,
5 type ReactNode,
6 useCallback,
7 useEffect,
8 useState,
9} from "react";
10import { message as errMessage } from "@clo/lib/error.ts";
11import type { Mutation } from "./types.ts";
12import { jsx } from "react/jsx-runtime";
13
14/**
15 * Subscribe to a mutation's status, as well as accessing a local `run` method.
16 * The resulting mutation can be used directly or passed to a {@link createMutationButton|mutation button}.
17 */
18export function useMutate<
19 Args extends unknown[],
20 Result,
21>(
22 mutation: Mutation<Args, Result> | null,
23): UseMutateResult<Args, Result> {
24 const [_, setRerender] = useState(0);
25 const [observer] = useState(() => new Observer<Args, Result>(setRerender));
26 useEffect(() => () => void observer.reset(), []);
27 if (mutation !== observer.mutation) {
28 observer.mutation = mutation;
29 observer.reset();
30 }
31 return observer.binding;
32}
33
34export type UseMutateResult<Args extends unknown[], Result> =
35 & UseMutateResultBase<Args, Result>
36 & (
37 | UseMutateSuccess<Result>
38 | UseMutateError
39 | UseMutateIdle
40 );
41
42export interface UseMutateResultBase<Args extends unknown[], Result> {
43 run: (...args: Args) => void;
44 runWithResult: (...args: Args) => Promise<Result>;
45 clear: () => void;
46}
47
48export interface UseMutateSuccess<Result> {
49 status: "success";
50 result: Result;
51 error: undefined;
52 errorMessage: undefined;
53 /** `true` when a `mutate` function is currently running. */
54 isMutating: false;
55 /** `true` when a loading indicator should be shown. */
56 isPending: false;
57 /** `true` when a mutation has completed and has a result. */
58 isSuccess: true;
59 /** `true` when a mutation has failed. */
60 isError: false;
61 /** `true` when there is optimistic state applied. */
62 isOptimisticData: boolean;
63}
64export interface UseMutateError {
65 status: "error";
66 result: undefined;
67 error: unknown;
68 /** User-friendly in this format: `Failed to {action}: {details}` */
69 errorMessage: string;
70 /** `true` when a `mutate` function is currently running. */
71 isMutating: false;
72 /** `true` when a loading indicator should be shown. */
73 isPending: false;
74 /** `true` when a mutation has completed and has a result. */
75 isSuccess: false;
76 /** `true` when a mutation has failed. */
77 isError: true;
78 /** `true` when there is optimistic state applied. */
79 isOptimisticData: boolean;
80}
81export interface UseMutateIdle {
82 status: "idle" | "mutating";
83 result: undefined;
84 error: undefined;
85 errorMessage: undefined;
86 /** `true` when a `mutate` function is currently running. */
87 isMutating: boolean;
88 /** `true` when a loading indicator should be shown. */
89 isPending: boolean;
90 /** `true` when a mutation has completed and has a result. */
91 isSuccess: false;
92 /** `true` when a mutation has failed. */
93 isError: false;
94 /** `true` when there is optimistic state applied. */
95 isOptimisticData: boolean;
96}
97
98type AnyMutationStateWithoutRun<Result> =
99 & Omit<
100 UseMutateIdle,
101 "status" | "result" | "error" | "isSuccess" | "isError" | "errorMessage"
102 >
103 & {
104 status: "idle" | "mutating" | "error" | "success";
105 result: undefined | Result;
106 error: undefined | unknown;
107 errorMessage: undefined | string;
108 isSuccess: boolean;
109 isError: boolean;
110 };
111
112export type AnyMutationState<Args extends unknown[], Result> =
113 & AnyMutationStateWithoutRun<Result>
114 & UseMutateResultBase<Args, Result>;
115
116function initialState() {
117 return {
118 status: "idle",
119 result: undefined,
120 error: undefined,
121 errorMessage: undefined,
122 isMutating: false,
123 isPending: false,
124 isSuccess: false,
125 isError: false,
126 isOptimisticData: false,
127 } as const;
128}
129
130class Observer<Args extends unknown[], Result> {
131 setRerender: (fn: number) => void;
132 mutation: Mutation<Args, Result> | null = null;
133 unsubscribe: (() => void) | null = null;
134 currentKey: string | null = null;
135 currentArgs: Args | null = null;
136
137 constructor(setRerender: (fn: number) => void) {
138 this.setRerender = setRerender;
139 }
140
141 watched: Set<string> = new Set();
142 state: AnyMutationStateWithoutRun<Result> = initialState();
143 setState(newState: Partial<AnyMutationStateWithoutRun<Result>>) {
144 let updateUi = false;
145 const current: Record<string, unknown> = this.state;
146 for (const [key, value] of Object.entries(newState)) {
147 if (value !== current[key]) {
148 current[key] = value;
149 updateUi ||= this.watched.has(key);
150 }
151 }
152 if (updateUi) {
153 this.setRerender(Math.random());
154 }
155 }
156
157 reset() {
158 this.unsubscribe?.();
159 this.unsubscribe = null;
160 this.currentKey = null;
161 this.state = initialState();
162 }
163
164 computeErrorMessage(error: unknown): string | undefined {
165 if (!error) return undefined;
166 const mutation = this.mutation;
167 if (!mutation || !this.currentArgs) return errMessage(error);
168 return `Failed to ${mutation.describe(...this.currentArgs)}: ${
169 errMessage(error)
170 }`;
171 }
172
173 run(...args: Args) {
174 const mutation = this.mutation;
175 if (!mutation) return;
176 this.currentArgs = args;
177 const key = mutation.key(args);
178 if (key !== this.currentKey) {
179 this.currentKey = key;
180 this.unsubscribe?.();
181 this.unsubscribe = mutation.subscribe(
182 mutation.key(args),
183 ({ status, error, result }) => {
184 if (status === "idle") {
185 this.setState({
186 isMutating: false,
187 isPending: false,
188 isOptimisticData: false,
189 });
190 return;
191 }
192 const hasError = error != null;
193 const hasResult = result != null;
194
195 this.setState({
196 status: hasError
197 ? "error"
198 : hasResult
199 ? "success"
200 : status === "mutating"
201 ? "mutating"
202 : "idle",
203 error: error ?? undefined,
204 errorMessage: this.computeErrorMessage(error ?? undefined),
205 result: result ?? undefined,
206 isMutating: status === "mutating",
207 isPending: status === "mutating" || status === "refetching",
208 isSuccess: hasResult && !hasError,
209 isError: hasError,
210 isOptimisticData: status === "waiting" || status === "mutating" ||
211 status === "refetching",
212 });
213 },
214 );
215 }
216 // Use global error/success handling if this usage of the hook doesn't check for
217 // errors or success. This makes it act pretty awesome in terms of defaults.
218 // You don't have to worry about result UI, they'll surface exactly once.
219 const watchesError = this.watched.has("isError") ||
220 this.watched.has("error") || this.watched.has("errorMessage");
221 const watchesSuccess = this.watched.has("isSuccess") ||
222 this.watched.has("result");
223 const promise = mutation.runAndReturn(...args)
224 .then((result) => {
225 if (!watchesSuccess && mutation.describeResult) {
226 const message = mutation.describeResult(args, result);
227 if (message && mutation.client.reportSuccess) {
228 mutation.client.reportSuccess(message);
229 }
230 }
231 });
232 promise.catch((err) => {
233 if (!watchesError) {
234 const message = `Failed to ${mutation.describe(...args)}: ${
235 errMessage(err)
236 }`;
237 mutation.client.reportError(message, err);
238 }
239 });
240 return promise;
241 }
242
243 binding: UseMutateResult<Args, Result> = ((self: this) => ({
244 run(...args) {
245 return self.run(...args);
246 },
247 runWithResult(...args) {
248 return self.run(...args);
249 },
250 clear() {
251 self.setState({
252 status: ["error", "success"].includes(self.state.status)
253 ? "idle"
254 : self.state.status,
255 isError: false,
256 isSuccess: false,
257 error: undefined,
258 errorMessage: undefined,
259 result: undefined,
260 });
261 },
262 get status() {
263 self.watched.add("status");
264 return self.state.status;
265 },
266 get result() {
267 self.watched.add("result");
268 return self.state.result;
269 },
270 get error() {
271 self.watched.add("error");
272 return self.state.error;
273 },
274 get errorMessage() {
275 self.watched.add("errorMessage");
276 return self.state.errorMessage;
277 },
278 get isMutating() {
279 self.watched.add("isMutating");
280 return self.state.isMutating;
281 },
282 get isPending() {
283 self.watched.add("isPending");
284 return self.state.isPending;
285 },
286 get isSuccess() {
287 self.watched.add("isSuccess");
288 return self.state.isSuccess;
289 },
290 get isError() {
291 self.watched.add("isError");
292 return self.state.isError;
293 },
294 get isOptimisticData() {
295 self.watched.add("isOptimisticData");
296 return self.state.isOptimisticData;
297 },
298 } as UseMutateResult<Args, Result>))(this);
299}
300
301interface BaseButtonProps {
302 onClick: MouseEventHandler<HTMLElement> | undefined;
303 isPending: boolean;
304}
305
306export interface MutationButtonComponent<Props> {
307 <Args extends unknown[], Result>(
308 props:
309 & MutationButtonProps<Args, Result>
310 & Props,
311 ): ReactNode;
312 displayName?: string;
313}
314
315export interface MutationButtonProps<Args extends unknown[], Result> {
316 mutation:
317 | Mutation<Args, Result>
318 | UseMutateResult<Args, Result>;
319 /** Preventing default will interrupt the mutation */
320 args: Args | ((e: MouseEvent) => Args | null);
321 /** Preventing default will interrupt the mutation */
322 onClick?: (e: MouseEvent) => void;
323
324 /** Omitting this will use the global error handler */
325 onError?: (result: unknown) => void;
326 /** Omitting this will use the global success handler */
327 onSuccess?: (result: Result) => void;
328
329 /** Global event handlers will still be called! */
330 onSettled?: (
331 event: {
332 status: "success";
333 result: Result;
334 } | {
335 status: "error";
336 error: unknown;
337 },
338 ) => void;
339}
340
341/**
342 * Wraps a custom button component with logic to execute a mutation. The wrapped
343 * component must accept `onClick` and an `isPending` property. When the inner
344 * component emits `onClick`, that will begin the mutation. This is a trival
345 * abstraction on top of `useMutate`, but with type gymnastics to allow safe
346 * types.
347 */
348export function createMutationButton<Props>(
349 // Prevent calling this function if missing `onClick`
350 base: Required<Props> extends BaseButtonProps ? FC<Props>
351 : "Base component is missing required props",
352): MutationButtonComponent<Flatten<Omit<Props, keyof BaseButtonProps>>> {
353 const Component = base as ResolveMutationButtonFc<Props, unknown[], unknown>;
354 // apply the generics at a type level to allow `.bind` to work
355 type BareProps = Omit<Props, keyof BaseButtonProps>;
356 const bound = (GenericMutationButton<Props, unknown[], unknown>)
357 // the `as` clause here converts the second and third generic parameter
358 // back into unspecified generics.
359 .bind(null, Component) as MutationButtonComponent<BareProps>;
360 // react devtools loves display names
361 bound.displayName = `MutationButton[${
362 Component.displayName ?? Component.name
363 }]`;
364
365 return bound;
366}
367
368type Identity<T> = T;
369type Flatten<T> = Identity<{ [K in keyof T]: T[K] }>;
370type ResolveMutationButtonFc<Props, Args extends unknown[], Result> = FC<
371 & Omit<Props, keyof MutationButtonProps<Args, Result>>
372 & BaseButtonProps
373>;
374
375function GenericMutationButton<
376 Props,
377 Args extends unknown[],
378 Result,
379>(
380 Component: ResolveMutationButtonFc<Props, Args, Result>,
381 props: MutationButtonProps<Args, Result> & Props,
382) {
383 const {
384 mutation,
385 args,
386 onClick,
387 onError,
388 onSuccess,
389 onSettled,
390 ...forwarded
391 } = props;
392 forwarded satisfies Omit<Props, keyof MutationButtonProps<Args, Result>>;
393
394 const localHook = useMutate("subscribe" in mutation ? mutation : null);
395 const state = "subscribe" in mutation ? localHook : mutation;
396
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.
401 return jsx(
402 Component,
403 {
404 ...forwarded,
405 onClick: useCallback((e: MouseEvent) => {
406 onClick?.(e);
407 if (e.defaultPrevented) return;
408 const computedArgs = typeof args === "function" ? args(e) : args;
409 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 });
419 }, [state]),
420 isPending: state.isPending,
421 } satisfies Parameters<typeof Component>[0],
422 );
423}
src/react.tsx deleted-370
......@@ -1,370 +0,0 @@
1import {
2 type FC,
3 type MouseEvent,
4 type MouseEventHandler,
5 type ReactNode,
6 useCallback,
7 useEffect,
8 useState,
9} from "react";
10import { message as errMessage } from "@clo/lib/error.ts";
11import type { Mutation } from "./types.ts";
12
13/**
14 * Subscribe to a mutation's status, as well as accessing a local `run` method.
15 * The resulting mutation can be used directly or passed to a {@link createMutationButton|mutation button}.
16 */
17export function useMutate<
18 Args extends unknown[],
19 Result,
20>(
21 mutation: Mutation<Args, Result> | null,
22): UseMutateResult<Args, Result> {
23 const [_, setRerender] = useState(0);
24 const [observer] = useState(() => new Observer<Args, Result>(setRerender));
25 useEffect(() => () => void observer.reset(), []);
26 if (mutation !== observer.mutation) {
27 observer.mutation = mutation;
28 observer.reset();
29 }
30 return observer.binding;
31}
32
33export type UseMutateResult<Args extends unknown[], Result> =
34 & UseMutateResultBase<Args>
35 & (
36 | UseMutateSuccess<Result>
37 | UseMutateError
38 | UseMutateIdle
39 );
40
41export interface UseMutateResultBase<Args extends unknown[]> {
42 run: (...args: Args) => void;
43 clear: () => void;
44}
45
46export interface UseMutateSuccess<Result> {
47 status: "success";
48 result: Result;
49 error: undefined;
50 errorMessage: undefined;
51 /** `true` when a `mutate` function is currently running. */
52 isMutating: false;
53 /** `true` when a loading indicator should be shown. */
54 isPending: false;
55 /** `true` when a mutation has completed and has a result. */
56 isSuccess: true;
57 /** `true` when a mutation has failed. */
58 isError: false;
59 /** `true` when there is optimistic state applied. */
60 isOptimisticData: boolean;
61}
62export interface UseMutateError {
63 status: "error";
64 result: undefined;
65 error: unknown;
66 /** User-friendly in this format: `Failed to {action}: {details}` */
67 errorMessage: string;
68 /** `true` when a `mutate` function is currently running. */
69 isMutating: false;
70 /** `true` when a loading indicator should be shown. */
71 isPending: false;
72 /** `true` when a mutation has completed and has a result. */
73 isSuccess: false;
74 /** `true` when a mutation has failed. */
75 isError: true;
76 /** `true` when there is optimistic state applied. */
77 isOptimisticData: boolean;
78}
79export interface UseMutateIdle {
80 status: "idle" | "mutating";
81 result: undefined;
82 error: undefined;
83 errorMessage: undefined;
84 /** `true` when a `mutate` function is currently running. */
85 isMutating: boolean;
86 /** `true` when a loading indicator should be shown. */
87 isPending: boolean;
88 /** `true` when a mutation has completed and has a result. */
89 isSuccess: false;
90 /** `true` when a mutation has failed. */
91 isError: false;
92 /** `true` when there is optimistic state applied. */
93 isOptimisticData: boolean;
94}
95
96type AnyMutationState<Result> =
97 & Omit<
98 UseMutateIdle,
99 "status" | "result" | "error" | "isSuccess" | "isError" | "errorMessage"
100 >
101 & {
102 status: "idle" | "mutating" | "error" | "success";
103 result: undefined | Result;
104 error: undefined | unknown;
105 errorMessage: undefined | string;
106 isSuccess: boolean;
107 isError: boolean;
108 };
109
110function initialState() {
111 return {
112 status: "idle",
113 result: undefined,
114 error: undefined,
115 errorMessage: undefined,
116 isMutating: false,
117 isPending: false,
118 isSuccess: false,
119 isError: false,
120 isOptimisticData: false,
121 } as const;
122}
123
124class Observer<Args extends unknown[], Result> {
125 setRerender: (fn: number) => void;
126 mutation: Mutation<Args, Result> | null = null;
127 unsubscribe: (() => void) | null = null;
128 currentKey: string | null = null;
129 currentArgs: Args | null = null;
130
131 constructor(setRerender: (fn: number) => void) {
132 this.setRerender = setRerender;
133 }
134
135 watched: Set<string> = new Set();
136 state: AnyMutationState<Result> = initialState();
137 setState(newState: Partial<AnyMutationState<Result>>) {
138 let updateUi = false;
139 const current: Record<string, unknown> = this.state;
140 for (const [key, value] of Object.entries(newState)) {
141 if (value !== current[key]) {
142 current[key] = value;
143 updateUi ||= this.watched.has(key);
144 }
145 }
146 if (updateUi) {
147 this.setRerender(Math.random());
148 }
149 }
150
151 reset() {
152 this.unsubscribe?.();
153 this.unsubscribe = null;
154 this.currentKey = null;
155 this.state = initialState();
156 }
157
158 computeErrorMessage(error: unknown): string | undefined {
159 if (!error) return undefined;
160 const mutation = this.mutation;
161 if (!mutation || !this.currentArgs) return errMessage(error);
162 return `Failed to ${mutation.describe(...this.currentArgs)}: ${
163 errMessage(error)
164 }`;
165 }
166
167 binding: UseMutateResult<Args, Result> = ((self: this) => ({
168 run(...args: Args) {
169 const mutation = self.mutation;
170 if (!mutation) return;
171 self.currentArgs = args;
172 const key = mutation.key(args);
173 if (key !== self.currentKey) {
174 self.currentKey = key;
175 self.unsubscribe?.();
176 self.unsubscribe = mutation.subscribe(
177 mutation.key(args),
178 ({ status, error, result }) => {
179 if (status === "idle") {
180 self.setState({
181 isMutating: false,
182 isPending: false,
183 isOptimisticData: false,
184 });
185 return;
186 }
187 const hasError = error != null;
188 const hasResult = result != null;
189
190 self.setState({
191 status: hasError
192 ? "error"
193 : hasResult
194 ? "success"
195 : status === "mutating"
196 ? "mutating"
197 : "idle",
198 error: error ?? undefined,
199 errorMessage: self.computeErrorMessage(error ?? undefined),
200 result: result ?? undefined,
201 isMutating: status === "mutating",
202 isPending: status === "mutating" || status === "refetching",
203 isSuccess: hasResult && !hasError,
204 isError: hasError,
205 isOptimisticData: status === "waiting" || status === "mutating" ||
206 status === "refetching",
207 });
208 },
209 );
210 }
211 // Use global error/success handling if this usage of the hook doesn't check for
212 // errors or success. This makes it act pretty awesome in terms of defaults.
213 // You don't have to worry about result UI, they'll surface exactly once.
214 const watchesError = self.watched.has("isError") ||
215 self.watched.has("error") || self.watched.has("errorMessage");
216 const watchesSuccess = self.watched.has("isSuccess") ||
217 self.watched.has("result");
218 mutation.runAndReturn(...args)
219 .then((result) => {
220 if (!watchesSuccess && mutation.describeResult) {
221 const message = mutation.describeResult(args, result);
222 if (message && mutation.client.reportSuccess) {
223 mutation.client.reportSuccess(message);
224 }
225 }
226 })
227 .catch((err) => {
228 if (!watchesError) {
229 const message = `Failed to ${mutation.describe(...args)}: ${errMessage(err)}`;
230 mutation.client.reportError(message, err);
231 }
232 });
233 },
234 clear() {
235 self.setState({
236 status: ["error", "success"].includes(self.state.status)
237 ? "idle"
238 : self.state.status,
239 isError: false,
240 isSuccess: false,
241 error: undefined,
242 errorMessage: undefined,
243 result: undefined,
244 });
245 },
246 get status() {
247 self.watched.add("status");
248 return self.state.status;
249 },
250 get result() {
251 self.watched.add("result");
252 return self.state.result;
253 },
254 get error() {
255 self.watched.add("error");
256 return self.state.error;
257 },
258 get errorMessage() {
259 self.watched.add("errorMessage");
260 return self.state.errorMessage;
261 },
262 get isMutating() {
263 self.watched.add("isMutating");
264 return self.state.isMutating;
265 },
266 get isPending() {
267 self.watched.add("isPending");
268 return self.state.isPending;
269 },
270 get isSuccess() {
271 self.watched.add("isSuccess");
272 return self.state.isSuccess;
273 },
274 get isError() {
275 self.watched.add("isError");
276 return self.state.isError;
277 },
278 get isOptimisticData() {
279 self.watched.add("isOptimisticData");
280 return self.state.isOptimisticData;
281 },
282 } as UseMutateResult<Args, Result>))(this);
283}
284
285interface BaseButtonProps {
286 onClick: MouseEventHandler<HTMLElement> | undefined;
287 isPending: boolean;
288}
289
290interface MutationButtonComponent<Props> {
291 <Args extends unknown[], Result>(
292 props:
293 & MutationButtonProps<Args, Result>
294 & Props,
295 ): ReactNode;
296 displayName?: string;
297}
298
299export interface MutationButtonProps<Args extends unknown[], Result> {
300 mutation:
301 | Mutation<Args, Result>
302 | Pick<UseMutateResult<Args, Result>, "run" | "status" | "isPending">;
303 /** Preventing default will interrupt the mutation */
304 args: Args | ((e: MouseEvent) => Args | null);
305 /** Preventing default will interrupt the mutation */
306 onClick?: (e: MouseEvent) => void;
307}
308
309/**
310 * Wraps a custom button component with logic to execute a mutation. The wrapped
311 * component must accept `onClick` and an `isPending` property. When the inner
312 * component emits `onClick`, that will begin the mutation. This is a trival
313 * abstraction on top of `useMutate`, but with type gymnastics to allow safe
314 * types.
315 */
316export function createMutationButton<Props>(
317 // Prevent calling this function if missing `onClick`
318 base: Required<Props> extends BaseButtonProps ? FC<Props>
319 : "Base component is missing required props",
320): MutationButtonComponent<Flatten<Omit<Props, keyof BaseButtonProps>>> {
321 const Component = base as ResolveMutationButtonFc<Props, unknown[], unknown>;
322 // apply the generics at a type level to allow `.bind` to work
323 type BareProps = Omit<Props, keyof BaseButtonProps>;
324 const bound = (GenericMutationButton<Props, unknown[], unknown>)
325 // the `as` clause here converts the second and third generic parameter
326 // back into unspecified generics.
327 .bind(null, Component) as MutationButtonComponent<BareProps>;
328 // react devtools loves display names
329 bound.displayName = `MutationButton[${
330 Component.displayName ?? Component.name
331 }]`;
332
333 return bound;
334}
335
336type Identity<T> = T;
337type Flatten<T> = Identity<{ [K in keyof T]: T[K] }>;
338type ResolveMutationButtonFc<Props, Args extends unknown[], Result> = FC<
339 & Omit<Props, keyof MutationButtonProps<Args, Result>>
340 & BaseButtonProps
341>;
342
343function GenericMutationButton<
344 Props,
345 Args extends unknown[],
346 Result,
347>(
348 Component: ResolveMutationButtonFc<Props, Args, Result>,
349 props: MutationButtonProps<Args, Result> & Props,
350) {
351 const { mutation, args, onClick, ...forwarded } = props;
352 forwarded satisfies Omit<Props, keyof MutationButtonProps<Args, Result>>;
353
354 const localHook = useMutate("subscribe" in mutation ? mutation : null);
355 const state = "subscribe" in mutation ? localHook : mutation;
356
357 return (
358 <Component
359 {...forwarded}
360 onClick={useCallback((e: MouseEvent) => {
361 onClick?.(e);
362 if (e.defaultPrevented) return;
363 const computedArgs = typeof args === "function" ? args(e) : args;
364 if (!computedArgs || e.defaultPrevented) return;
365 state.run(...computedArgs);
366 }, [state])}
367 isPending={state.isPending}
368 />
369 );
370}
test/batch.test.ts deleted-1019
......@@ -1,1019 +0,0 @@
1import { assertEquals, assertRejects } from "@std/assert";
2import { MutationClient } from "../src/client.ts";
3import type { MutationEvent } from "../src/types.ts";
4import { test } from "vitest";
5
6// Shared test store for optimistic updates
7const testStore = new Map<string, number>();
8
9// Helper to create a test mutation client
10function createTestClient() {
11 const errors: unknown[] = [];
12 const client = new MutationClient({
13 context: { userId: "test-user" },
14 getOptimisticHelpers({ onRestore }) {
15 return {
16 increment(key: string, amount: number) {
17 const oldValue = testStore.get(key) ?? 0;
18 testStore.set(key, oldValue + amount);
19 onRestore(() => testStore.set(key, oldValue));
20 },
21 setValue(key: string, value: number) {
22 const oldValue = testStore.get(key);
23 testStore.set(key, value);
24 onRestore(() => {
25 if (oldValue === undefined) {
26 testStore.delete(key);
27 } else {
28 testStore.set(key, oldValue);
29 }
30 });
31 },
32 };
33 },
34 reportError(message, error) {
35 errors.push(error);
36 },
37 });
38
39 return { client, errors };
40}
41
42// Helper to track mutation events
43function createEventTracker<Result>() {
44 const events: Array<MutationEvent<Result>> = [];
45 const callback = (event: MutationEvent<Result>) => {
46 events.push({ ...event });
47 };
48 return { events, callback };
49}
50
51// Helper to wait for async operations
52function delay(ms: number) {
53 return new Promise((resolve) => setTimeout(resolve, ms));
54}
55
56// ============================================================================
57// Basic functionality tests
58// ============================================================================
59
60test("BatchMutation - basic mutation success with debounce", async () => {
61 const { client } = createTestClient();
62 testStore.clear();
63 testStore.set("counter", 0);
64
65 let commitCallCount = 0;
66 let refetchCallCount = 0;
67
68 const mutation = client.defineDebounced({
69 optimistic({ helpers }, amount: number) {
70 helpers.increment("counter", amount);
71 },
72 mode: "debounce",
73 time: 50,
74 key: () => "test-key",
75 getValue: (_) => testStore.get("counter") ?? 0,
76 async commit({ initial, current }) {
77 commitCallCount++;
78 await delay(10);
79 return current - initial;
80 },
81 describe: "increment counter",
82 async refetch() {
83 refetchCallCount++;
84 },
85 });
86
87 const result = await mutation.runAndReturn(5);
88 await delay(20); // Wait for refetch
89
90 assertEquals(result, 5);
91 assertEquals(commitCallCount, 1);
92 assertEquals(refetchCallCount, 1);
93 assertEquals(testStore.get("counter"), 5);
94});
95
96test("BatchMutation - run() catches errors", async () => {
97 const { client, errors } = createTestClient();
98 testStore.clear();
99 testStore.set("counter", 0);
100
101 const mutation = client.defineDebounced({
102 optimistic({ helpers }, amount: number) {
103 helpers.increment("counter", amount);
104 },
105 mode: "debounce",
106 time: 20,
107 key: () => "test-key",
108 getValue: (_) => testStore.get("counter") ?? 0,
109 async commit() {
110 throw new Error("commit failed");
111 },
112 describe: "failing mutation",
113 async refetch() {},
114 });
115
116 mutation.run(5);
117 await delay(100);
118
119 assertEquals(errors.length, 1);
120 assertEquals((errors[0] as Error).message, "commit failed");
121});
122
123test("BatchMutation - runAndReturn() rejects on error", async () => {
124 const { client } = createTestClient();
125 testStore.clear();
126 testStore.set("counter", 0);
127
128 const mutation = client.defineDebounced({
129 optimistic({ helpers }, amount: number) {
130 helpers.increment("counter", amount);
131 },
132 mode: "debounce",
133 time: 10,
134 key: () => "test-key",
135 getValue: (_) => testStore.get("counter") ?? 0,
136 async commit() {
137 throw new Error("commit failed");
138 },
139 describe: "failing mutation",
140 async refetch() {},
141 });
142
143 await assertRejects(
144 () => mutation.runAndReturn(5),
145 Error,
146 "commit failed",
147 );
148});
149
150// ============================================================================
151// Debounce mode tests
152// ============================================================================
153
154test("BatchMutation - debounce batches rapid calls", async () => {
155 const { client } = createTestClient();
156 testStore.clear();
157 testStore.set("counter", 0);
158
159 let commitCallCount = 0;
160 const commitArgs: Array<{ initial: number; current: number }> = [];
161
162 const mutation = client.defineDebounced({
163 optimistic({ helpers }, amount: number) {
164 helpers.increment("counter", amount);
165 },
166 mode: "debounce",
167 time: 50,
168 key: () => "test-key",
169 getValue: (_) => testStore.get("counter") ?? 0,
170 async commit({ initial, current }) {
171 commitCallCount++;
172 commitArgs.push({ initial, current });
173 return current - initial;
174 },
175 describe: "increment counter",
176 async refetch() {},
177 });
178
179 // Rapid calls within debounce window
180 const promise1 = mutation.runAndReturn(1);
181 const promise2 = mutation.runAndReturn(2);
182 const promise3 = mutation.runAndReturn(3);
183
184 // Optimistic updates should be applied immediately
185 assertEquals(testStore.get("counter"), 6);
186
187 const results = await Promise.all([promise1, promise2, promise3]);
188
189 // All should resolve with the same result (total delta)
190 assertEquals(results, [6, 6, 6]);
191
192 // Only one commit should have been made
193 assertEquals(commitCallCount, 1);
194 assertEquals(commitArgs, [{ initial: 0, current: 6 }]);
195});
196
197test("BatchMutation - debounce resets timer on each call", async () => {
198 const { client } = createTestClient();
199 testStore.clear();
200 testStore.set("counter", 0);
201
202 let commitCallCount = 0;
203
204 const mutation = client.defineDebounced({
205 optimistic({ helpers }, amount: number) {
206 helpers.increment("counter", amount);
207 },
208 mode: "debounce",
209 time: 30,
210 key: () => "test-key",
211 getValue: (_) => testStore.get("counter") ?? 0,
212 async commit({ initial, current }) {
213 commitCallCount++;
214 return current - initial;
215 },
216 describe: "increment counter",
217 async refetch() {},
218 });
219
220 // First call
221 const promise1 = mutation.runAndReturn(1);
222
223 // Wait less than debounce time
224 await delay(15);
225
226 // Second call should reset the timer
227 const promise2 = mutation.runAndReturn(2);
228
229 // Wait less than debounce time again
230 await delay(15);
231
232 // Commit should not have happened yet
233 assertEquals(commitCallCount, 0);
234
235 // Third call
236 const promise3 = mutation.runAndReturn(3);
237
238 // Wait for all to complete
239 await Promise.all([promise1, promise2, promise3]);
240
241 // Only one commit
242 assertEquals(commitCallCount, 1);
243});
244
245test("BatchMutation - debounce separates batches after timeout", async () => {
246 const { client } = createTestClient();
247 testStore.clear();
248 testStore.set("counter", 0);
249
250 let commitCallCount = 0;
251 const commitArgs: Array<{ initial: number; current: number }> = [];
252
253 const mutation = client.defineDebounced({
254 optimistic({ helpers }, amount: number) {
255 helpers.increment("counter", amount);
256 },
257 mode: "debounce",
258 time: 30,
259 key: () => "test-key",
260 getValue: (_) => testStore.get("counter") ?? 0,
261 async commit({ initial, current }) {
262 commitCallCount++;
263 commitArgs.push({ initial, current });
264 return current - initial;
265 },
266 describe: "increment counter",
267 async refetch() {},
268 });
269
270 // First batch
271 await mutation.runAndReturn(1);
272 await delay(50); // Wait for first batch to complete
273
274 // Second batch (after timeout)
275 await mutation.runAndReturn(2);
276 await delay(50);
277
278 // Two separate commits
279 assertEquals(commitCallCount, 2);
280 assertEquals(commitArgs, [
281 { initial: 0, current: 1 },
282 { initial: 1, current: 3 },
283 ]);
284});
285
286// ============================================================================
287// Throttle mode tests
288// ============================================================================
289
290test("BatchMutation - throttle commits immediately on first call", async () => {
291 const { client } = createTestClient();
292 testStore.clear();
293 testStore.set("counter", 0);
294
295 let commitTime = 0;
296 const startTime = Date.now();
297
298 const mutation = client.defineDebounced({
299 optimistic({ helpers }, amount: number) {
300 helpers.increment("counter", amount);
301 },
302 mode: "throttle",
303 time: 100,
304 key: () => "test-key",
305 getValue: (_) => testStore.get("counter") ?? 0,
306 async commit({ initial, current }) {
307 commitTime = Date.now() - startTime;
308 return current - initial;
309 },
310 describe: "increment counter",
311 async refetch() {},
312 });
313
314 await mutation.runAndReturn(5);
315
316 // First call should commit immediately (within a small tolerance)
317 assertEquals(commitTime < 20, true);
318});
319
320test("BatchMutation - throttle batches calls within time window", async () => {
321 const { client } = createTestClient();
322 testStore.clear();
323 testStore.set("counter", 0);
324
325 let commitCallCount = 0;
326 const commitArgs: Array<{ initial: number; current: number }> = [];
327
328 const mutation = client.defineDebounced({
329 optimistic({ helpers }, amount: number) {
330 helpers.increment("counter", amount);
331 },
332 mode: "throttle",
333 time: 100,
334 key: () => "test-key",
335 getValue: (_) => testStore.get("counter") ?? 0,
336 async commit({ initial, current }) {
337 commitCallCount++;
338 commitArgs.push({ initial, current });
339 await delay(10);
340 return current - initial;
341 },
342 describe: "increment counter",
343 async refetch() {},
344 });
345
346 // First call commits immediately
347 const promise1 = mutation.runAndReturn(1);
348 await delay(5);
349
350 // Second call within throttle window - should batch
351 const promise2 = mutation.runAndReturn(2);
352 await delay(5);
353
354 // Third call within throttle window - should batch with second
355 const promise3 = mutation.runAndReturn(3);
356
357 // Wait for first to complete
358 await promise1;
359
360 // First commit happened immediately
361 assertEquals(commitCallCount, 1);
362 assertEquals(commitArgs[0], { initial: 0, current: 1 });
363
364 // Wait for throttle window to pass and second batch to commit
365 await Promise.all([promise2, promise3]);
366 await delay(50);
367
368 // Second batch committed
369 assertEquals(commitCallCount, 2);
370 assertEquals(commitArgs[1], { initial: 1, current: 6 });
371});
372
373test("BatchMutation - throttle allows new batch after time window", async () => {
374 const { client } = createTestClient();
375 testStore.clear();
376 testStore.set("counter", 0);
377
378 let commitCallCount = 0;
379
380 const mutation = client.defineDebounced({
381 optimistic({ helpers }, amount: number) {
382 helpers.increment("counter", amount);
383 },
384 mode: "throttle",
385 time: 50,
386 key: () => "test-key",
387 getValue: (_) => testStore.get("counter") ?? 0,
388 async commit({ initial, current }) {
389 commitCallCount++;
390 return current - initial;
391 },
392 describe: "increment counter",
393 async refetch() {},
394 });
395
396 // First call
397 await mutation.runAndReturn(1);
398 await delay(10);
399
400 assertEquals(commitCallCount, 1);
401
402 // Wait for throttle window to pass
403 await delay(60);
404
405 // Second call should commit immediately
406 await mutation.runAndReturn(2);
407 await delay(10);
408
409 assertEquals(commitCallCount, 2);
410});
411
412// ============================================================================
413// No-op detection tests
414// ============================================================================
415
416test("BatchMutation - skips commit when value unchanged", async () => {
417 const { client } = createTestClient();
418 testStore.clear();
419 testStore.set("counter", 5);
420
421 let commitCallCount = 0;
422
423 const mutation = client.defineDebounced({
424 optimistic({ helpers }, amount: number) {
425 helpers.increment("counter", amount);
426 },
427 mode: "debounce",
428 time: 20,
429 key: () => "test-key",
430 getValue: (_) => testStore.get("counter") ?? 0,
431 async commit({ initial, current }) {
432 commitCallCount++;
433 return current - initial;
434 },
435 describe: "increment counter",
436 async refetch() {},
437 });
438
439 // +5 and -5 cancel out
440 const promise1 = mutation.runAndReturn(5);
441 const promise2 = mutation.runAndReturn(-5);
442
443 const [result1, result2] = await Promise.all([promise1, promise2]);
444
445 // No commit should have been made
446 assertEquals(commitCallCount, 0);
447
448 // Results should be null (no actual change)
449 assertEquals(result1, null);
450 assertEquals(result2, null);
451
452 // Store should be unchanged
453 assertEquals(testStore.get("counter"), 5);
454});
455
456test("BatchMutation - uses deepEquals for comparison", async () => {
457 const errors: unknown[] = [];
458 const objectStore: { value: { count: number } | null } = {
459 value: { count: 0 },
460 };
461
462 const client = new MutationClient({
463 context: {},
464 getOptimisticHelpers({ onRestore }) {
465 return {
466 setCount(count: number) {
467 const old = objectStore.value;
468 objectStore.value = { count };
469 onRestore(() => {
470 objectStore.value = old;
471 });
472 },
473 };
474 },
475 reportError(message, error) {
476 errors.push(error);
477 },
478 });
479
480 let commitCallCount = 0;
481
482 const mutation = client.defineDebounced({
483 optimistic({ helpers }, count: number) {
484 helpers.setCount(count);
485 },
486 mode: "debounce",
487 time: 20,
488 key: () => "test-key",
489 getValue: (_) => objectStore.value,
490 async commit() {
491 commitCallCount++;
492 return null;
493 },
494 describe: "set count",
495 async refetch() {},
496 });
497
498 // Set to same value (different object reference but same content)
499 await mutation.runAndReturn(0);
500 await delay(30);
501
502 // Should skip commit because value is deeply equal
503 assertEquals(commitCallCount, 0);
504});
505
506test("BatchMutation - custom deepEquals function", async () => {
507 const errors: unknown[] = [];
508 let compareCallCount = 0;
509
510 const client = new MutationClient({
511 context: {},
512 getOptimisticHelpers({ onRestore }) {
513 return {
514 increment(key: string, amount: number) {
515 const old = testStore.get(key) ?? 0;
516 testStore.set(key, old + amount);
517 onRestore(() => testStore.set(key, old));
518 },
519 };
520 },
521 reportError(message, error) {
522 errors.push(error);
523 },
524 deepEquals(a, b) {
525 compareCallCount++;
526 // Custom comparison
527 return a === b;
528 },
529 });
530
531 testStore.clear();
532 testStore.set("counter", 0);
533
534 const mutation = client.defineDebounced({
535 optimistic({ helpers }, amount: number) {
536 helpers.increment("counter", amount);
537 },
538 mode: "debounce",
539 time: 10,
540 key: () => "test-key",
541 getValue: (_) => testStore.get("counter") ?? 0,
542 async commit() {
543 throw new Error("commit failed");
544 },
545 describe: "failing mutation",
546 async refetch() {},
547 });
548
549 await mutation.runAndReturn(5).catch(() => {
550 // Expected to fail due to commit error
551 });
552 await delay(30);
553
554 // Custom deepEquals should have been called
555 assertEquals(compareCallCount > 0, true);
556});
557
558// ============================================================================
559// Rollback tests
560// ============================================================================
561
562test("BatchMutation - rollback on commit error", async () => {
563 const { client } = createTestClient();
564 testStore.clear();
565 testStore.set("counter", 10);
566
567 const mutation = client.defineDebounced({
568 optimistic({ helpers }, amount: number) {
569 helpers.increment("counter", amount);
570 },
571 mode: "debounce",
572 time: 20,
573 key: () => "test-key",
574 getValue: (_) => testStore.get("counter") ?? 0,
575 async commit() {
576 throw new Error("commit failed");
577 },
578 describe: "failing mutation",
579 async refetch() {},
580 });
581
582 // Optimistic update applied
583 const promise = mutation.runAndReturn(5);
584 assertEquals(testStore.get("counter"), 15);
585
586 await assertRejects(() => promise, Error, "commit failed");
587
588 // Should be rolled back
589 assertEquals(testStore.get("counter"), 10);
590});
591
592test("BatchMutation - error event includes error details", async () => {
593 const { client } = createTestClient();
594 testStore.clear();
595 testStore.set("counter", 0);
596
597 const tracker = createEventTracker<number>();
598
599 const mutation = client.defineDebounced({
600 optimistic({ helpers }, amount: number) {
601 helpers.increment("counter", amount);
602 },
603 mode: "debounce",
604 time: 20,
605 key: () => "test-key",
606 getValue: (_) => testStore.get("counter") ?? 0,
607 async commit() {
608 throw new Error("commit failed");
609 },
610 describe: "failing mutation",
611 async refetch() {},
612 });
613
614 const key = mutation.key([5]);
615 mutation.subscribe(key, tracker.callback);
616
617 await assertRejects(() => mutation.runAndReturn(5));
618 await delay(30);
619
620 // Should have error in events
621 const errorEvents = tracker.events.filter((e) => e.error !== null);
622 assertEquals(errorEvents.length > 0, true);
623 assertEquals((errorEvents[0]?.error as Error).message, "commit failed");
624});
625
626// ============================================================================
627// Key handling tests
628// ============================================================================
629
630test("BatchMutation - key() returns JSON stringified key", () => {
631 const { client } = createTestClient();
632 testStore.clear();
633
634 const mutation = client.defineDebounced({
635 optimistic(_ctx, _id: string) {},
636 mode: "debounce",
637 time: 20,
638 key: ({ args }) => args[0],
639 getValue: (_) => 0,
640 async commit() {
641 return null;
642 },
643 describe: "test mutation",
644 async refetch() {},
645 });
646
647 assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id"));
648});
649
650test("BatchMutation - key() can return array", () => {
651 const { client } = createTestClient();
652 testStore.clear();
653
654 const mutation = client.defineDebounced({
655 optimistic(_ctx, _id: string) {},
656 mode: "debounce",
657 time: 20,
658 key: ({ args }) => ["user", args[0]],
659 getValue: (_) => 0,
660 async commit() {
661 return null;
662 },
663 describe: "test mutation",
664 async refetch() {},
665 });
666
667 assertEquals(
668 mutation.key(["123"]),
669 JSON.stringify(["user", "123"]),
670 );
671});
672
673test("BatchMutation - different keys create separate batches", async () => {
674 const { client } = createTestClient();
675 testStore.clear();
676 testStore.set("counter-a", 0);
677 testStore.set("counter-b", 0);
678
679 let commitCallCount = 0;
680
681 const mutation = client.defineDebounced({
682 optimistic({ helpers }, key: string, amount: number) {
683 helpers.increment(`counter-${key}`, amount);
684 },
685 mode: "debounce",
686 time: 20,
687 key: ({ args }) => args[0],
688 getValue: (_, key) => testStore.get(`counter-${key}`) ?? 0,
689 async commit({ current }) {
690 commitCallCount++;
691 return current;
692 },
693 describe: "test mutation",
694 async refetch() {},
695 });
696
697 // Two different keys
698 const promise1 = mutation.runAndReturn("a", 5);
699 const promise2 = mutation.runAndReturn("b", 10);
700
701 await Promise.all([promise1, promise2]);
702 await delay(30);
703
704 // Should have two separate commits
705 assertEquals(commitCallCount, 2);
706 assertEquals(testStore.get("counter-a"), 5);
707 assertEquals(testStore.get("counter-b"), 10);
708});
709
710// ============================================================================
711// Describe tests
712// ============================================================================
713
714test("BatchMutation - describe() with string", () => {
715 const { client } = createTestClient();
716 testStore.clear();
717
718 const mutation = client.defineDebounced({
719 optimistic(_ctx, _amount: number) {},
720 mode: "debounce",
721 time: 20,
722 key: () => "test-key",
723 getValue: (_) => 0,
724 async commit() {
725 return null;
726 },
727 describe: "update counter",
728 async refetch() {},
729 });
730
731 assertEquals(mutation.describe(5), "update counter");
732});
733
734test("BatchMutation - describe() with function", () => {
735 const { client } = createTestClient();
736 testStore.clear();
737
738 const mutation = client.defineDebounced({
739 optimistic(_ctx, _amount: number) {},
740 mode: "debounce",
741 time: 20,
742 key: () => "test-key",
743 getValue: (_) => 0,
744 async commit() {
745 return null;
746 },
747 describe: ({ args }) => `increment by ${args[0]}`,
748 async refetch() {},
749 });
750
751 assertEquals(mutation.describe(5), "increment by 5");
752});
753
754// ============================================================================
755// Promise resolution tests
756// ============================================================================
757
758test("BatchMutation - all pending promises resolve with same result", async () => {
759 const { client } = createTestClient();
760 testStore.clear();
761 testStore.set("counter", 0);
762
763 const mutation = client.defineDebounced({
764 optimistic({ helpers }, amount: number) {
765 helpers.increment("counter", amount);
766 },
767 mode: "debounce",
768 time: 30,
769 key: () => "test-key",
770 getValue: (_) => testStore.get("counter") ?? 0,
771 async commit({ initial, current }) {
772 return { delta: current - initial, timestamp: Date.now() };
773 },
774 describe: "increment counter",
775 async refetch() {},
776 });
777
778 const promise1 = mutation.runAndReturn(1);
779 const promise2 = mutation.runAndReturn(2);
780 const promise3 = mutation.runAndReturn(3);
781
782 const [result1, result2, result3] = await Promise.all([
783 promise1,
784 promise2,
785 promise3,
786 ]);
787
788 // All should get the same result object
789 assertEquals(result1, result2);
790 assertEquals(result2, result3);
791 assertEquals(result1.delta, 6);
792});
793
794test("BatchMutation - all pending promises reject with same error", async () => {
795 const { client } = createTestClient();
796 testStore.clear();
797 testStore.set("counter", 0);
798
799 const mutation = client.defineDebounced({
800 optimistic({ helpers }, amount: number) {
801 helpers.increment("counter", amount);
802 },
803 mode: "debounce",
804 time: 30,
805 key: () => "test-key",
806 getValue: (_) => testStore.get("counter") ?? 0,
807 async commit() {
808 throw new Error("batch commit failed");
809 },
810 describe: "increment counter",
811 async refetch() {},
812 });
813
814 const promise1 = mutation.runAndReturn(1);
815 const promise2 = mutation.runAndReturn(2);
816 const promise3 = mutation.runAndReturn(3);
817
818 const errors: Error[] = [];
819 await Promise.all([
820 promise1.catch((e) => errors.push(e)),
821 promise2.catch((e) => errors.push(e)),
822 promise3.catch((e) => errors.push(e)),
823 ]);
824
825 // All should get the same error
826 assertEquals(errors.length, 3);
827 assertEquals(errors[0].message, "batch commit failed");
828 assertEquals(errors[1].message, "batch commit failed");
829 assertEquals(errors[2].message, "batch commit failed");
830});
831
832// ============================================================================
833// Edge case tests
834// ============================================================================
835
836test("BatchMutation - handles empty getValue result", async () => {
837 const { client } = createTestClient();
838 testStore.clear();
839
840 let commitCallCount = 0;
841
842 const mutation = client.defineDebounced({
843 optimistic({ helpers }, amount: number) {
844 helpers.setValue("nonexistent", amount);
845 },
846 mode: "debounce",
847 time: 20,
848 key: () => "test-key",
849 getValue: (_) => testStore.get("nonexistent"),
850 async commit({ initial, current }) {
851 commitCallCount++;
852 return { initial, current };
853 },
854 describe: "test mutation",
855 });
856
857 const result = await mutation.runAndReturn(5);
858 await delay(30);
859
860 assertEquals(commitCallCount, 1);
861 assertEquals(result.initial, undefined);
862 assertEquals(result.current, 5);
863});
864
865test("BatchMutation - channel cleanup after idle with no listeners", async () => {
866 const { client } = createTestClient();
867 testStore.clear();
868 testStore.set("counter", 0);
869
870 const mutation = client.defineDebounced({
871 optimistic({ helpers }, amount: number) {
872 helpers.increment("counter", amount);
873 },
874 mode: "debounce",
875 time: 20,
876 key: () => "test-key",
877 getValue: (_) => testStore.get("counter") ?? 0,
878 async commit({ initial, current }) {
879 return current - initial;
880 },
881 describe: "test mutation",
882 async refetch() {},
883 });
884
885 // Run mutation without subscribing
886 await mutation.runAndReturn(5);
887 await delay(30);
888
889 // Run another mutation - should work fine (channel recreated if needed)
890 const result = await mutation.runAndReturn(3);
891 await delay(30);
892
893 assertEquals(result, 3);
894 assertEquals(testStore.get("counter"), 8);
895});
896
897test("BatchMutation - default time is 200ms", async () => {
898 const { client } = createTestClient();
899 testStore.clear();
900 testStore.set("counter", 0);
901
902 let commitTime: number | null = null;
903 const startTime = Date.now();
904
905 const mutation = client.defineDebounced({
906 optimistic({ helpers }, amount: number) {
907 helpers.increment("counter", amount);
908 },
909 mode: "debounce",
910 // time not specified, should default to 200
911 key: () => "test-key",
912 getValue: (_) => testStore.get("counter") ?? 0,
913 async commit({ initial, current }) {
914 commitTime = Date.now() - startTime;
915 return current - initial;
916 },
917 describe: "test mutation",
918 async refetch() {},
919 });
920
921 await mutation.runAndReturn(5);
922
923 // Should commit after ~200ms (with some tolerance)
924 assertEquals(commitTime !== null, true);
925 assertEquals(commitTime! >= 180, true);
926 assertEquals(commitTime! <= 250, true);
927});
928
929test("BatchMutation - context is passed to getValue", async () => {
930 const { client } = createTestClient();
931 testStore.clear();
932 testStore.set("counter", 0);
933
934 let receivedUserId: string | undefined;
935
936 const mutation = client.defineDebounced({
937 optimistic({ helpers }, amount: number) {
938 helpers.increment("counter", amount);
939 },
940 mode: "debounce",
941 time: 20,
942 key: () => "test-key",
943 getValue: ({ userId }, _) => {
944 receivedUserId = userId;
945 return testStore.get("counter") ?? 0;
946 },
947 async commit({ initial, current }) {
948 return current - initial;
949 },
950 describe: "test mutation",
951 async refetch() {},
952 });
953
954 await mutation.runAndReturn(5);
955 await delay(30);
956
957 assertEquals(receivedUserId, "test-user");
958});
959
960test("BatchMutation - context is passed to commit", async () => {
961 const { client } = createTestClient();
962 testStore.clear();
963 testStore.set("counter", 0);
964
965 let receivedUserId: string | undefined;
966
967 const mutation = client.defineDebounced({
968 optimistic({ helpers }, amount: number) {
969 helpers.increment("counter", amount);
970 },
971 mode: "debounce",
972 time: 20,
973 key: () => "test-key",
974 getValue: (_) => testStore.get("counter") ?? 0,
975 async commit({ userId, initial, current }) {
976 receivedUserId = userId;
977 return current - initial;
978 },
979 describe: "test mutation",
980 async refetch() {},
981 });
982
983 await mutation.runAndReturn(5);
984 await delay(30);
985
986 assertEquals(receivedUserId, "test-user");
987});
988
989test("BatchMutation - first args are used for commit", async () => {
990 const { client } = createTestClient();
991 testStore.clear();
992 testStore.set("counter", 0);
993
994 let receivedArgs: [string, number] | undefined;
995
996 const mutation = client.defineDebounced({
997 optimistic({ helpers }, _label: string, amount: number) {
998 helpers.increment("counter", amount);
999 },
1000 mode: "debounce",
1001 time: 30,
1002 key: () => "test-key",
1003 getValue: (_) => testStore.get("counter") ?? 0,
1004 async commit({ args, initial, current }) {
1005 receivedArgs = args;
1006 return current - initial;
1007 },
1008 describe: "test mutation",
1009 async refetch() {},
1010 });
1011
1012 mutation.runAndReturn("first", 1);
1013 mutation.runAndReturn("second", 2);
1014 await mutation.runAndReturn("third", 3);
1015 await delay(10);
1016
1017 // Should use first args
1018 assertEquals(receivedArgs, ["first", 1]);
1019});
test/blocking.test.ts created+1006
......@@ -0,0 +1,1006 @@
1import { assertEquals, assertRejects } from "@std/assert";
2import { MutationClient } from "../src/client.ts";
3import type { MutationEvent } from "../src/types.ts";
4import { test } from "vitest";
5
6// Helper to create a test mutation client
7function createTestClient() {
8 const errors: Array<{ message: string; error: unknown }> = [];
9 const successes: string[] = [];
10 const client = new MutationClient({
11 context: { userId: "test-user" },
12 getOptimisticHelpers({ onRestore }) {
13 return {
14 setValue(key: string, value: string) {
15 testStore.set(key, value);
16 onRestore(() => testStore.delete(key));
17 },
18 };
19 },
20 reportError(message, error) {
21 errors.push({ message, error });
22 },
23 reportSuccess(message) {
24 successes.push(message);
25 },
26 });
27
28 return { client, errors, successes };
29}
30
31const testStore = new Map<string, string>();
32
33// Helper to track mutation events
34function createEventTracker<Result>() {
35 const events: Array<MutationEvent<Result>> = [];
36 const callback = (event: MutationEvent<Result>) => {
37 events.push(event);
38 };
39 return { events, callback };
40}
41
42// Helper to wait for async operations
43function delay(ms: number) {
44 return new Promise((resolve) => setTimeout(resolve, ms));
45}
46
47test("BlockingMutation - basic mutation success", async () => {
48 const { client } = createTestClient();
49 let mutateCallCount = 0;
50 let refetchCallCount = 0;
51
52 const mutation = client.defineBlocking({
53 async mutate(value: string) {
54 mutateCallCount++;
55 await delay(10);
56 return `result-${value}`;
57 },
58 describe: "test mutation",
59 describeResult: "Success",
60 optimistic() {
61 // Empty optimistic update
62 },
63 async refetch() {
64 refetchCallCount++;
65 await delay(5);
66 },
67 });
68
69 const result = await mutation.runAndReturn("test");
70 // Wait for refetch to complete
71 await delay(20);
72
73 assertEquals(result, "result-test");
74 assertEquals(mutateCallCount, 1);
75 assertEquals(refetchCallCount, 1);
76});
77
78test("BlockingMutation - run() catches errors", async () => {
79 const { client, errors } = createTestClient();
80
81 const mutation = client.defineBlocking({
82 async mutate(_value: string) {
83 throw new Error("mutation failed");
84 },
85 describe: "failing mutation",
86 describeResult: "Success",
87 optimistic() {},
88 async refetch() {},
89 });
90
91 mutation.run("test");
92 await delay(50);
93
94 assertEquals(errors.length, 1);
95 assertEquals((errors[0].error as Error).message, "mutation failed");
96});
97
98test("BlockingMutation - runAndReturn() rejects on error", async () => {
99 const { client } = createTestClient();
100
101 const mutation = client.defineBlocking({
102 async mutate(_value: string) {
103 throw new Error("mutation failed");
104 },
105 describe: "failing mutation",
106 describeResult: "Success",
107 optimistic() {},
108 async refetch() {},
109 });
110
111 await assertRejects(
112 () => mutation.runAndReturn("test"),
113 Error,
114 "mutation failed",
115 );
116});
117
118test("BlockingMutation - optimistic updates are applied immediately", async () => {
119 const { client } = createTestClient();
120 testStore.clear();
121
122 const mutation = client.defineBlocking({
123 async mutate(_key: string, value: string) {
124 await delay(50);
125 return value;
126 },
127 describe: "set value",
128 describeResult: "Success",
129 optimistic({ args, helpers }) {
130 const [key, value] = args;
131 helpers.setValue(key, value);
132 },
133 async refetch() {},
134 });
135
136 const promise = mutation.runAndReturn("key1", "value1");
137
138 // Optimistic update should be applied synchronously
139 assertEquals(testStore.get("key1"), "value1");
140
141 // Wait for mutation to complete
142 await promise;
143 await delay(10);
144});
145
146test("BlockingMutation - rollback on error", async () => {
147 const { client } = createTestClient();
148 testStore.clear();
149
150 const mutation = client.defineBlocking({
151 async mutate(_key: string, _value: string) {
152 await delay(10);
153 throw new Error("mutation failed");
154 },
155 describe: "failing mutation",
156 describeResult: "Success",
157 optimistic({ args, helpers }) {
158 const [key, value] = args;
159 helpers.setValue(key, value);
160 },
161 async refetch() {},
162 });
163
164 await assertRejects(() => mutation.runAndReturn("key1", "value1"));
165
166 // Optimistic update should be rolled back
167 assertEquals(testStore.has("key1"), false);
168});
169
170test("BlockingMutation - onSuccess callback is called", async () => {
171 const { client } = createTestClient();
172 const successResults: string[] = [];
173
174 const mutation = client.defineBlocking({
175 async mutate(value: string) {
176 return `result-${value}`;
177 },
178 describe: "test mutation",
179 describeResult: "Success",
180 optimistic({ onSuccess }) {
181 onSuccess((result) => {
182 successResults.push(result);
183 });
184 },
185 async refetch() {},
186 });
187
188 await mutation.runAndReturn("test");
189
190 assertEquals(successResults, ["result-test"]);
191});
192
193test("BlockingMutation - mutations with same key execute serially", async () => {
194 const { client } = createTestClient();
195 const executionOrder: string[] = [];
196
197 const mutation = client.defineBlocking({
198 async mutate(id: string) {
199 executionOrder.push(`start-${id}`);
200 await delay(20);
201 executionOrder.push(`end-${id}`);
202 return id;
203 },
204 describe: "test mutation",
205 describeResult: "Success",
206 optimistic() {},
207 async refetch() {},
208 refetchOnSuccess: false,
209 key() {
210 return "same-key";
211 },
212 });
213
214 // Start two mutations with the same key
215 const promise1 = mutation.runAndReturn("1");
216 const promise2 = mutation.runAndReturn("2");
217
218 await Promise.all([promise1, promise2]);
219 await delay(10);
220
221 // They should execute serially, not in parallel
222 assertEquals(executionOrder, ["start-1", "end-1", "start-2", "end-2"]);
223});
224
225test("BlockingMutation - mutations with different keys execute in parallel", async () => {
226 const { client } = createTestClient();
227 const executionOrder: string[] = [];
228
229 const mutation = client.defineBlocking({
230 async mutate(id: string) {
231 executionOrder.push(`start-${id}`);
232 await delay(20);
233 executionOrder.push(`end-${id}`);
234 return id;
235 },
236 describe: "test mutation",
237 describeResult: "Success",
238 optimistic() {},
239 async refetch() {},
240 key({ args }) {
241 const [id] = args;
242 return id;
243 },
244 });
245
246 // Start two mutations with different keys
247 const promise1 = mutation.runAndReturn("key1");
248 const promise2 = mutation.runAndReturn("key2");
249
250 await Promise.all([promise1, promise2]);
251
252 // They should start in parallel
253 assertEquals(executionOrder.slice(0, 2).sort(), ["start-key1", "start-key2"]);
254});
255
256test("BlockingMutation - key() returns JSON stringified key", () => {
257 const { client } = createTestClient();
258
259 const mutation = client.defineBlocking({
260 async mutate(id: string) {
261 return id;
262 },
263 describe: "test mutation",
264 describeResult: "Success",
265 optimistic() {},
266 async refetch() {},
267 key({ args }) {
268 const [id] = args;
269 return id;
270 },
271 });
272
273 assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id"));
274});
275
276test("BlockingMutation - key() defaults to 'shared' when no key function", () => {
277 const { client } = createTestClient();
278
279 const mutation = client.defineBlocking({
280 async mutate(id: string) {
281 return id;
282 },
283 describe: "test mutation",
284 describeResult: "Success",
285 optimistic() {},
286 async refetch() {},
287 });
288
289 assertEquals(mutation.key(["test-id"]), JSON.stringify("shared"));
290});
291
292test("BlockingMutation - key() can return array", () => {
293 const { client } = createTestClient();
294
295 const mutation = client.defineBlocking({
296 async mutate(_userId: string, _itemId: string) {
297 return "result";
298 },
299 describe: "test mutation",
300 describeResult: "Success",
301 optimistic() {},
302 async refetch() {},
303 key({ args }) {
304 const [userId, itemId] = args;
305 return [userId, itemId];
306 },
307 });
308
309 assertEquals(
310 mutation.key(["user1", "item1"]),
311 JSON.stringify(["user1", "item1"]),
312 );
313});
314
315test("BlockingMutation - describe() with string", () => {
316 const { client } = createTestClient();
317
318 const mutation = client.defineBlocking({
319 async mutate(value: string) {
320 return value;
321 },
322 describe: "create item",
323 describeResult: "Success",
324 optimistic() {},
325 async refetch() {},
326 });
327
328 assertEquals(mutation.describe("test"), "create item");
329});
330
331test("BlockingMutation - describe() with function", () => {
332 const { client } = createTestClient();
333
334 const mutation = client.defineBlocking({
335 async mutate(id: string) {
336 return id;
337 },
338 describe({ args }) {
339 const [id] = args;
340 return `delete item ${id}`;
341 },
342 optimistic() {},
343 async refetch() {},
344 });
345
346 assertEquals(mutation.describe("123"), "delete item 123");
347});
348
349test("BlockingMutation - describe() receives context", () => {
350 const { client } = createTestClient();
351
352 const mutation = client.defineBlocking({
353 async mutate(id: string) {
354 return id;
355 },
356 describe({ userId, args }) {
357 const [id] = args;
358 return `user ${userId} editing item ${id}`;
359 },
360 optimistic() {},
361 async refetch() {},
362 });
363
364 assertEquals(
365 mutation.describe("123"),
366 "user test-user editing item 123",
367 );
368});
369
370test("BlockingMutation - subscribe() tracks mutation events", async () => {
371 const { client } = createTestClient();
372 const tracker = createEventTracker<string>();
373
374 const mutation = client.defineBlocking({
375 async mutate(value: string) {
376 await delay(10);
377 return `result-${value}`;
378 },
379 describe: "test mutation",
380 describeResult: "Success",
381 optimistic() {},
382 async refetch() {
383 await delay(5);
384 },
385 });
386
387 const key = mutation.key(["test"]);
388 mutation.subscribe(key, tracker.callback);
389
390 await mutation.runAndReturn("test");
391 // Wait for refetch to complete
392 await delay(20);
393
394 // Should have received status updates
395 assertEquals(tracker.events.length >= 2, true);
396 assertEquals(tracker.events.some((e) => e.status === "mutating"), true);
397 assertEquals(tracker.events.some((e) => e.status === "refetching"), true);
398});
399
400test("BlockingMutation - unsubscribe stops receiving events", async () => {
401 const { client } = createTestClient();
402 const tracker = createEventTracker<string>();
403
404 const mutation = client.defineBlocking({
405 async mutate(value: string) {
406 await delay(10);
407 return value;
408 },
409 describe: "test mutation",
410 describeResult: "Success",
411 optimistic() {},
412 async refetch() {},
413 refetchOnSuccess: false,
414 });
415
416 const key = mutation.key(["test"]);
417 const unsubscribe = mutation.subscribe(key, tracker.callback);
418
419 unsubscribe();
420
421 await mutation.runAndReturn("test");
422 await delay(10);
423
424 // Should not have received any events
425 assertEquals(tracker.events.length, 0);
426});
427
428test("BlockingMutation - refetchOnSuccess can be disabled", async () => {
429 const { client } = createTestClient();
430 let refetchCallCount = 0;
431
432 const mutation = client.defineBlocking({
433 async mutate(_value: string) {
434 return _value;
435 },
436 describe: "test mutation",
437 describeResult: "Success",
438 optimistic() {},
439 async refetch() {
440 refetchCallCount++;
441 },
442 refetchOnSuccess: false,
443 });
444
445 await mutation.runAndReturn("test");
446
447 assertEquals(refetchCallCount, 0);
448});
449
450test("BlockingMutation - refetch is called on error", async () => {
451 const { client } = createTestClient();
452 let refetchCallCount = 0;
453
454 const mutation = client.defineBlocking({
455 async mutate(_value: string) {
456 throw new Error("mutation failed");
457 },
458 describe: "failing mutation",
459 describeResult: "Success",
460 optimistic() {},
461 async refetch() {
462 refetchCallCount++;
463 },
464 });
465
466 await assertRejects(() => mutation.runAndReturn("test"));
467
468 assertEquals(refetchCallCount, 1);
469});
470
471test("BlockingMutation - queued mutations are cancelled on error", async () => {
472 const { client } = createTestClient();
473 const executionOrder: string[] = [];
474
475 const mutation = client.defineBlocking({
476 async mutate(id: string) {
477 executionOrder.push(`start-${id}`);
478 await delay(10);
479 if (id === "1") {
480 throw new Error("first mutation failed");
481 }
482 executionOrder.push(`end-${id}`);
483 return id;
484 },
485 describe: "test mutation",
486 describeResult: "Success",
487 optimistic() {},
488 async refetch() {},
489 key() {
490 return "same-key";
491 },
492 });
493
494 const promise1 = mutation.runAndReturn("1");
495 const promise2 = mutation.runAndReturn("2");
496 const promise3 = mutation.runAndReturn("3");
497
498 await assertRejects(() => promise1, Error, "first mutation failed");
499 await assertRejects(() => promise2, Error, "first mutation failed");
500 await assertRejects(() => promise3, Error, "first mutation failed");
501
502 // Only the first mutation should start
503 assertEquals(executionOrder, ["start-1"]);
504});
505
506test("BlockingMutation - rollbacks are called in reverse order on error", async () => {
507 const { client } = createTestClient();
508 const rollbackOrder: number[] = [];
509
510 const mutation = client.defineBlocking({
511 async mutate(_value: string) {
512 throw new Error("mutation failed");
513 },
514 describe: "failing mutation",
515 describeResult: "Success",
516 optimistic({ onRestore }) {
517 onRestore(() => rollbackOrder.push(1));
518 onRestore(() => rollbackOrder.push(2));
519 onRestore(() => rollbackOrder.push(3));
520 },
521 async refetch() {},
522 });
523
524 await assertRejects(() => mutation.runAndReturn("test"));
525
526 // Rollbacks should be called in reverse order
527 assertEquals(rollbackOrder, [3, 2, 1]);
528});
529
530test("BlockingMutation - multiple mutations: rollbacks only affect failed mutation", async () => {
531 const { client } = createTestClient();
532 const rollbackOrder: string[] = [];
533
534 const mutation = client.defineBlocking({
535 async mutate(id: string) {
536 await delay(10);
537 if (id === "fail") {
538 throw new Error("mutation failed");
539 }
540 return id;
541 },
542 describe: "test mutation",
543 describeResult: "Success",
544 optimistic({ args: [id], onRestore }) {
545 onRestore(() => rollbackOrder.push(`rollback-${id}`));
546 },
547 async refetch() {},
548 key() {
549 return "same-key";
550 },
551 });
552
553 // First mutation succeeds
554 await mutation.runAndReturn("success");
555
556 // Second mutation fails
557 await assertRejects(() => mutation.runAndReturn("fail"));
558
559 // Only the failed mutation's rollback should be called
560 // And all rollbacks from queued items
561 assertEquals(rollbackOrder, ["rollback-fail"]);
562});
563
564test("BlockingMutation - onRestore throws error if called after optimistic phase", async () => {
565 const { client } = createTestClient();
566 let capturedOnRestore: ((cb: () => void) => void) | null = null;
567
568 const mutation = client.defineBlocking({
569 async mutate(_value: string) {
570 return "result";
571 },
572 describe: "test mutation",
573 describeResult: "Success",
574 optimistic({ onRestore }) {
575 capturedOnRestore = onRestore;
576 },
577 async refetch() {},
578 });
579
580 await mutation.runAndReturn("test");
581
582 // Calling onRestore after the optimistic phase should throw
583 let error: Error | null = null;
584 try {
585 capturedOnRestore!(() => {});
586 } catch (e) {
587 error = e as Error;
588 }
589
590 assertEquals(
591 error?.message,
592 "Can only call onRestore from within the optimistic update function.",
593 );
594});
595
596test("BlockingMutation - onSuccess throws error if called after optimistic phase", async () => {
597 const { client } = createTestClient();
598 let capturedOnSuccess: ((cb: (result: string) => void) => void) | null = null;
599
600 const mutation = client.defineBlocking({
601 async mutate(_value: string) {
602 return "result";
603 },
604 describe: "test mutation",
605 describeResult: "Success",
606 optimistic({ onSuccess }) {
607 capturedOnSuccess = onSuccess;
608 },
609 async refetch() {},
610 });
611
612 await mutation.runAndReturn("test");
613
614 // Calling onSuccess after the optimistic phase should throw
615 let error: Error | null = null;
616 try {
617 capturedOnSuccess!(() => {});
618 } catch (e) {
619 error = e as Error;
620 }
621
622 assertEquals(
623 error?.message,
624 "Can only call onSuccess from within the optimistic update function.",
625 );
626});
627
628test("BlockingMutation - error during optimistic update is rejected immediately", async () => {
629 const { client } = createTestClient();
630
631 const mutation = client.defineBlocking({
632 async mutate(_value: string) {
633 return "result";
634 },
635 describe: "test mutation",
636 describeResult: "Success",
637 optimistic() {
638 throw new Error("optimistic update failed");
639 },
640 async refetch() {},
641 });
642
643 await assertRejects(
644 () => mutation.runAndReturn("test"),
645 Error,
646 "optimistic update failed",
647 );
648});
649
650test("BlockingMutation - error during optimistic update rolls back registered callbacks", async () => {
651 const { client } = createTestClient();
652 const rollbackOrder: number[] = [];
653
654 const mutation = client.defineBlocking({
655 async mutate(_value: string) {
656 return "result";
657 },
658 describe: "test mutation",
659 describeResult: "Success",
660 optimistic({ onRestore }) {
661 onRestore(() => rollbackOrder.push(1));
662 onRestore(() => rollbackOrder.push(2));
663 throw new Error("optimistic update failed");
664 },
665 async refetch() {},
666 });
667
668 await assertRejects(() => mutation.runAndReturn("test"));
669
670 // Rollbacks should be called even though optimistic update failed
671 // Note: during optimistic error, rollbacks are executed in the order they were added
672 assertEquals(rollbackOrder, [1, 2]);
673});
674
675test("BlockingMutation - refetch errors are reported but don't fail mutation", async () => {
676 const { client, errors } = createTestClient();
677
678 const mutation = client.defineBlocking({
679 async mutate(value: string) {
680 return value;
681 },
682 describe: "test mutation",
683 describeResult: "Success",
684 optimistic() {},
685 async refetch() {
686 throw new Error("refetch failed");
687 },
688 });
689
690 // Mutation should still succeed
691 const result = await mutation.runAndReturn("test");
692 assertEquals(result, "test");
693
694 // But refetch error should be reported
695 await delay(20);
696 assertEquals(errors.length, 1);
697 assertEquals((errors[0].error as Error).message, "refetch failed");
698});
699
700test("BlockingMutation - optimistic function receives args and helpers", async () => {
701 const { client } = createTestClient();
702 let receivedArgs: unknown[] | undefined;
703 let receivedHelpers: unknown | undefined;
704
705 const mutation = client.defineBlocking({
706 async mutate(_value: string) {
707 return "result";
708 },
709 describe: "test mutation",
710 describeResult: "Success",
711 optimistic({ args, helpers }) {
712 receivedArgs = args;
713 receivedHelpers = helpers;
714 },
715 async refetch() {},
716 });
717
718 await mutation.runAndReturn("test");
719
720 assertEquals(receivedArgs, ["test"]);
721 assertEquals(typeof receivedHelpers, "object");
722});
723
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.defineBlocking({
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 () => {
749 const { client } = createTestClient();
750 const tracker = createEventTracker<string>();
751
752 const mutation = client.defineBlocking({
753 async mutate(_value: string) {
754 await delay(10);
755 throw new Error("mutation failed");
756 },
757 describe: "failing mutation",
758 describeResult: "Success",
759 optimistic() {},
760 async refetch() {},
761 });
762
763 const key = mutation.key(["test"]);
764 mutation.subscribe(key, tracker.callback);
765
766 await assertRejects(() => mutation.runAndReturn("test"));
767
768 // Should have error event
769 const errorEvents = tracker.events.filter((e) =>
770 e.status === "mutating" && e.error
771 );
772 assertEquals(errorEvents.length > 0, true);
773 assertEquals((errorEvents[0]?.error as Error).message, "mutation failed");
774});
775
776test("BlockingMutation - multiple subscribers receive events", async () => {
777 const { client } = createTestClient();
778 const tracker1 = createEventTracker<string>();
779 const tracker2 = createEventTracker<string>();
780
781 const mutation = client.defineBlocking({
782 async mutate(value: string) {
783 await delay(5);
784 return value;
785 },
786 describe: "test mutation",
787 describeResult: "Success",
788 optimistic() {},
789 async refetch() {},
790 refetchOnSuccess: false,
791 });
792
793 const key = mutation.key(["test"]);
794 mutation.subscribe(key, tracker1.callback);
795 mutation.subscribe(key, tracker2.callback);
796
797 await mutation.runAndReturn("test");
798 await delay(10);
799
800 // Both subscribers should receive events
801 assertEquals(tracker1.events.length, tracker2.events.length);
802 assertEquals(tracker1.events.length > 0, true);
803});
804
805test("BlockingMutation - onSuccess is called before mutation resolves", async () => {
806 const { client } = createTestClient();
807 const callOrder: string[] = [];
808
809 const mutation = client.defineBlocking({
810 async mutate(value: string) {
811 return value;
812 },
813 describe: "test mutation",
814 describeResult: "Success",
815 optimistic({ onSuccess }) {
816 onSuccess(() => {
817 callOrder.push("onSuccess");
818 });
819 },
820 async refetch() {},
821 refetchOnSuccess: false,
822 });
823
824 const promise = mutation.runAndReturn("test");
825 promise.then(() => {
826 callOrder.push("then");
827 });
828
829 await promise;
830 await delay(5);
831
832 // onSuccess should be called before the promise resolves
833 assertEquals(callOrder, ["onSuccess", "then"]);
834});
835
836test("BlockingMutation - result is passed to notification on success", async () => {
837 const { client } = createTestClient();
838 const tracker = createEventTracker<string>();
839
840 const mutation = client.defineBlocking({
841 async mutate(value: string) {
842 await delay(5);
843 return `result-${value}`;
844 },
845 describe: "test mutation",
846 describeResult: "Success",
847 optimistic() {},
848 async refetch() {
849 await delay(5);
850 },
851 });
852
853 const key = mutation.key(["test"]);
854 mutation.subscribe(key, tracker.callback);
855
856 await mutation.runAndReturn("test");
857 await delay(20);
858
859 // Should have refetching event with result
860 const refetchingEvents = tracker.events.filter((e) =>
861 e.status === "refetching"
862 );
863 assertEquals(refetchingEvents.length > 0, true);
864 assertEquals(refetchingEvents[0]?.result, "result-test");
865});
866
867test("BlockingMutation - channel is reused for same key", async () => {
868 const { client } = createTestClient();
869 const events: string[] = [];
870
871 const mutation = client.defineBlocking({
872 async mutate(value: string) {
873 events.push(`mutate-${value}`);
874 return value;
875 },
876 describe: "test mutation",
877 describeResult: "Success",
878 optimistic() {},
879 async refetch() {},
880 refetchOnSuccess: false,
881 });
882
883 // First mutation
884 await mutation.runAndReturn("first");
885 await delay(5);
886
887 // Second mutation with same key
888 await mutation.runAndReturn("second");
889 await delay(5);
890
891 assertEquals(events, ["mutate-first", "mutate-second"]);
892});
893
894test("BlockingMutation - empty queue after all mutations complete", async () => {
895 const { client } = createTestClient();
896
897 const mutation = client.defineBlocking({
898 async mutate(value: string) {
899 await delay(5);
900 return value;
901 },
902 describe: "test mutation",
903 describeResult: "Success",
904 optimistic() {},
905 async refetch() {},
906 refetchOnSuccess: false,
907 key() {
908 return "test-key";
909 },
910 });
911
912 // Run multiple mutations
913 await mutation.runAndReturn("1");
914 await mutation.runAndReturn("2");
915 await mutation.runAndReturn("3");
916 await delay(10);
917
918 // All mutations should have completed
919 // (We can't directly check the queue, but we can verify by running another mutation)
920 const start = Date.now();
921 await mutation.runAndReturn("4");
922 const duration = Date.now() - start;
923
924 // Should execute immediately, not be queued (< 10ms if not queued)
925 assertEquals(duration < 15, true);
926});
927
928test("BlockingMutation - multiple onSuccess callbacks are all called", async () => {
929 const { client } = createTestClient();
930 const results: string[] = [];
931
932 const mutation = client.defineBlocking({
933 async mutate(value: string) {
934 return value;
935 },
936 describe: "test mutation",
937 describeResult: "Success",
938 optimistic({ onSuccess }) {
939 onSuccess((result) => results.push(`first-${result}`));
940 onSuccess((result) => results.push(`second-${result}`));
941 onSuccess((result) => results.push(`third-${result}`));
942 },
943 async refetch() {},
944 refetchOnSuccess: false,
945 });
946
947 await mutation.runAndReturn("test");
948
949 assertEquals(results, ["first-test", "second-test", "third-test"]);
950});
951
952test("BlockingMutation - refetchOnSuccess false skips refetch", async () => {
953 const { client } = createTestClient();
954 let refetchCalled = false;
955
956 const mutation = client.defineBlocking({
957 async mutate(value: string) {
958 return value;
959 },
960 describe: "test mutation",
961 describeResult: "Success",
962 optimistic() {},
963 async refetch() {
964 refetchCalled = true;
965 },
966 refetchOnSuccess: false,
967 });
968
969 await mutation.runAndReturn("test");
970 await delay(10);
971
972 // Refetch should not have been called
973 assertEquals(refetchCalled, false);
974});
975
976test("BlockingMutation - refetch error after mutation failure is reported", async () => {
977 const { client, errors } = createTestClient();
978
979 const mutation = client.defineBlocking({
980 async mutate(_value: string) {
981 throw new Error("mutation failed");
982 },
983 describe: "failing mutation",
984 describeResult: "Success",
985 optimistic() {},
986 async refetch() {
987 throw new Error("refetch also failed");
988 },
989 });
990
991 await assertRejects(
992 () => mutation.runAndReturn("test"),
993 Error,
994 "mutation failed",
995 );
996
997 // Wait for refetch to complete and error to be reported
998 await delay(20);
999
1000 // Should have both the mutation error and refetch error reported
1001 assertEquals(errors.length >= 1, true);
1002 assertEquals(
1003 (errors[errors.length - 1].error as Error).message,
1004 "refetch also failed",
1005 );
1006});
test/debounced.test.ts created+1050
......@@ -0,0 +1,1050 @@
1import { assertEquals, assertRejects } from "@std/assert";
2import { MutationClient } from "../src/client.ts";
3import type { MutationEvent } from "../src/types.ts";
4import { test } from "vitest";
5
6// Shared test store for optimistic updates
7const testStore = new Map<string, number>();
8
9// Helper to create a test mutation client
10function createTestClient() {
11 const errors: Array<{ message: string; error: unknown }> = [];
12 const successes: string[] = [];
13 const client = new MutationClient({
14 context: { userId: "test-user" },
15 getOptimisticHelpers({ onRestore }) {
16 return {
17 increment(key: string, amount: number) {
18 const oldValue = testStore.get(key) ?? 0;
19 testStore.set(key, oldValue + amount);
20 onRestore(() => testStore.set(key, oldValue));
21 },
22 setValue(key: string, value: number) {
23 const oldValue = testStore.get(key);
24 testStore.set(key, value);
25 onRestore(() => {
26 if (oldValue === undefined) {
27 testStore.delete(key);
28 } else {
29 testStore.set(key, oldValue);
30 }
31 });
32 },
33 };
34 },
35 reportError(message, error) {
36 errors.push({ message, error });
37 },
38 reportSuccess(message) {
39 successes.push(message);
40 },
41 });
42
43 return { client, errors, successes };
44}
45
46// Helper to track mutation events
47function createEventTracker<Result>() {
48 const events: Array<MutationEvent<Result>> = [];
49 const callback = (event: MutationEvent<Result>) => {
50 events.push({ ...event });
51 };
52 return { events, callback };
53}
54
55// Helper to wait for async operations
56function delay(ms: number) {
57 return new Promise((resolve) => setTimeout(resolve, ms));
58}
59
60// ============================================================================
61// Basic functionality tests
62// ============================================================================
63
64test("DebouncedMutation - basic mutation success with debounce", async () => {
65 const { client } = createTestClient();
66 testStore.clear();
67 testStore.set("counter", 0);
68
69 let commitCallCount = 0;
70 let refetchCallCount = 0;
71
72 const mutation = client.defineDebounced({
73 optimistic({ helpers }, amount: number) {
74 helpers.increment("counter", amount);
75 },
76 mode: "debounce",
77 time: 50,
78 key: () => "test-key",
79 getValue: (_) => testStore.get("counter") ?? 0,
80 async commit({ initial, current }) {
81 commitCallCount++;
82 await delay(10);
83 return current - initial;
84 },
85 describe: "increment counter",
86 describeResult: "Success",
87 async refetch() {
88 refetchCallCount++;
89 },
90 });
91
92 const result = await mutation.runAndReturn(5);
93 await delay(20); // Wait for refetch
94
95 assertEquals(result, 5);
96 assertEquals(commitCallCount, 1);
97 assertEquals(refetchCallCount, 1);
98 assertEquals(testStore.get("counter"), 5);
99});
100
101test("DebouncedMutation - run() catches errors", async () => {
102 const { client, errors } = createTestClient();
103 testStore.clear();
104 testStore.set("counter", 0);
105
106 const mutation = client.defineDebounced({
107 optimistic({ helpers }, amount: number) {
108 helpers.increment("counter", amount);
109 },
110 mode: "debounce",
111 time: 20,
112 key: () => "test-key",
113 getValue: (_) => testStore.get("counter") ?? 0,
114 async commit() {
115 throw new Error("commit failed");
116 },
117 describe: "failing mutation",
118 describeResult: "Success",
119 async refetch() {},
120 });
121
122 mutation.run(5);
123 await delay(100);
124
125 assertEquals(errors.length, 1);
126 assertEquals((errors[0].error as Error).message, "commit failed");
127});
128
129test("DebouncedMutation - runAndReturn() rejects on error", async () => {
130 const { client } = createTestClient();
131 testStore.clear();
132 testStore.set("counter", 0);
133
134 const mutation = client.defineDebounced({
135 optimistic({ helpers }, amount: number) {
136 helpers.increment("counter", amount);
137 },
138 mode: "debounce",
139 time: 10,
140 key: () => "test-key",
141 getValue: (_) => testStore.get("counter") ?? 0,
142 async commit() {
143 throw new Error("commit failed");
144 },
145 describe: "failing mutation",
146 describeResult: "Success",
147 async refetch() {},
148 });
149
150 await assertRejects(
151 () => mutation.runAndReturn(5),
152 Error,
153 "commit failed",
154 );
155});
156
157// ============================================================================
158// Debounce mode tests
159// ============================================================================
160
161test("DebouncedMutation - debounce batches rapid calls", async () => {
162 const { client } = createTestClient();
163 testStore.clear();
164 testStore.set("counter", 0);
165
166 let commitCallCount = 0;
167 const commitArgs: Array<{ initial: number; current: number }> = [];
168
169 const mutation = client.defineDebounced({
170 optimistic({ helpers }, amount: number) {
171 helpers.increment("counter", amount);
172 },
173 mode: "debounce",
174 time: 50,
175 key: () => "test-key",
176 getValue: (_) => testStore.get("counter") ?? 0,
177 async commit({ initial, current }) {
178 commitCallCount++;
179 commitArgs.push({ initial, current });
180 return current - initial;
181 },
182 describe: "increment counter",
183 describeResult: "Success",
184 async refetch() {},
185 });
186
187 // Rapid calls within debounce window
188 const promise1 = mutation.runAndReturn(1);
189 const promise2 = mutation.runAndReturn(2);
190 const promise3 = mutation.runAndReturn(3);
191
192 // Optimistic updates should be applied immediately
193 assertEquals(testStore.get("counter"), 6);
194
195 const results = await Promise.all([promise1, promise2, promise3]);
196
197 // All should resolve with the same result (total delta)
198 assertEquals(results, [6, 6, 6]);
199
200 // Only one commit should have been made
201 assertEquals(commitCallCount, 1);
202 assertEquals(commitArgs, [{ initial: 0, current: 6 }]);
203});
204
205test("DebouncedMutation - debounce resets timer on each call", async () => {
206 const { client } = createTestClient();
207 testStore.clear();
208 testStore.set("counter", 0);
209
210 let commitCallCount = 0;
211
212 const mutation = client.defineDebounced({
213 optimistic({ helpers }, amount: number) {
214 helpers.increment("counter", amount);
215 },
216 mode: "debounce",
217 time: 30,
218 key: () => "test-key",
219 getValue: (_) => testStore.get("counter") ?? 0,
220 async commit({ initial, current }) {
221 commitCallCount++;
222 return current - initial;
223 },
224 describe: "increment counter",
225 describeResult: "Success",
226 async refetch() {},
227 });
228
229 // First call
230 const promise1 = mutation.runAndReturn(1);
231
232 // Wait less than debounce time
233 await delay(15);
234
235 // Second call should reset the timer
236 const promise2 = mutation.runAndReturn(2);
237
238 // Wait less than debounce time again
239 await delay(15);
240
241 // Commit should not have happened yet
242 assertEquals(commitCallCount, 0);
243
244 // Third call
245 const promise3 = mutation.runAndReturn(3);
246
247 // Wait for all to complete
248 await Promise.all([promise1, promise2, promise3]);
249
250 // Only one commit
251 assertEquals(commitCallCount, 1);
252});
253
254test("DebouncedMutation - debounce separates batches after timeout", async () => {
255 const { client } = createTestClient();
256 testStore.clear();
257 testStore.set("counter", 0);
258
259 let commitCallCount = 0;
260 const commitArgs: Array<{ initial: number; current: number }> = [];
261
262 const mutation = client.defineDebounced({
263 optimistic({ helpers }, amount: number) {
264 helpers.increment("counter", amount);
265 },
266 mode: "debounce",
267 time: 30,
268 key: () => "test-key",
269 getValue: (_) => testStore.get("counter") ?? 0,
270 async commit({ initial, current }) {
271 commitCallCount++;
272 commitArgs.push({ initial, current });
273 return current - initial;
274 },
275 describe: "increment counter",
276 describeResult: "Success",
277 async refetch() {},
278 });
279
280 // First batch
281 await mutation.runAndReturn(1);
282 await delay(50); // Wait for first batch to complete
283
284 // Second batch (after timeout)
285 await mutation.runAndReturn(2);
286 await delay(50);
287
288 // Two separate commits
289 assertEquals(commitCallCount, 2);
290 assertEquals(commitArgs, [
291 { initial: 0, current: 1 },
292 { initial: 1, current: 3 },
293 ]);
294});
295
296// ============================================================================
297// Throttle mode tests
298// ============================================================================
299
300test("DebouncedMutation - throttle commits immediately on first call", async () => {
301 const { client } = createTestClient();
302 testStore.clear();
303 testStore.set("counter", 0);
304
305 let commitTime = 0;
306 const startTime = Date.now();
307
308 const mutation = client.defineDebounced({
309 optimistic({ helpers }, amount: number) {
310 helpers.increment("counter", amount);
311 },
312 mode: "throttle",
313 time: 100,
314 key: () => "test-key",
315 getValue: (_) => testStore.get("counter") ?? 0,
316 async commit({ initial, current }) {
317 commitTime = Date.now() - startTime;
318 return current - initial;
319 },
320 describe: "increment counter",
321 describeResult: "Success",
322 async refetch() {},
323 });
324
325 await mutation.runAndReturn(5);
326
327 // First call should commit immediately (within a small tolerance)
328 assertEquals(commitTime < 20, true);
329});
330
331test("DebouncedMutation - throttle batches calls within time window", async () => {
332 const { client } = createTestClient();
333 testStore.clear();
334 testStore.set("counter", 0);
335
336 let commitCallCount = 0;
337 const commitArgs: Array<{ initial: number; current: number }> = [];
338
339 const mutation = client.defineDebounced({
340 optimistic({ helpers }, amount: number) {
341 helpers.increment("counter", amount);
342 },
343 mode: "throttle",
344 time: 100,
345 key: () => "test-key",
346 getValue: (_) => testStore.get("counter") ?? 0,
347 async commit({ initial, current }) {
348 commitCallCount++;
349 commitArgs.push({ initial, current });
350 await delay(10);
351 return current - initial;
352 },
353 describe: "increment counter",
354 describeResult: "Success",
355 async refetch() {},
356 });
357
358 // First call commits immediately
359 const promise1 = mutation.runAndReturn(1);
360 await delay(5);
361
362 // Second call within throttle window - should batch
363 const promise2 = mutation.runAndReturn(2);
364 await delay(5);
365
366 // Third call within throttle window - should batch with second
367 const promise3 = mutation.runAndReturn(3);
368
369 // Wait for first to complete
370 await promise1;
371
372 // First commit happened immediately
373 assertEquals(commitCallCount, 1);
374 assertEquals(commitArgs[0], { initial: 0, current: 1 });
375
376 // Wait for throttle window to pass and second batch to commit
377 await Promise.all([promise2, promise3]);
378 await delay(50);
379
380 // Second batch committed
381 assertEquals(commitCallCount, 2);
382 assertEquals(commitArgs[1], { initial: 1, current: 6 });
383});
384
385test("DebouncedMutation - throttle allows new batch after time window", async () => {
386 const { client } = createTestClient();
387 testStore.clear();
388 testStore.set("counter", 0);
389
390 let commitCallCount = 0;
391
392 const mutation = client.defineDebounced({
393 optimistic({ helpers }, amount: number) {
394 helpers.increment("counter", amount);
395 },
396 mode: "throttle",
397 time: 50,
398 key: () => "test-key",
399 getValue: (_) => testStore.get("counter") ?? 0,
400 async commit({ initial, current }) {
401 commitCallCount++;
402 return current - initial;
403 },
404 describe: "increment counter",
405 describeResult: "Success",
406 async refetch() {},
407 });
408
409 // First call
410 await mutation.runAndReturn(1);
411 await delay(10);
412
413 assertEquals(commitCallCount, 1);
414
415 // Wait for throttle window to pass
416 await delay(60);
417
418 // Second call should commit immediately
419 await mutation.runAndReturn(2);
420 await delay(10);
421
422 assertEquals(commitCallCount, 2);
423});
424
425// ============================================================================
426// No-op detection tests
427// ============================================================================
428
429test("DebouncedMutation - skips commit when value unchanged", async () => {
430 const { client } = createTestClient();
431 testStore.clear();
432 testStore.set("counter", 5);
433
434 let commitCallCount = 0;
435
436 const mutation = client.defineDebounced({
437 optimistic({ helpers }, amount: number) {
438 helpers.increment("counter", amount);
439 },
440 mode: "debounce",
441 time: 20,
442 key: () => "test-key",
443 getValue: (_) => testStore.get("counter") ?? 0,
444 async commit({ initial, current }) {
445 commitCallCount++;
446 return current - initial;
447 },
448 describe: "increment counter",
449 describeResult: "Success",
450 async refetch() {},
451 });
452
453 // +5 and -5 cancel out
454 const promise1 = mutation.runAndReturn(5);
455 const promise2 = mutation.runAndReturn(-5);
456
457 const [result1, result2] = await Promise.all([promise1, promise2]);
458
459 // No commit should have been made
460 assertEquals(commitCallCount, 0);
461
462 // Results should be null (no actual change)
463 assertEquals(result1, null);
464 assertEquals(result2, null);
465
466 // Store should be unchanged
467 assertEquals(testStore.get("counter"), 5);
468});
469
470test("DebouncedMutation - uses deepEquals for comparison", async () => {
471 const errors: unknown[] = [];
472 const objectStore: { value: { count: number } | null } = {
473 value: { count: 0 },
474 };
475
476 const client = new MutationClient({
477 context: {},
478 getOptimisticHelpers({ onRestore }) {
479 return {
480 setCount(count: number) {
481 const old = objectStore.value;
482 objectStore.value = { count };
483 onRestore(() => {
484 objectStore.value = old;
485 });
486 },
487 };
488 },
489 reportError(message, error) {
490 errors.push(error);
491 },
492 });
493
494 let commitCallCount = 0;
495
496 const mutation = client.defineDebounced({
497 optimistic({ helpers }, count: number) {
498 helpers.setCount(count);
499 },
500 mode: "debounce",
501 time: 20,
502 key: () => "test-key",
503 getValue: (_) => objectStore.value,
504 async commit() {
505 commitCallCount++;
506 return null;
507 },
508 describe: "set count",
509 describeResult: "Success",
510 async refetch() {},
511 });
512
513 // Set to same value (different object reference but same content)
514 await mutation.runAndReturn(0);
515 await delay(30);
516
517 // Should skip commit because value is deeply equal
518 assertEquals(commitCallCount, 0);
519});
520
521test("DebouncedMutation - custom deepEquals function", async () => {
522 const errors: unknown[] = [];
523 let compareCallCount = 0;
524
525 const client = new MutationClient({
526 context: {},
527 getOptimisticHelpers({ onRestore }) {
528 return {
529 increment(key: string, amount: number) {
530 const old = testStore.get(key) ?? 0;
531 testStore.set(key, old + amount);
532 onRestore(() => testStore.set(key, old));
533 },
534 };
535 },
536 reportError(message, error) {
537 errors.push(error);
538 },
539 deepEquals(a, b) {
540 compareCallCount++;
541 // Custom comparison
542 return a === b;
543 },
544 });
545
546 testStore.clear();
547 testStore.set("counter", 0);
548
549 const mutation = client.defineDebounced({
550 optimistic({ helpers }, amount: number) {
551 helpers.increment("counter", amount);
552 },
553 mode: "debounce",
554 time: 10,
555 key: () => "test-key",
556 getValue: (_) => testStore.get("counter") ?? 0,
557 async commit() {
558 throw new Error("commit failed");
559 },
560 describe: "failing mutation",
561 describeResult: "Success",
562 async refetch() {},
563 });
564
565 await mutation.runAndReturn(5).catch(() => {
566 // Expected to fail due to commit error
567 });
568 await delay(30);
569
570 // Custom deepEquals should have been called
571 assertEquals(compareCallCount > 0, true);
572});
573
574// ============================================================================
575// Rollback tests
576// ============================================================================
577
578test("DebouncedMutation - rollback on commit error", async () => {
579 const { client } = createTestClient();
580 testStore.clear();
581 testStore.set("counter", 10);
582
583 const mutation = client.defineDebounced({
584 optimistic({ helpers }, amount: number) {
585 helpers.increment("counter", amount);
586 },
587 mode: "debounce",
588 time: 20,
589 key: () => "test-key",
590 getValue: (_) => testStore.get("counter") ?? 0,
591 async commit() {
592 throw new Error("commit failed");
593 },
594 describe: "failing mutation",
595 describeResult: "Success",
596 async refetch() {},
597 });
598
599 // Optimistic update applied
600 const promise = mutation.runAndReturn(5);
601 assertEquals(testStore.get("counter"), 15);
602
603 await assertRejects(() => promise, Error, "commit failed");
604
605 // Should be rolled back
606 assertEquals(testStore.get("counter"), 10);
607});
608
609test("DebouncedMutation - error event includes error details", async () => {
610 const { client } = createTestClient();
611 testStore.clear();
612 testStore.set("counter", 0);
613
614 const tracker = createEventTracker<number>();
615
616 const mutation = client.defineDebounced({
617 optimistic({ helpers }, amount: number) {
618 helpers.increment("counter", amount);
619 },
620 mode: "debounce",
621 time: 20,
622 key: () => "test-key",
623 getValue: (_) => testStore.get("counter") ?? 0,
624 async commit() {
625 throw new Error("commit failed");
626 },
627 describe: "failing mutation",
628 describeResult: "Success",
629 async refetch() {},
630 });
631
632 const key = mutation.key([5]);
633 mutation.subscribe(key, tracker.callback);
634
635 await assertRejects(() => mutation.runAndReturn(5));
636 await delay(30);
637
638 // Should have error in events
639 const errorEvents = tracker.events.filter((e) => e.error !== null);
640 assertEquals(errorEvents.length > 0, true);
641 assertEquals((errorEvents[0]?.error as Error).message, "commit failed");
642});
643
644// ============================================================================
645// Key handling tests
646// ============================================================================
647
648test("DebouncedMutation - key() returns JSON stringified key", () => {
649 const { client } = createTestClient();
650 testStore.clear();
651
652 const mutation = client.defineDebounced({
653 optimistic(_ctx, _id: string) {},
654 mode: "debounce",
655 time: 20,
656 key: ({ args }) => args[0],
657 getValue: (_) => 0,
658 async commit() {
659 return null;
660 },
661 describe: "test mutation",
662 describeResult: "Success",
663 async refetch() {},
664 });
665
666 assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id"));
667});
668
669test("DebouncedMutation - key() can return array", () => {
670 const { client } = createTestClient();
671 testStore.clear();
672
673 const mutation = client.defineDebounced({
674 optimistic(_ctx, _id: string) {},
675 mode: "debounce",
676 time: 20,
677 key: ({ args }) => ["user", args[0]],
678 getValue: (_) => 0,
679 async commit() {
680 return null;
681 },
682 describe: "test mutation",
683 describeResult: "Success",
684 async refetch() {},
685 });
686
687 assertEquals(
688 mutation.key(["123"]),
689 JSON.stringify(["user", "123"]),
690 );
691});
692
693test("DebouncedMutation - different keys create separate batches", async () => {
694 const { client } = createTestClient();
695 testStore.clear();
696 testStore.set("counter-a", 0);
697 testStore.set("counter-b", 0);
698
699 let commitCallCount = 0;
700
701 const mutation = client.defineDebounced({
702 optimistic({ helpers }, key: string, amount: number) {
703 helpers.increment(`counter-${key}`, amount);
704 },
705 mode: "debounce",
706 time: 20,
707 key: ({ args }) => args[0],
708 getValue: ({ args: [key] }) => testStore.get(`counter-${key}`) ?? 0,
709 async commit({ current }) {
710 commitCallCount++;
711 return current;
712 },
713 describe: "test mutation",
714 describeResult: "Success",
715 async refetch() {},
716 });
717
718 // Two different keys
719 const promise1 = mutation.runAndReturn("a", 5);
720 const promise2 = mutation.runAndReturn("b", 10);
721
722 await Promise.all([promise1, promise2]);
723 await delay(30);
724
725 // Should have two separate commits
726 assertEquals(commitCallCount, 2);
727 assertEquals(testStore.get("counter-a"), 5);
728 assertEquals(testStore.get("counter-b"), 10);
729});
730
731// ============================================================================
732// Describe tests
733// ============================================================================
734
735test("DebouncedMutation - describe() with string", () => {
736 const { client } = createTestClient();
737 testStore.clear();
738
739 const mutation = client.defineDebounced({
740 optimistic(_ctx, _amount: number) {},
741 mode: "debounce",
742 time: 20,
743 key: () => "test-key",
744 getValue: (_) => 0,
745 async commit() {
746 return null;
747 },
748 describe: "update counter",
749 describeResult: "Success",
750 async refetch() {},
751 });
752
753 assertEquals(mutation.describe(5), "update counter");
754});
755
756test("DebouncedMutation - describe() with function", () => {
757 const { client } = createTestClient();
758 testStore.clear();
759
760 const mutation = client.defineDebounced({
761 optimistic(_ctx, _amount: number) {},
762 mode: "debounce",
763 time: 20,
764 key: () => "test-key",
765 getValue: (_) => 0,
766 async commit() {
767 return null;
768 },
769 describe: ({ args }) => `increment by ${args[0]}`,
770 describeResult: "Success",
771 async refetch() {},
772 });
773
774 assertEquals(mutation.describe(5), "increment by 5");
775});
776
777// ============================================================================
778// Promise resolution tests
779// ============================================================================
780
781test("DebouncedMutation - all pending promises resolve with same result", async () => {
782 const { client } = createTestClient();
783 testStore.clear();
784 testStore.set("counter", 0);
785
786 const mutation = client.defineDebounced({
787 optimistic({ helpers }, amount: number) {
788 helpers.increment("counter", amount);
789 },
790 mode: "debounce",
791 time: 30,
792 key: () => "test-key",
793 getValue: (_) => testStore.get("counter") ?? 0,
794 async commit({ initial, current }) {
795 return { delta: current - initial, timestamp: Date.now() };
796 },
797 describe: "increment counter",
798 describeResult: "Success",
799 async refetch() {},
800 });
801
802 const promise1 = mutation.runAndReturn(1);
803 const promise2 = mutation.runAndReturn(2);
804 const promise3 = mutation.runAndReturn(3);
805
806 const [result1, result2, result3] = await Promise.all([
807 promise1,
808 promise2,
809 promise3,
810 ]);
811
812 // All should get the same result object
813 assertEquals(result1, result2);
814 assertEquals(result2, result3);
815 assertEquals(result1.delta, 6);
816});
817
818test("DebouncedMutation - all pending promises reject with same error", async () => {
819 const { client } = createTestClient();
820 testStore.clear();
821 testStore.set("counter", 0);
822
823 const mutation = client.defineDebounced({
824 optimistic({ helpers }, amount: number) {
825 helpers.increment("counter", amount);
826 },
827 mode: "debounce",
828 time: 30,
829 key: () => "test-key",
830 getValue: (_) => testStore.get("counter") ?? 0,
831 async commit() {
832 throw new Error("batch commit failed");
833 },
834 describe: "increment counter",
835 describeResult: "Success",
836 async refetch() {},
837 });
838
839 const promise1 = mutation.runAndReturn(1);
840 const promise2 = mutation.runAndReturn(2);
841 const promise3 = mutation.runAndReturn(3);
842
843 const errors: Error[] = [];
844 await Promise.all([
845 promise1.catch((e) => errors.push(e)),
846 promise2.catch((e) => errors.push(e)),
847 promise3.catch((e) => errors.push(e)),
848 ]);
849
850 // All should get the same error
851 assertEquals(errors.length, 3);
852 assertEquals(errors[0].message, "batch commit failed");
853 assertEquals(errors[1].message, "batch commit failed");
854 assertEquals(errors[2].message, "batch commit failed");
855});
856
857// ============================================================================
858// Edge case tests
859// ============================================================================
860
861test("DebouncedMutation - handles empty getValue result", async () => {
862 const { client } = createTestClient();
863 testStore.clear();
864
865 let commitCallCount = 0;
866
867 const mutation = client.defineDebounced({
868 optimistic({ helpers }, amount: number) {
869 helpers.setValue("nonexistent", amount);
870 },
871 mode: "debounce",
872 time: 20,
873 key: () => "test-key",
874 getValue: (_) => testStore.get("nonexistent"),
875 async commit({ initial, current }) {
876 commitCallCount++;
877 return { initial, current };
878 },
879 describe: "test mutation",
880 describeResult: "Success",
881 });
882
883 const result = await mutation.runAndReturn(5);
884 await delay(30);
885
886 assertEquals(commitCallCount, 1);
887 assertEquals(result.initial, undefined);
888 assertEquals(result.current, 5);
889});
890
891test("DebouncedMutation - channel cleanup after idle with no listeners", async () => {
892 const { client } = createTestClient();
893 testStore.clear();
894 testStore.set("counter", 0);
895
896 const mutation = client.defineDebounced({
897 optimistic({ helpers }, amount: number) {
898 helpers.increment("counter", amount);
899 },
900 mode: "debounce",
901 time: 20,
902 key: () => "test-key",
903 getValue: (_) => testStore.get("counter") ?? 0,
904 async commit({ initial, current }) {
905 return current - initial;
906 },
907 describe: "test mutation",
908 describeResult: "Success",
909 async refetch() {},
910 });
911
912 // Run mutation without subscribing
913 await mutation.runAndReturn(5);
914 await delay(30);
915
916 // Run another mutation - should work fine (channel recreated if needed)
917 const result = await mutation.runAndReturn(3);
918 await delay(30);
919
920 assertEquals(result, 3);
921 assertEquals(testStore.get("counter"), 8);
922});
923
924test("DebouncedMutation - default time is 200ms", async () => {
925 const { client } = createTestClient();
926 testStore.clear();
927 testStore.set("counter", 0);
928
929 let commitTime: number | null = null;
930 const startTime = Date.now();
931
932 const mutation = client.defineDebounced({
933 optimistic({ helpers }, amount: number) {
934 helpers.increment("counter", amount);
935 },
936 mode: "debounce",
937 // time not specified, should default to 200
938 key: () => "test-key",
939 getValue: (_) => testStore.get("counter") ?? 0,
940 async commit({ initial, current }) {
941 commitTime = Date.now() - startTime;
942 return current - initial;
943 },
944 describe: "test mutation",
945 describeResult: "Success",
946 async refetch() {},
947 });
948
949 await mutation.runAndReturn(5);
950
951 // Should commit after ~200ms (with some tolerance)
952 assertEquals(commitTime !== null, true);
953 assertEquals(commitTime! >= 180, true);
954 assertEquals(commitTime! <= 250, true);
955});
956
957test("DebouncedMutation - context is passed to getValue", async () => {
958 const { client } = createTestClient();
959 testStore.clear();
960 testStore.set("counter", 0);
961
962 let receivedUserId: string | undefined;
963
964 const mutation = client.defineDebounced({
965 optimistic({ helpers }, amount: number) {
966 helpers.increment("counter", amount);
967 },
968 mode: "debounce",
969 time: 20,
970 key: () => "test-key",
971 getValue: ({ userId }) => {
972 receivedUserId = userId;
973 return testStore.get("counter") ?? 0;
974 },
975 async commit({ initial, current }) {
976 return current - initial;
977 },
978 describe: "test mutation",
979 describeResult: "Success",
980 async refetch() {},
981 });
982
983 await mutation.runAndReturn(5);
984 await delay(30);
985
986 assertEquals(receivedUserId, "test-user");
987});
988
989test("DebouncedMutation - context is passed to commit", async () => {
990 const { client } = createTestClient();
991 testStore.clear();
992 testStore.set("counter", 0);
993
994 let receivedUserId: string | undefined;
995
996 const mutation = client.defineDebounced({
997 optimistic({ helpers }, amount: number) {
998 helpers.increment("counter", amount);
999 },
1000 mode: "debounce",
1001 time: 20,
1002 key: () => "test-key",
1003 getValue: (_) => testStore.get("counter") ?? 0,
1004 async commit({ userId, initial, current }) {
1005 receivedUserId = userId;
1006 return current - initial;
1007 },
1008 describe: "test mutation",
1009 describeResult: "Success",
1010 async refetch() {},
1011 });
1012
1013 await mutation.runAndReturn(5);
1014 await delay(30);
1015
1016 assertEquals(receivedUserId, "test-user");
1017});
1018
1019test("DebouncedMutation - first args are used for commit", async () => {
1020 const { client } = createTestClient();
1021 testStore.clear();
1022 testStore.set("counter", 0);
1023
1024 let receivedArgs: [string, number] | undefined;
1025
1026 const mutation = client.defineDebounced({
1027 optimistic({ helpers }, _label: string, amount: number) {
1028 helpers.increment("counter", amount);
1029 },
1030 mode: "debounce",
1031 time: 30,
1032 key: () => "test-key",
1033 getValue: (_) => testStore.get("counter") ?? 0,
1034 async commit({ args, initial, current }) {
1035 receivedArgs = args;
1036 return current - initial;
1037 },
1038 describe: "test mutation",
1039 describeResult: "Success",
1040 async refetch() {},
1041 });
1042
1043 mutation.runAndReturn("first", 1);
1044 mutation.runAndReturn("second", 2);
1045 await mutation.runAndReturn("third", 3);
1046 await delay(10);
1047
1048 // Should use first args
1049 assertEquals(receivedArgs, ["first", 1]);
1050});
test/object-path-types.test.ts deleted-407
......@@ -1,407 +0,0 @@
1/**
2 * Type-level tests for object-path system
3 * These tests verify that TypeScript types work correctly at compile time
4 */
5
6import type { AllObjectPaths, GetObjectPath } from "../src/object-path.ts";
7
8// Type testing utilities
9type Expect<T extends true> = T;
10type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends
11 <T>() => T extends Y ? 1
12 : 2 ? true
13 : false;
14type NotEqual<X, Y> = Equal<X, Y> extends true ? false : true;
15type IsAny<T> = 0 extends 1 & T ? true : false;
16type NotAny<T> = IsAny<T> extends true ? false : true;
17
18// Test interface
19interface TestData {
20 name: string;
21 count: number;
22 active: boolean;
23 settings: {
24 theme: string;
25 notifications: boolean;
26 };
27 items: Array<{ id: number; label: string }>;
28 tags: string[];
29 nested: {
30 deep: {
31 value: boolean;
32 config: {
33 enabled: true;
34 };
35 };
36 };
37}
38
39// ============================================================================
40// AllObjectPaths tests
41// ============================================================================
42
43// Should allow top-level paths
44type TestPath1 = Expect<
45 Equal<["name"], Extract<AllObjectPaths<TestData>, ["name"]>>
46>;
47type TestPath2 = Expect<
48 Equal<["count"], Extract<AllObjectPaths<TestData>, ["count"]>>
49>;
50
51// Should allow nested paths
52type TestPath3 = Expect<
53 Equal<
54 ["settings", "theme"],
55 Extract<AllObjectPaths<TestData>, ["settings", "theme"]>
56 >
57>;
58
59// Should allow deep nested paths
60type TestPath4 = Expect<
61 Equal<
62 ["nested", "deep", "value"],
63 Extract<AllObjectPaths<TestData>, ["nested", "deep", "value"]>
64 >
65>;
66
67// Should allow array index access
68type TestPath5 = Expect<
69 Equal<[number], Extract<AllObjectPaths<string[]>, [number]>>
70>;
71
72// Should allow array element property access
73type TestPath6 = Expect<
74 Equal<
75 ["items", number, "id"],
76 Extract<AllObjectPaths<TestData>, ["items", number, "id"]>
77 >
78>;
79
80// Should allow empty path for nested objects
81type TestPath7 = Expect<
82 Equal<[], Extract<AllObjectPaths<TestData>, []>>
83>;
84
85// ============================================================================
86// GetObjectPath tests
87// ============================================================================
88
89// Top-level property access
90type GetTest1 = Expect<Equal<GetObjectPath<TestData, ["name"]>, string>>;
91type GetTest2 = Expect<Equal<GetObjectPath<TestData, ["count"]>, number>>;
92type GetTest3 = Expect<Equal<GetObjectPath<TestData, ["active"]>, boolean>>;
93
94// Nested property access
95type GetTest4 = Expect<
96 Equal<
97 GetObjectPath<TestData, ["settings", "theme"]>,
98 string
99 >
100>;
101type GetTest5 = Expect<
102 Equal<
103 GetObjectPath<TestData, ["settings", "notifications"]>,
104 boolean
105 >
106>;
107
108// Deep nested access
109type GetTest6 = Expect<
110 Equal<
111 GetObjectPath<TestData, ["nested", "deep", "value"]>,
112 boolean
113 >
114>;
115type GetTest7 = Expect<
116 Equal<
117 GetObjectPath<TestData, ["nested", "deep", "config", "enabled"]>,
118 true
119 >
120>;
121
122// Array access
123type GetTest8 = Expect<
124 Equal<
125 GetObjectPath<TestData, ["tags"]>,
126 string[]
127 >
128>;
129type GetTest9 = Expect<
130 Equal<
131 GetObjectPath<TestData, ["tags", number]>,
132 string
133 >
134>;
135type GetTest10 = Expect<
136 Equal<
137 GetObjectPath<TestData, ["items"]>,
138 Array<{ id: number; label: string }>
139 >
140>;
141type GetTest11 = Expect<
142 Equal<
143 GetObjectPath<TestData, ["items", number]>,
144 { id: number; label: string }
145 >
146>;
147type GetTest12 = Expect<
148 Equal<
149 GetObjectPath<TestData, ["items", number, "id"]>,
150 number
151 >
152>;
153type GetTest13 = Expect<
154 Equal<
155 GetObjectPath<TestData, ["items", number, "label"]>,
156 string
157 >
158>;
159
160// Object access
161type GetTest14 = Expect<
162 Equal<
163 GetObjectPath<TestData, ["settings"]>,
164 { theme: string; notifications: boolean }
165 >
166>;
167
168// Empty path returns the whole object
169type GetTest15 = Expect<Equal<GetObjectPath<TestData, []>, TestData>>;
170
171// ============================================================================
172// Array element type extraction tests
173// ============================================================================
174
175type ArrayElement<T> = T extends readonly (infer U)[] ? U : never;
176
177type ArrayTest1 = Expect<Equal<ArrayElement<string[]>, string>>;
178type ArrayTest2 = Expect<Equal<ArrayElement<number[]>, number>>;
179type ArrayTest3 = Expect<
180 Equal<ArrayElement<Array<{ id: number }>>, { id: number }>
181>;
182type ArrayTest4 = Expect<
183 Equal<
184 ArrayElement<GetObjectPath<TestData, ["items"]>>,
185 { id: number; label: string }
186 >
187>;
188type ArrayTest5 = Expect<
189 Equal<ArrayElement<GetObjectPath<TestData, ["tags"]>>, string>
190>;
191
192// ============================================================================
193// Conditional type tests for helper functions
194// ============================================================================
195
196// Test that we can extract array element types from paths
197type ExtractArrayElement<
198 Data extends object,
199 Path extends AllObjectPaths<Data>,
200> = GetObjectPath<Data, Path> extends readonly (infer T)[] ? T : never;
201
202type ElementTest1 = Expect<
203 Equal<
204 ExtractArrayElement<TestData, ["items"]>,
205 { id: number; label: string }
206 >
207>;
208type ElementTest2 = Expect<
209 Equal<ExtractArrayElement<TestData, ["tags"]>, string>
210>;
211
212// Test that non-array paths return never
213type ElementTest3 = Expect<
214 Equal<ExtractArrayElement<TestData, ["name"]>, never>
215>;
216
217// Test number extraction
218type IsNumber<
219 Data extends object,
220 Path extends AllObjectPaths<Data>,
221> = GetObjectPath<Data, Path> extends number ? true : false;
222
223type NumberTest1 = Expect<Equal<IsNumber<TestData, ["count"]>, true>>;
224type NumberTest2 = Expect<Equal<IsNumber<TestData, ["name"]>, false>>;
225
226// Test boolean extraction
227type IsBoolean<
228 Data extends object,
229 Path extends AllObjectPaths<Data>,
230> = GetObjectPath<Data, Path> extends boolean ? true : false;
231
232type BooleanTest1 = Expect<Equal<IsBoolean<TestData, ["active"]>, true>>;
233type BooleanTest2 = Expect<Equal<IsBoolean<TestData, ["count"]>, false>>;
234
235// Test object extraction
236type IsObject<
237 Data extends object,
238 Path extends AllObjectPaths<Data>,
239> = GetObjectPath<Data, Path> extends object ? true : false;
240
241type ObjectTest1 = Expect<Equal<IsObject<TestData, ["settings"]>, true>>;
242type ObjectTest2 = Expect<Equal<IsObject<TestData, ["items"]>, true>>;
243type ObjectTest3 = Expect<Equal<IsObject<TestData, ["name"]>, false>>;
244
245// ============================================================================
246// Edge cases
247// ============================================================================
248
249// Readonly arrays should work
250interface ReadonlyData {
251 readonly items: readonly { id: number }[];
252}
253
254type ReadonlyTest1 = Expect<
255 Equal<
256 GetObjectPath<ReadonlyData, ["items"]>,
257 readonly { id: number }[]
258 >
259>;
260type ReadonlyTest2 = Expect<
261 Equal<
262 GetObjectPath<ReadonlyData, ["items", number]>,
263 { id: number }
264 >
265>;
266type ReadonlyTest3 = Expect<
267 Equal<
268 GetObjectPath<ReadonlyData, ["items", number, "id"]>,
269 number
270 >
271>;
272
273// Optional properties
274interface OptionalData {
275 required: string;
276 optional?: number;
277 nested?: {
278 value: boolean;
279 };
280}
281
282type OptionalTest1 = Expect<
283 Equal<GetObjectPath<OptionalData, ["required"]>, string>
284>;
285type OptionalTest2 = Expect<
286 Equal<GetObjectPath<OptionalData, ["optional"]>, number | undefined>
287>;
288
289// Union types
290interface UnionData {
291 value: string | number;
292 items: Array<{ type: "a"; a: string } | { type: "b"; b: number }>;
293}
294
295type UnionTest1 = Expect<
296 Equal<GetObjectPath<UnionData, ["value"]>, string | number>
297>;
298
299// ============================================================================
300// Real-world usage simulation
301// ============================================================================
302
303// Simulate the actual helper function signatures
304type ObjSetSignature<
305 Data extends object,
306 Path extends AllObjectPaths<Data>,
307> = (
308 path: Path,
309 value:
310 | Exclude<GetObjectPath<Data, Path>, Function>
311 | ((prev: GetObjectPath<Data, Path>) => GetObjectPath<Data, Path>),
312) => void;
313
314// This should accept string or function
315declare const objSetName: ObjSetSignature<TestData, ["name"]>;
316objSetName(["name"], "test");
317objSetName(["name"], (prev) => prev.toUpperCase());
318
319// This should accept number or function
320declare const objSetCount: ObjSetSignature<TestData, ["count"]>;
321objSetCount(["count"], 42);
322objSetCount(["count"], (n) => n + 1);
323
324// Array push signature
325type ArrayPushSignature<
326 Data extends object,
327 Path extends AllObjectPaths<Data>,
328> = GetObjectPath<Data, Path> extends readonly (infer T)[]
329 ? (path: Path, ...items: T[]) => void
330 : never;
331
332// This should accept individual items, not arrays
333declare const arrayPushItems: ArrayPushSignature<TestData, ["items"]>;
334arrayPushItems(
335 ["items"],
336 { id: 1, label: "first" },
337 { id: 2, label: "second" },
338);
339
340declare const arrayPushTags: ArrayPushSignature<TestData, ["tags"]>;
341arrayPushTags(["tags"], "alpha", "beta", "gamma");
342
343// Array remove signature
344type ArrayRemoveSignature<
345 Data extends object,
346 Path extends AllObjectPaths<Data>,
347> = GetObjectPath<Data, Path> extends readonly (infer T)[]
348 ? (path: Path, filter: (item: T, index: number) => boolean) => void
349 : never;
350
351declare const arrayRemoveItems: ArrayRemoveSignature<TestData, ["items"]>;
352arrayRemoveItems(["items"], (item) => item.id === 1);
353arrayRemoveItems(["items"], (item, index) => index === 0);
354
355declare const arrayRemoveTags: ArrayRemoveSignature<TestData, ["tags"]>;
356arrayRemoveTags(["tags"], (tag) => tag === "alpha");
357
358// Increment signature
359type IncrementSignature<
360 Data extends object,
361 Path extends AllObjectPaths<Data>,
362> = GetObjectPath<Data, Path> extends number
363 ? (path: Path, amount?: number) => void
364 : never;
365
366declare const increment: IncrementSignature<TestData, ["count"]>;
367increment(["count"]);
368increment(["count"], 5);
369
370// Should not work on non-numbers (type should be never)
371type IncrementNameTest = Expect<
372 Equal<IncrementSignature<TestData, ["name"]>, never>
373>;
374
375// Toggle signature
376type ToggleSignature<
377 Data extends object,
378 Path extends AllObjectPaths<Data>,
379> = GetObjectPath<Data, Path> extends boolean ? (path: Path) => void
380 : never;
381
382declare const toggle: ToggleSignature<TestData, ["active"]>;
383toggle(["active"]);
384
385// Should not work on non-booleans (type should be never)
386type ToggleCountTest = Expect<
387 Equal<ToggleSignature<TestData, ["count"]>, never>
388>;
389
390// ============================================================================
391// Verify no `any` types leaked through
392// ============================================================================
393
394type NoAnyTest1 = Expect<NotAny<GetObjectPath<TestData, ["name"]>>>;
395type NoAnyTest2 = Expect<NotAny<GetObjectPath<TestData, ["items"]>>>;
396type NoAnyTest3 = Expect<NotAny<GetObjectPath<TestData, ["items", number]>>>;
397type NoAnyTest4 = Expect<
398 NotAny<GetObjectPath<TestData, ["settings", "theme"]>>
399>;
400
401export type {
402 ArrayPushSignature,
403 ArrayRemoveSignature,
404 IncrementSignature,
405 ObjSetSignature,
406 ToggleSignature,
407};
test/object-path.types.ts created+407
......@@ -0,0 +1,407 @@
1/**
2 * Type-level tests for object-path system
3 * These tests verify that TypeScript types work correctly at compile time
4 */
5
6import type { AllObjectPaths, GetObjectPath } from "../src/object-path.ts";
7
8// Type testing utilities
9type Expect<T extends true> = T;
10type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends
11 <T>() => T extends Y ? 1
12 : 2 ? true
13 : false;
14type NotEqual<X, Y> = Equal<X, Y> extends true ? false : true;
15type IsAny<T> = 0 extends 1 & T ? true : false;
16type NotAny<T> = IsAny<T> extends true ? false : true;
17
18// Test interface
19interface TestData {
20 name: string;
21 count: number;
22 active: boolean;
23 settings: {
24 theme: string;
25 notifications: boolean;
26 };
27 items: Array<{ id: number; label: string }>;
28 tags: string[];
29 nested: {
30 deep: {
31 value: boolean;
32 config: {
33 enabled: true;
34 };
35 };
36 };
37}
38
39// ============================================================================
40// AllObjectPaths tests
41// ============================================================================
42
43// Should allow top-level paths
44type TestPath1 = Expect<
45 Equal<["name"], Extract<AllObjectPaths<TestData>, ["name"]>>
46>;
47type TestPath2 = Expect<
48 Equal<["count"], Extract<AllObjectPaths<TestData>, ["count"]>>
49>;
50
51// Should allow nested paths
52type TestPath3 = Expect<
53 Equal<
54 ["settings", "theme"],
55 Extract<AllObjectPaths<TestData>, ["settings", "theme"]>
56 >
57>;
58
59// Should allow deep nested paths
60type TestPath4 = Expect<
61 Equal<
62 ["nested", "deep", "value"],
63 Extract<AllObjectPaths<TestData>, ["nested", "deep", "value"]>
64 >
65>;
66
67// Should allow array index access
68type TestPath5 = Expect<
69 Equal<[number], Extract<AllObjectPaths<string[]>, [number]>>
70>;
71
72// Should allow array element property access
73type TestPath6 = Expect<
74 Equal<
75 ["items", number, "id"],
76 Extract<AllObjectPaths<TestData>, ["items", number, "id"]>
77 >
78>;
79
80// Should allow empty path for nested objects
81type TestPath7 = Expect<
82 Equal<[], Extract<AllObjectPaths<TestData>, []>>
83>;
84
85// ============================================================================
86// GetObjectPath tests
87// ============================================================================
88
89// Top-level property access
90type GetTest1 = Expect<Equal<GetObjectPath<TestData, ["name"]>, string>>;
91type GetTest2 = Expect<Equal<GetObjectPath<TestData, ["count"]>, number>>;
92type GetTest3 = Expect<Equal<GetObjectPath<TestData, ["active"]>, boolean>>;
93
94// Nested property access
95type GetTest4 = Expect<
96 Equal<
97 GetObjectPath<TestData, ["settings", "theme"]>,
98 string
99 >
100>;
101type GetTest5 = Expect<
102 Equal<
103 GetObjectPath<TestData, ["settings", "notifications"]>,
104 boolean
105 >
106>;
107
108// Deep nested access
109type GetTest6 = Expect<
110 Equal<
111 GetObjectPath<TestData, ["nested", "deep", "value"]>,
112 boolean
113 >
114>;
115type GetTest7 = Expect<
116 Equal<
117 GetObjectPath<TestData, ["nested", "deep", "config", "enabled"]>,
118 true
119 >
120>;
121
122// Array access
123type GetTest8 = Expect<
124 Equal<
125 GetObjectPath<TestData, ["tags"]>,
126 string[]
127 >
128>;
129type GetTest9 = Expect<
130 Equal<
131 GetObjectPath<TestData, ["tags", number]>,
132 string
133 >
134>;
135type GetTest10 = Expect<
136 Equal<
137 GetObjectPath<TestData, ["items"]>,
138 Array<{ id: number; label: string }>
139 >
140>;
141type GetTest11 = Expect<
142 Equal<
143 GetObjectPath<TestData, ["items", number]>,
144 { id: number; label: string }
145 >
146>;
147type GetTest12 = Expect<
148 Equal<
149 GetObjectPath<TestData, ["items", number, "id"]>,
150 number
151 >
152>;
153type GetTest13 = Expect<
154 Equal<
155 GetObjectPath<TestData, ["items", number, "label"]>,
156 string
157 >
158>;
159
160// Object access
161type GetTest14 = Expect<
162 Equal<
163 GetObjectPath<TestData, ["settings"]>,
164 { theme: string; notifications: boolean }
165 >
166>;
167
168// Empty path returns the whole object
169type GetTest15 = Expect<Equal<GetObjectPath<TestData, []>, TestData>>;
170
171// ============================================================================
172// Array element type extraction tests
173// ============================================================================
174
175type ArrayElement<T> = T extends readonly (infer U)[] ? U : never;
176
177type ArrayTest1 = Expect<Equal<ArrayElement<string[]>, string>>;
178type ArrayTest2 = Expect<Equal<ArrayElement<number[]>, number>>;
179type ArrayTest3 = Expect<
180 Equal<ArrayElement<Array<{ id: number }>>, { id: number }>
181>;
182type ArrayTest4 = Expect<
183 Equal<
184 ArrayElement<GetObjectPath<TestData, ["items"]>>,
185 { id: number; label: string }
186 >
187>;
188type ArrayTest5 = Expect<
189 Equal<ArrayElement<GetObjectPath<TestData, ["tags"]>>, string>
190>;
191
192// ============================================================================
193// Conditional type tests for helper functions
194// ============================================================================
195
196// Test that we can extract array element types from paths
197type ExtractArrayElement<
198 Data extends object,
199 Path extends AllObjectPaths<Data>,
200> = GetObjectPath<Data, Path> extends readonly (infer T)[] ? T : never;
201
202type ElementTest1 = Expect<
203 Equal<
204 ExtractArrayElement<TestData, ["items"]>,
205 { id: number; label: string }
206 >
207>;
208type ElementTest2 = Expect<
209 Equal<ExtractArrayElement<TestData, ["tags"]>, string>
210>;
211
212// Test that non-array paths return never
213type ElementTest3 = Expect<
214 Equal<ExtractArrayElement<TestData, ["name"]>, never>
215>;
216
217// Test number extraction
218type IsNumber<
219 Data extends object,
220 Path extends AllObjectPaths<Data>,
221> = GetObjectPath<Data, Path> extends number ? true : false;
222
223type NumberTest1 = Expect<Equal<IsNumber<TestData, ["count"]>, true>>;
224type NumberTest2 = Expect<Equal<IsNumber<TestData, ["name"]>, false>>;
225
226// Test boolean extraction
227type IsBoolean<
228 Data extends object,
229 Path extends AllObjectPaths<Data>,
230> = GetObjectPath<Data, Path> extends boolean ? true : false;
231
232type BooleanTest1 = Expect<Equal<IsBoolean<TestData, ["active"]>, true>>;
233type BooleanTest2 = Expect<Equal<IsBoolean<TestData, ["count"]>, false>>;
234
235// Test object extraction
236type IsObject<
237 Data extends object,
238 Path extends AllObjectPaths<Data>,
239> = GetObjectPath<Data, Path> extends object ? true : false;
240
241type ObjectTest1 = Expect<Equal<IsObject<TestData, ["settings"]>, true>>;
242type ObjectTest2 = Expect<Equal<IsObject<TestData, ["items"]>, true>>;
243type ObjectTest3 = Expect<Equal<IsObject<TestData, ["name"]>, false>>;
244
245// ============================================================================
246// Edge cases
247// ============================================================================
248
249// Readonly arrays should work
250interface ReadonlyData {
251 readonly items: readonly { id: number }[];
252}
253
254type ReadonlyTest1 = Expect<
255 Equal<
256 GetObjectPath<ReadonlyData, ["items"]>,
257 readonly { id: number }[]
258 >
259>;
260type ReadonlyTest2 = Expect<
261 Equal<
262 GetObjectPath<ReadonlyData, ["items", number]>,
263 { id: number }
264 >
265>;
266type ReadonlyTest3 = Expect<
267 Equal<
268 GetObjectPath<ReadonlyData, ["items", number, "id"]>,
269 number
270 >
271>;
272
273// Optional properties
274interface OptionalData {
275 required: string;
276 optional?: number;
277 nested?: {
278 value: boolean;
279 };
280}
281
282type OptionalTest1 = Expect<
283 Equal<GetObjectPath<OptionalData, ["required"]>, string>
284>;
285type OptionalTest2 = Expect<
286 Equal<GetObjectPath<OptionalData, ["optional"]>, number | undefined>
287>;
288
289// Union types
290interface UnionData {
291 value: string | number;
292 items: Array<{ type: "a"; a: string } | { type: "b"; b: number }>;
293}
294
295type UnionTest1 = Expect<
296 Equal<GetObjectPath<UnionData, ["value"]>, string | number>
297>;
298
299// ============================================================================
300// Real-world usage simulation
301// ============================================================================
302
303// Simulate the actual helper function signatures
304type ObjSetSignature<
305 Data extends object,
306 Path extends AllObjectPaths<Data>,
307> = (
308 path: Path,
309 value:
310 | Exclude<GetObjectPath<Data, Path>, Function>
311 | ((prev: GetObjectPath<Data, Path>) => GetObjectPath<Data, Path>),
312) => void;
313
314// This should accept string or function
315declare const objSetName: ObjSetSignature<TestData, ["name"]>;
316objSetName(["name"], "test");
317objSetName(["name"], (prev) => prev.toUpperCase());
318
319// This should accept number or function
320declare const objSetCount: ObjSetSignature<TestData, ["count"]>;
321objSetCount(["count"], 42);
322objSetCount(["count"], (n) => n + 1);
323
324// Array push signature
325type ArrayPushSignature<
326 Data extends object,
327 Path extends AllObjectPaths<Data>,
328> = GetObjectPath<Data, Path> extends readonly (infer T)[]
329 ? (path: Path, ...items: T[]) => void
330 : never;
331
332// This should accept individual items, not arrays
333declare const arrayPushItems: ArrayPushSignature<TestData, ["items"]>;
334arrayPushItems(
335 ["items"],
336 { id: 1, label: "first" },
337 { id: 2, label: "second" },
338);
339
340declare const arrayPushTags: ArrayPushSignature<TestData, ["tags"]>;
341arrayPushTags(["tags"], "alpha", "beta", "gamma");
342
343// Array remove signature
344type ArrayRemoveSignature<
345 Data extends object,
346 Path extends AllObjectPaths<Data>,
347> = GetObjectPath<Data, Path> extends readonly (infer T)[]
348 ? (path: Path, filter: (item: T, index: number) => boolean) => void
349 : never;
350
351declare const arrayRemoveItems: ArrayRemoveSignature<TestData, ["items"]>;
352arrayRemoveItems(["items"], (item) => item.id === 1);
353arrayRemoveItems(["items"], (item, index) => index === 0);
354
355declare const arrayRemoveTags: ArrayRemoveSignature<TestData, ["tags"]>;
356arrayRemoveTags(["tags"], (tag) => tag === "alpha");
357
358// Increment signature
359type IncrementSignature<
360 Data extends object,
361 Path extends AllObjectPaths<Data>,
362> = GetObjectPath<Data, Path> extends number
363 ? (path: Path, amount?: number) => void
364 : never;
365
366declare const increment: IncrementSignature<TestData, ["count"]>;
367increment(["count"]);
368increment(["count"], 5);
369
370// Should not work on non-numbers (type should be never)
371type IncrementNameTest = Expect<
372 Equal<IncrementSignature<TestData, ["name"]>, never>
373>;
374
375// Toggle signature
376type ToggleSignature<
377 Data extends object,
378 Path extends AllObjectPaths<Data>,
379> = GetObjectPath<Data, Path> extends boolean ? (path: Path) => void
380 : never;
381
382declare const toggle: ToggleSignature<TestData, ["active"]>;
383toggle(["active"]);
384
385// Should not work on non-booleans (type should be never)
386type ToggleCountTest = Expect<
387 Equal<ToggleSignature<TestData, ["count"]>, never>
388>;
389
390// ============================================================================
391// Verify no `any` types leaked through
392// ============================================================================
393
394type NoAnyTest1 = Expect<NotAny<GetObjectPath<TestData, ["name"]>>>;
395type NoAnyTest2 = Expect<NotAny<GetObjectPath<TestData, ["items"]>>>;
396type NoAnyTest3 = Expect<NotAny<GetObjectPath<TestData, ["items", number]>>>;
397type NoAnyTest4 = Expect<
398 NotAny<GetObjectPath<TestData, ["settings", "theme"]>>
399>;
400
401export type {
402 ArrayPushSignature,
403 ArrayRemoveSignature,
404 IncrementSignature,
405 ObjSetSignature,
406 ToggleSignature,
407};
test/queued.test.ts deleted-967
......@@ -1,967 +0,0 @@
1import { assertEquals, assertRejects } from "@std/assert";
2import { MutationClient } from "../src/client.ts";
3import type { MutationEvent } from "../src/types.ts";
4import { test } from "vitest";
5
6// Helper to create a test mutation client
7function createTestClient() {
8 const errors: unknown[] = [];
9 const client = new MutationClient({
10 context: { userId: "test-user" },
11 getOptimisticHelpers({ onRestore }) {
12 return {
13 setValue(key: string, value: string) {
14 testStore.set(key, value);
15 onRestore(() => testStore.delete(key));
16 },
17 };
18 },
19 reportError(message, error) {
20 errors.push(error);
21 },
22 });
23
24 return { client, errors };
25}
26
27const testStore = new Map<string, string>();
28
29// Helper to track mutation events
30function createEventTracker<Result>() {
31 const events: Array<MutationEvent<Result>> = [];
32 const callback = (event: MutationEvent<Result>) => {
33 events.push(event);
34 };
35 return { events, callback };
36}
37
38// Helper to wait for async operations
39function delay(ms: number) {
40 return new Promise((resolve) => setTimeout(resolve, ms));
41}
42
43test("QueuedMutation - basic mutation success", async () => {
44 const { client } = createTestClient();
45 let mutateCallCount = 0;
46 let refetchCallCount = 0;
47
48 const mutation = client.defineBlocking({
49 async mutate(value: string) {
50 mutateCallCount++;
51 await delay(10);
52 return `result-${value}`;
53 },
54 describe: "test mutation",
55 optimistic() {
56 // Empty optimistic update
57 },
58 async refetch() {
59 refetchCallCount++;
60 await delay(5);
61 },
62 });
63
64 const result = await mutation.runAndReturn("test");
65 // Wait for refetch to complete
66 await delay(20);
67
68 assertEquals(result, "result-test");
69 assertEquals(mutateCallCount, 1);
70 assertEquals(refetchCallCount, 1);
71});
72
73test("QueuedMutation - run() catches errors", async () => {
74 const { client, errors } = createTestClient();
75
76 const mutation = client.defineBlocking({
77 async mutate(_value: string) {
78 throw new Error("mutation failed");
79 },
80 describe: "failing mutation",
81 optimistic() {},
82 async refetch() {},
83 });
84
85 mutation.run("test");
86 await delay(50);
87
88 assertEquals(errors.length, 1);
89 assertEquals((errors[0] as Error).message, "mutation failed");
90});
91
92test("QueuedMutation - runAndReturn() rejects on error", async () => {
93 const { client } = createTestClient();
94
95 const mutation = client.defineBlocking({
96 async mutate(_value: string) {
97 throw new Error("mutation failed");
98 },
99 describe: "failing mutation",
100 optimistic() {},
101 async refetch() {},
102 });
103
104 await assertRejects(
105 () => mutation.runAndReturn("test"),
106 Error,
107 "mutation failed",
108 );
109});
110
111test("QueuedMutation - optimistic updates are applied immediately", async () => {
112 const { client } = createTestClient();
113 testStore.clear();
114
115 const mutation = client.defineBlocking({
116 async mutate(_key: string, value: string) {
117 await delay(50);
118 return value;
119 },
120 describe: "set value",
121 optimistic({ args, helpers }) {
122 const [key, value] = args;
123 helpers.setValue(key, value);
124 },
125 async refetch() {},
126 });
127
128 const promise = mutation.runAndReturn("key1", "value1");
129
130 // Optimistic update should be applied synchronously
131 assertEquals(testStore.get("key1"), "value1");
132
133 // Wait for mutation to complete
134 await promise;
135 await delay(10);
136});
137
138test("QueuedMutation - rollback on error", async () => {
139 const { client } = createTestClient();
140 testStore.clear();
141
142 const mutation = client.defineBlocking({
143 async mutate(_key: string, _value: string) {
144 await delay(10);
145 throw new Error("mutation failed");
146 },
147 describe: "failing mutation",
148 optimistic({ args, helpers }) {
149 const [key, value] = args;
150 helpers.setValue(key, value);
151 },
152 async refetch() {},
153 });
154
155 await assertRejects(() => mutation.runAndReturn("key1", "value1"));
156
157 // Optimistic update should be rolled back
158 assertEquals(testStore.has("key1"), false);
159});
160
161test("QueuedMutation - onSuccess callback is called", async () => {
162 const { client } = createTestClient();
163 const successResults: string[] = [];
164
165 const mutation = client.defineBlocking({
166 async mutate(value: string) {
167 return `result-${value}`;
168 },
169 describe: "test mutation",
170 optimistic({ onSuccess }) {
171 onSuccess((result) => {
172 successResults.push(result);
173 });
174 },
175 async refetch() {},
176 });
177
178 await mutation.runAndReturn("test");
179
180 assertEquals(successResults, ["result-test"]);
181});
182
183test("QueuedMutation - mutations with same key execute serially", async () => {
184 const { client } = createTestClient();
185 const executionOrder: string[] = [];
186
187 const mutation = client.defineBlocking({
188 async mutate(id: string) {
189 executionOrder.push(`start-${id}`);
190 await delay(20);
191 executionOrder.push(`end-${id}`);
192 return id;
193 },
194 describe: "test mutation",
195 optimistic() {},
196 async refetch() {},
197 refetchOnSuccess: false,
198 key() {
199 return "same-key";
200 },
201 });
202
203 // Start two mutations with the same key
204 const promise1 = mutation.runAndReturn("1");
205 const promise2 = mutation.runAndReturn("2");
206
207 await Promise.all([promise1, promise2]);
208 await delay(10);
209
210 // They should execute serially, not in parallel
211 assertEquals(executionOrder, ["start-1", "end-1", "start-2", "end-2"]);
212});
213
214test("QueuedMutation - mutations with different keys execute in parallel", async () => {
215 const { client } = createTestClient();
216 const executionOrder: string[] = [];
217
218 const mutation = client.defineBlocking({
219 async mutate(id: string) {
220 executionOrder.push(`start-${id}`);
221 await delay(20);
222 executionOrder.push(`end-${id}`);
223 return id;
224 },
225 describe: "test mutation",
226 optimistic() {},
227 async refetch() {},
228 key({ args }) {
229 const [id] = args;
230 return id;
231 },
232 });
233
234 // Start two mutations with different keys
235 const promise1 = mutation.runAndReturn("key1");
236 const promise2 = mutation.runAndReturn("key2");
237
238 await Promise.all([promise1, promise2]);
239
240 // They should start in parallel
241 assertEquals(executionOrder.slice(0, 2).sort(), ["start-key1", "start-key2"]);
242});
243
244test("QueuedMutation - key() returns JSON stringified key", () => {
245 const { client } = createTestClient();
246
247 const mutation = client.defineBlocking({
248 async mutate(id: string) {
249 return id;
250 },
251 describe: "test mutation",
252 optimistic() {},
253 async refetch() {},
254 key({ args }) {
255 const [id] = args;
256 return id;
257 },
258 });
259
260 assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id"));
261});
262
263test("QueuedMutation - key() defaults to 'shared' when no key function", () => {
264 const { client } = createTestClient();
265
266 const mutation = client.defineBlocking({
267 async mutate(id: string) {
268 return id;
269 },
270 describe: "test mutation",
271 optimistic() {},
272 async refetch() {},
273 });
274
275 assertEquals(mutation.key(["test-id"]), JSON.stringify("shared"));
276});
277
278test("QueuedMutation - key() can return array", () => {
279 const { client } = createTestClient();
280
281 const mutation = client.defineBlocking({
282 async mutate(_userId: string, _itemId: string) {
283 return "result";
284 },
285 describe: "test mutation",
286 optimistic() {},
287 async refetch() {},
288 key({ args }) {
289 const [userId, itemId] = args;
290 return [userId, itemId];
291 },
292 });
293
294 assertEquals(
295 mutation.key(["user1", "item1"]),
296 JSON.stringify(["user1", "item1"]),
297 );
298});
299
300test("QueuedMutation - describe() with string", () => {
301 const { client } = createTestClient();
302
303 const mutation = client.defineBlocking({
304 async mutate(value: string) {
305 return value;
306 },
307 describe: "create item",
308 optimistic() {},
309 async refetch() {},
310 });
311
312 assertEquals(mutation.describe("test"), "create item");
313});
314
315test("QueuedMutation - describe() with function", () => {
316 const { client } = createTestClient();
317
318 const mutation = client.defineBlocking({
319 async mutate(id: string) {
320 return id;
321 },
322 describe({ args }) {
323 const [id] = args;
324 return `delete item ${id}`;
325 },
326 optimistic() {},
327 async refetch() {},
328 });
329
330 assertEquals(mutation.describe("123"), "delete item 123");
331});
332
333test("QueuedMutation - describe() receives context", () => {
334 const { client } = createTestClient();
335
336 const mutation = client.defineBlocking({
337 async mutate(id: string) {
338 return id;
339 },
340 describe({ userId, args }) {
341 const [id] = args;
342 return `user ${userId} editing item ${id}`;
343 },
344 optimistic() {},
345 async refetch() {},
346 });
347
348 assertEquals(
349 mutation.describe("123"),
350 "user test-user editing item 123",
351 );
352});
353
354test("QueuedMutation - subscribe() tracks mutation events", async () => {
355 const { client } = createTestClient();
356 const tracker = createEventTracker<string>();
357
358 const mutation = client.defineBlocking({
359 async mutate(value: string) {
360 await delay(10);
361 return `result-${value}`;
362 },
363 describe: "test mutation",
364 optimistic() {},
365 async refetch() {
366 await delay(5);
367 },
368 });
369
370 const key = mutation.key(["test"]);
371 mutation.subscribe(key, tracker.callback);
372
373 await mutation.runAndReturn("test");
374 // Wait for refetch to complete
375 await delay(20);
376
377 // Should have received status updates
378 assertEquals(tracker.events.length >= 2, true);
379 assertEquals(tracker.events.some((e) => e.status === "mutating"), true);
380 assertEquals(tracker.events.some((e) => e.status === "refetching"), true);
381});
382
383test("QueuedMutation - unsubscribe stops receiving events", async () => {
384 const { client } = createTestClient();
385 const tracker = createEventTracker<string>();
386
387 const mutation = client.defineBlocking({
388 async mutate(value: string) {
389 await delay(10);
390 return value;
391 },
392 describe: "test mutation",
393 optimistic() {},
394 async refetch() {},
395 refetchOnSuccess: false,
396 });
397
398 const key = mutation.key(["test"]);
399 const unsubscribe = mutation.subscribe(key, tracker.callback);
400
401 unsubscribe();
402
403 await mutation.runAndReturn("test");
404 await delay(10);
405
406 // Should not have received any events
407 assertEquals(tracker.events.length, 0);
408});
409
410test("QueuedMutation - refetchOnSuccess can be disabled", async () => {
411 const { client } = createTestClient();
412 let refetchCallCount = 0;
413
414 const mutation = client.defineBlocking({
415 async mutate(_value: string) {
416 return _value;
417 },
418 describe: "test mutation",
419 optimistic() {},
420 async refetch() {
421 refetchCallCount++;
422 },
423 refetchOnSuccess: false,
424 });
425
426 await mutation.runAndReturn("test");
427
428 assertEquals(refetchCallCount, 0);
429});
430
431test("QueuedMutation - refetch is called on error", async () => {
432 const { client } = createTestClient();
433 let refetchCallCount = 0;
434
435 const mutation = client.defineBlocking({
436 async mutate(_value: string) {
437 throw new Error("mutation failed");
438 },
439 describe: "failing mutation",
440 optimistic() {},
441 async refetch() {
442 refetchCallCount++;
443 },
444 });
445
446 await assertRejects(() => mutation.runAndReturn("test"));
447
448 assertEquals(refetchCallCount, 1);
449});
450
451test("QueuedMutation - queued mutations are cancelled on error", async () => {
452 const { client } = createTestClient();
453 const executionOrder: string[] = [];
454
455 const mutation = client.defineBlocking({
456 async mutate(id: string) {
457 executionOrder.push(`start-${id}`);
458 await delay(10);
459 if (id === "1") {
460 throw new Error("first mutation failed");
461 }
462 executionOrder.push(`end-${id}`);
463 return id;
464 },
465 describe: "test mutation",
466 optimistic() {},
467 async refetch() {},
468 key() {
469 return "same-key";
470 },
471 });
472
473 const promise1 = mutation.runAndReturn("1");
474 const promise2 = mutation.runAndReturn("2");
475 const promise3 = mutation.runAndReturn("3");
476
477 await assertRejects(() => promise1, Error, "first mutation failed");
478 await assertRejects(() => promise2, Error, "first mutation failed");
479 await assertRejects(() => promise3, Error, "first mutation failed");
480
481 // Only the first mutation should start
482 assertEquals(executionOrder, ["start-1"]);
483});
484
485test("QueuedMutation - rollbacks are called in reverse order on error", async () => {
486 const { client } = createTestClient();
487 const rollbackOrder: number[] = [];
488
489 const mutation = client.defineBlocking({
490 async mutate(_value: string) {
491 throw new Error("mutation failed");
492 },
493 describe: "failing mutation",
494 optimistic({ onRestore }) {
495 onRestore(() => rollbackOrder.push(1));
496 onRestore(() => rollbackOrder.push(2));
497 onRestore(() => rollbackOrder.push(3));
498 },
499 async refetch() {},
500 });
501
502 await assertRejects(() => mutation.runAndReturn("test"));
503
504 // Rollbacks should be called in reverse order
505 assertEquals(rollbackOrder, [3, 2, 1]);
506});
507
508test("QueuedMutation - multiple mutations: rollbacks only affect failed mutation", async () => {
509 const { client } = createTestClient();
510 const rollbackOrder: string[] = [];
511
512 const mutation = client.defineBlocking({
513 async mutate(id: string) {
514 await delay(10);
515 if (id === "fail") {
516 throw new Error("mutation failed");
517 }
518 return id;
519 },
520 describe: "test mutation",
521 optimistic({ args: [id], onRestore }) {
522 onRestore(() => rollbackOrder.push(`rollback-${id}`));
523 },
524 async refetch() {},
525 key() {
526 return "same-key";
527 },
528 });
529
530 // First mutation succeeds
531 await mutation.runAndReturn("success");
532
533 // Second mutation fails
534 await assertRejects(() => mutation.runAndReturn("fail"));
535
536 // Only the failed mutation's rollback should be called
537 // And all rollbacks from queued items
538 assertEquals(rollbackOrder, ["rollback-fail"]);
539});
540
541test("QueuedMutation - onRestore throws error if called after optimistic phase", async () => {
542 const { client } = createTestClient();
543 let capturedOnRestore: ((cb: () => void) => void) | null = null;
544
545 const mutation = client.defineBlocking({
546 async mutate(_value: string) {
547 return "result";
548 },
549 describe: "test mutation",
550 optimistic({ onRestore }) {
551 capturedOnRestore = onRestore;
552 },
553 async refetch() {},
554 });
555
556 await mutation.runAndReturn("test");
557
558 // Calling onRestore after the optimistic phase should throw
559 let error: Error | null = null;
560 try {
561 capturedOnRestore!(() => {});
562 } catch (e) {
563 error = e as Error;
564 }
565
566 assertEquals(
567 error?.message,
568 "Can only call onRestore from within the optimistic update function.",
569 );
570});
571
572test("QueuedMutation - onSuccess throws error if called after optimistic phase", async () => {
573 const { client } = createTestClient();
574 let capturedOnSuccess: ((cb: (result: string) => void) => void) | null = null;
575
576 const mutation = client.defineBlocking({
577 async mutate(_value: string) {
578 return "result";
579 },
580 describe: "test mutation",
581 optimistic({ onSuccess }) {
582 capturedOnSuccess = onSuccess;
583 },
584 async refetch() {},
585 });
586
587 await mutation.runAndReturn("test");
588
589 // Calling onSuccess after the optimistic phase should throw
590 let error: Error | null = null;
591 try {
592 capturedOnSuccess!(() => {});
593 } catch (e) {
594 error = e as Error;
595 }
596
597 assertEquals(
598 error?.message,
599 "Can only call onSuccess from within the optimistic update function.",
600 );
601});
602
603test("QueuedMutation - error during optimistic update is rejected immediately", async () => {
604 const { client } = createTestClient();
605
606 const mutation = client.defineBlocking({
607 async mutate(_value: string) {
608 return "result";
609 },
610 describe: "test mutation",
611 optimistic() {
612 throw new Error("optimistic update failed");
613 },
614 async refetch() {},
615 });
616
617 await assertRejects(
618 () => mutation.runAndReturn("test"),
619 Error,
620 "optimistic update failed",
621 );
622});
623
624test("QueuedMutation - error during optimistic update rolls back registered callbacks", async () => {
625 const { client } = createTestClient();
626 const rollbackOrder: number[] = [];
627
628 const mutation = client.defineBlocking({
629 async mutate(_value: string) {
630 return "result";
631 },
632 describe: "test mutation",
633 optimistic({ onRestore }) {
634 onRestore(() => rollbackOrder.push(1));
635 onRestore(() => rollbackOrder.push(2));
636 throw new Error("optimistic update failed");
637 },
638 async refetch() {},
639 });
640
641 await assertRejects(() => mutation.runAndReturn("test"));
642
643 // Rollbacks should be called even though optimistic update failed
644 // Note: during optimistic error, rollbacks are executed in the order they were added
645 assertEquals(rollbackOrder, [1, 2]);
646});
647
648test("QueuedMutation - refetch errors are reported but don't fail mutation", async () => {
649 const { client, errors } = createTestClient();
650
651 const mutation = client.defineBlocking({
652 async mutate(value: string) {
653 return value;
654 },
655 describe: "test mutation",
656 optimistic() {},
657 async refetch() {
658 throw new Error("refetch failed");
659 },
660 });
661
662 // Mutation should still succeed
663 const result = await mutation.runAndReturn("test");
664 assertEquals(result, "test");
665
666 // But refetch error should be reported
667 await delay(20);
668 assertEquals(errors.length, 1);
669 assertEquals((errors[0] as Error).message, "refetch failed");
670});
671
672test("QueuedMutation - optimistic function receives args and helpers", async () => {
673 const { client } = createTestClient();
674 let receivedArgs: unknown[] | undefined;
675 let receivedHelpers: unknown | undefined;
676
677 const mutation = client.defineBlocking({
678 async mutate(_value: string) {
679 return "result";
680 },
681 describe: "test mutation",
682 optimistic({ args, helpers }) {
683 receivedArgs = args;
684 receivedHelpers = helpers;
685 },
686 async refetch() {},
687 });
688
689 await mutation.runAndReturn("test");
690
691 assertEquals(receivedArgs, ["test"]);
692 assertEquals(typeof receivedHelpers, "object");
693});
694
695test("QueuedMutation - refetch receives context and args", async () => {
696 const { client } = createTestClient();
697 let receivedUserId: string | undefined;
698 let receivedArgs: unknown[] | undefined;
699
700 const mutation = client.defineBlocking({
701 async mutate(_id: string, value: string) {
702 return value;
703 },
704 describe: "test mutation",
705 optimistic() {},
706 async refetch({ userId, args }) {
707 receivedUserId = userId;
708 receivedArgs = args;
709 },
710 });
711
712 await mutation.runAndReturn("test-id", "test-value");
713
714 assertEquals(receivedUserId, "test-user");
715 assertEquals(receivedArgs, ["test-id", "test-value"]);
716});
717
718test("QueuedMutation - notifies error on mutation failure", async () => {
719 const { client } = createTestClient();
720 const tracker = createEventTracker<string>();
721
722 const mutation = client.defineBlocking({
723 async mutate(_value: string) {
724 await delay(10);
725 throw new Error("mutation failed");
726 },
727 describe: "failing mutation",
728 optimistic() {},
729 async refetch() {},
730 });
731
732 const key = mutation.key(["test"]);
733 mutation.subscribe(key, tracker.callback);
734
735 await assertRejects(() => mutation.runAndReturn("test"));
736
737 // Should have error event
738 const errorEvents = tracker.events.filter((e) =>
739 e.status === "mutating" && e.error
740 );
741 assertEquals(errorEvents.length > 0, true);
742 assertEquals((errorEvents[0]?.error as Error).message, "mutation failed");
743});
744
745test("QueuedMutation - multiple subscribers receive events", async () => {
746 const { client } = createTestClient();
747 const tracker1 = createEventTracker<string>();
748 const tracker2 = createEventTracker<string>();
749
750 const mutation = client.defineBlocking({
751 async mutate(value: string) {
752 await delay(5);
753 return value;
754 },
755 describe: "test mutation",
756 optimistic() {},
757 async refetch() {},
758 refetchOnSuccess: false,
759 });
760
761 const key = mutation.key(["test"]);
762 mutation.subscribe(key, tracker1.callback);
763 mutation.subscribe(key, tracker2.callback);
764
765 await mutation.runAndReturn("test");
766 await delay(10);
767
768 // Both subscribers should receive events
769 assertEquals(tracker1.events.length, tracker2.events.length);
770 assertEquals(tracker1.events.length > 0, true);
771});
772
773test("QueuedMutation - onSuccess is called before mutation resolves", async () => {
774 const { client } = createTestClient();
775 const callOrder: string[] = [];
776
777 const mutation = client.defineBlocking({
778 async mutate(value: string) {
779 return value;
780 },
781 describe: "test mutation",
782 optimistic({ onSuccess }) {
783 onSuccess(() => {
784 callOrder.push("onSuccess");
785 });
786 },
787 async refetch() {},
788 refetchOnSuccess: false,
789 });
790
791 const promise = mutation.runAndReturn("test");
792 promise.then(() => {
793 callOrder.push("then");
794 });
795
796 await promise;
797 await delay(5);
798
799 // onSuccess should be called before the promise resolves
800 assertEquals(callOrder, ["onSuccess", "then"]);
801});
802
803test("QueuedMutation - result is passed to notification on success", async () => {
804 const { client } = createTestClient();
805 const tracker = createEventTracker<string>();
806
807 const mutation = client.defineBlocking({
808 async mutate(value: string) {
809 await delay(5);
810 return `result-${value}`;
811 },
812 describe: "test mutation",
813 optimistic() {},
814 async refetch() {
815 await delay(5);
816 },
817 });
818
819 const key = mutation.key(["test"]);
820 mutation.subscribe(key, tracker.callback);
821
822 await mutation.runAndReturn("test");
823 await delay(20);
824
825 // Should have refetching event with result
826 const refetchingEvents = tracker.events.filter((e) =>
827 e.status === "refetching"
828 );
829 assertEquals(refetchingEvents.length > 0, true);
830 assertEquals(refetchingEvents[0]?.result, "result-test");
831});
832
833test("QueuedMutation - channel is reused for same key", async () => {
834 const { client } = createTestClient();
835 const events: string[] = [];
836
837 const mutation = client.defineBlocking({
838 async mutate(value: string) {
839 events.push(`mutate-${value}`);
840 return value;
841 },
842 describe: "test mutation",
843 optimistic() {},
844 async refetch() {},
845 refetchOnSuccess: false,
846 });
847
848 // First mutation
849 await mutation.runAndReturn("first");
850 await delay(5);
851
852 // Second mutation with same key
853 await mutation.runAndReturn("second");
854 await delay(5);
855
856 assertEquals(events, ["mutate-first", "mutate-second"]);
857});
858
859test("QueuedMutation - empty queue after all mutations complete", async () => {
860 const { client } = createTestClient();
861
862 const mutation = client.defineBlocking({
863 async mutate(value: string) {
864 await delay(5);
865 return value;
866 },
867 describe: "test mutation",
868 optimistic() {},
869 async refetch() {},
870 refetchOnSuccess: false,
871 key() {
872 return "test-key";
873 },
874 });
875
876 // Run multiple mutations
877 await mutation.runAndReturn("1");
878 await mutation.runAndReturn("2");
879 await mutation.runAndReturn("3");
880 await delay(10);
881
882 // All mutations should have completed
883 // (We can't directly check the queue, but we can verify by running another mutation)
884 const start = Date.now();
885 await mutation.runAndReturn("4");
886 const duration = Date.now() - start;
887
888 // Should execute immediately, not be queued (< 10ms if not queued)
889 assertEquals(duration < 15, true);
890});
891
892test("QueuedMutation - multiple onSuccess callbacks are all called", async () => {
893 const { client } = createTestClient();
894 const results: string[] = [];
895
896 const mutation = client.defineBlocking({
897 async mutate(value: string) {
898 return value;
899 },
900 describe: "test mutation",
901 optimistic({ onSuccess }) {
902 onSuccess((result) => results.push(`first-${result}`));
903 onSuccess((result) => results.push(`second-${result}`));
904 onSuccess((result) => results.push(`third-${result}`));
905 },
906 async refetch() {},
907 refetchOnSuccess: false,
908 });
909
910 await mutation.runAndReturn("test");
911
912 assertEquals(results, ["first-test", "second-test", "third-test"]);
913});
914
915test("QueuedMutation - refetchOnSuccess false skips refetch", async () => {
916 const { client } = createTestClient();
917 let refetchCalled = false;
918
919 const mutation = client.defineBlocking({
920 async mutate(value: string) {
921 return value;
922 },
923 describe: "test mutation",
924 optimistic() {},
925 async refetch() {
926 refetchCalled = true;
927 },
928 refetchOnSuccess: false,
929 });
930
931 await mutation.runAndReturn("test");
932 await delay(10);
933
934 // Refetch should not have been called
935 assertEquals(refetchCalled, false);
936});
937
938test("QueuedMutation - refetch error after mutation failure is reported", async () => {
939 const { client, errors } = createTestClient();
940
941 const mutation = client.defineBlocking({
942 async mutate(_value: string) {
943 throw new Error("mutation failed");
944 },
945 describe: "failing mutation",
946 optimistic() {},
947 async refetch() {
948 throw new Error("refetch also failed");
949 },
950 });
951
952 await assertRejects(
953 () => mutation.runAndReturn("test"),
954 Error,
955 "mutation failed",
956 );
957
958 // Wait for refetch to complete and error to be reported
959 await delay(20);
960
961 // Should have both the mutation error and refetch error reported
962 assertEquals(errors.length >= 1, true);
963 assertEquals(
964 (errors[errors.length - 1] as Error).message,
965 "refetch also failed",
966 );
967});