diff --git a/example/src/App.tsx b/example/src/App.tsx index 67e13035b581a3437a2c1990f5ee109fd7927e24..ab99be9c72a92bdbc8f0e704c08f507b005dfc6d 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -69,6 +69,10 @@ const mutIncrement = mutationClient.defineDebounced({ }, describe: "update counter", + describeResult: ({ initial, current }) => { + const delta = current - initial; + return `Counter updated by ${delta > 0 ? '+' : ''}${delta}`; + }, }); function CustomButton( diff --git a/jsr.json b/jsr.json index 85a7159f32565ad14a17132bd0e62a719b72f355..89cf8b9a0e8c3bc7d4c131025d5dee86efc3c478 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@clo/react-mutation", - "version": "1.0.0-beta.2", + "version": "1.0.0-beta.3", "exports": { ".": "./src/mod.ts", "./tanstack-query.ts": "./src/tanstack-query.ts", diff --git a/readme.md b/readme.md index f86612a2c934f2ae0b5f7ffa0a95361d64e7bf6a..b1ed3256e5fed7486aec6b2c64c661190256b584 100644 --- a/readme.md +++ b/readme.md @@ -15,7 +15,7 @@ The primary gains React Mutation provides are display a UI toast. Otherwise, the component can display the error locally. - Optimistic helpers allow defining rollbacks and refetching logic independant of the actual mutation. The [built in helpers for React Query](#React-Query-Optimistic-Helpers) show this power in more detail. -- Batched Mutations are just so awesome to use. +- Debounced Mutations are just so awesome to use. ## Usage @@ -79,7 +79,7 @@ const queryItemList = queryOptions({ ... }); const queryItem = (id: string) => queryOptions({ ... }); // The convention is to name handlers starting with `mut` -const mutDeleteItem = mutations.defineQueued({ +const mutDeleteItem = mutations.defineBlocking({ // `mutate` comes first, is only worried about syncing with the backend. async mutate(id: string) { const response = await fetch(`/items/${id}`, { method: "delete" }); @@ -122,7 +122,7 @@ export function Example({ id }: { id: string }) { ### Debounced Mutations -A debounced mutation is defined with `mutations.defineBatched`. +A debounced mutation is defined with `mutations.defineDebounced`. ```tsx const mutSetItemName = mutationClient.defineDebounced({ @@ -178,4 +178,72 @@ function Item({ id }: { id: string }) { } ``` -### +### Optimistic Updates + +The `optimistic` function is given an object with the following APIs + +- All values from `MutationClient`'s `context`, spread. With React Query this is `get` and `client`. +- `helpers` - is the return type of `getOptimisticHelpers` (see next section) +- `args` - which is the arguments passed to the mutator +- `onSuccess` - add a callback to update queries after a success +- `onRestore` - add a callback to revert your optimistic update +- `onRefetch` - add a callback to fetch data after a success + +### React Query Optimistic Helpers + +When using React Query, you can opt into some incredible helpers for making it +very easy to write Optimistic Updates. Our setup at work is with this client +configuration. + +```ts +import { MutationClient } from "@clo/react-mutation"; +import { + boundQueryClientGet, + queryClientOptimisticHelpers, +} from "@clo/react-mutation/tanstack-query.ts"; +import { isServer } from "@tanstack/react-query"; +import { getQueryClient, makeNewQueryClient } from "./react-query-client"; + +const client = isServer ? makeNewQueryClient() : getQueryClient(); +export const mutations = new MutationClient({ + enabled: !isServer, + context: { + client, + get: boundQueryClientGet(client), + }, + getOptimisticHelpers: queryClientOptimisticHelpers(client), + reportError(message) { + showAlert(message, "error"); + }, + reportSuccess(message: string) { + showAlert(message, "success"); + }, +}); +``` + +Within optimistic updates, a `helpers` object is provided with many useful +helper functions. All helper functions take a `QueryKeyAndFn` (return type of +TanStack Query's `queryOptions`), and will track every query touched to +automatically implement `onRefetch` and `onRestore` callbacks. The current list of them is: + +- `set` - overwrite an entire query +- `updateExisting` - overwrite an entire query only if it exists +- `removeQuery` - delete a query, but restore and refetch when rolled back. +- For queries that resolve to arrays: + - `arrayPush` - add items to the end + - `arrayUnshift` - add items to the start + - `arrayRemove` - remove items by a `filter` function + - `arrayUpdate` - update items by a `filter` + `update` function + - `arrayInsertIndex` - insert an item at an index +- **experimental**: Queries that are complex options. Each function takes a type-safe + json path to evaluate, but this system has type bugs. + - `objSet` - set a property + - `objSetMany` - set many properties at once + - `objIncrement` - increment a number + - `objDecrement` - decrement a number + - `objToggle` - toggle a boolean + - `objArrayPush` - add items to the end of an array + - `objArrayUnshift` - add items to the start of an array + - `objArrayRemove` - remove items from array by `filter` + - `objArrayUpdate` - update items in array by `filter` + `update` + - `objArrayInsertIndex` - insert an item in an array at an index diff --git a/src/batch.ts b/src/batch.ts deleted file mode 100644 index 1e1e6540bff9a89dd1e21a903a13c5a05b12c2d8..0000000000000000000000000000000000000000 --- a/src/batch.ts +++ /dev/null @@ -1,486 +0,0 @@ -import type { MutationClient, MutationClientFromConfig } from "./client.ts"; -import type { MutationClientConfig } from "./client.ts"; -import type { Mutation, MutationEvent } from "./types.ts"; -import { message as errMessage } from "@clo/lib/error.ts"; - -export interface BatchMutationOptions< - Args extends unknown[], - Result, - Optimistic, - Config extends MutationClientConfig, -> { - /** - * Update the UI for one call to the optimistic function. - * A rest params type is used to allow type inference. Place this function first to - * ensure TypeScript correctly infers the argument type for the rest of the functions. - */ - optimistic: (context: BatchOptimisticContext, ...args: Args) => void; - /** - * Retrieve the current/optimistic value of the mutation. When this returns - * the same thing as when the mutation started, it means that `mutate` does - * not need to be called since the data is the same. - * - * Don't snapshot unrelated state that this mutation isn't concerned with. - */ - getValue: (context: Config["context"], ...args: Args) => Optimistic; - - /** - * @default "debounce" - */ - mode?: "debounce" | "throttle"; - /** - * Milliseconds - * @default 200 - */ - time?: number; - - /** A key to associate batch items. For example, returning a user ID */ - key: ( - context: Config["context"] & { args: NoInfer }, - ) => string | string[]; - - /** - * Commit the optimistic state. Throw on failure. - */ - commit: ( - context: BatchCommitContext, Optimistic, Config>, - ) => Promise; - /** - * Used in error messages and debug tools. - * "Failed to {action}" - */ - describe: - | string - | (( - context: BatchCommitContext, Optimistic, Config>, - ) => string); - /** - * Used in success messages. - * Phrase it as a complete success message, e.g., "Renamed item successfully" - * Set to null to suppress success reporting. - */ - describeResult?: - | string - | (( - context: BatchCommitContext, Optimistic, Config> & { result: Result }, - ) => string) - | null; - /** - * Refetch all of the data this mutation could have affected. - */ - refetch?: () => Promise; -} - -export type BatchOptimisticContext = - & Config["context"] - & { - /** Add an event listener to roll back the update */ - onRestore: (cb: () => void) => void; - helpers: Config["optimisticHelpers"]; - }; - -export type BatchCommitContext< - Args, - Optimistic, - Config extends MutationClientConfig, -> = Config["context"] & { - /** One of the arguments. Use this only to extract the shared key */ - args: Args; - /** The initial snapshot */ - initial: Optimistic; - /** The compared snapshot */ - current: Optimistic; -}; - -interface BatchChannel { - listeners: Set<(update: MutationEvent) => void>; - status: "idle" | "waiting" | "mutating" | "refetching"; - - // Snapshot before first call in current batch - initial: Optimistic | null; - // First args in batch (used for commit/describe/getValue) - firstArgs: Args | null; - rollbacks: Array<() => void>; - refetches: Array<() => Promise>; - timer: ReturnType | null; - - // Track last commit time for throttle mode - lastCommitTime: number; - - // Pending promises from callers in current batch - pending: Array<{ - args: Args; - resolve: (result: Result) => void; - reject: (error: unknown) => void; - reportSuccessGlobally?: boolean; - }>; -} - -export class BatchMutation< - Args extends unknown[], - Result, - Optimistic, - Config extends MutationClientConfig, -> implements Mutation { - #options: BatchMutationOptions; - #client: MutationClientFromConfig; - #channels: Map> = new Map(); - client: MutationClientFromConfig; - - constructor( - client: MutationClient, - options: BatchMutationOptions, - ) { - this.#options = options; - this.#client = client; - this.client = client; - } - - key(args: Args): string { - const k = this.#options.key({ ...this.#client.context, args }); - return JSON.stringify(k); - } - - #getOrPutChannel(key: string): BatchChannel { - let channel = this.#channels.get(key); - if (!channel) { - channel = { - listeners: new Set(), - status: "idle", - initial: null, - firstArgs: null, - rollbacks: [], - refetches: [], - timer: null, - lastCommitTime: 0, - pending: [], - }; - this.#channels.set(key, channel); - } - return channel; - } - - subscribe( - key: string, - cb: (update: MutationEvent) => void, - ): () => void { - const channel = this.#getOrPutChannel(key); - channel.listeners.add(cb); - return () => channel?.listeners.delete(cb); - } - - #notify( - channel: BatchChannel, - status: MutationEvent["status"], - result: Result | null = null, - error: unknown = null, - ) { - const event: MutationEvent = { status, result, error }; - channel.listeners.forEach((cb) => cb(event)); - } - - #setIdle(key: string, channel: BatchChannel) { - channel.status = "idle"; - this.#notify(channel, "idle", null, null); - // Clean up the channel if there are no listeners - if (channel.listeners.size === 0) { - this.#channels.delete(key); - } - } - - #resetBatchState(channel: BatchChannel) { - channel.initial = null; - channel.firstArgs = null; - channel.rollbacks = []; - channel.refetches = []; - channel.pending = []; - if (channel.timer !== null) { - clearTimeout(channel.timer); - channel.timer = null; - } - } - - describe(...args: Args): string { - const { describe } = this.#options; - if (typeof describe === "function") { - // For describe, we need initial/current but may not have them yet - // Use placeholder values when called outside of commit context - return describe({ - ...this.#client.context, - args, - initial: null as unknown as Optimistic, - current: null as unknown as Optimistic, - }); - } - return describe; - } - - // Not available for batched mutations - success reporting happens during commit - describeResult: undefined = undefined; - - #describeResult(args: Args, initial: Optimistic, current: Optimistic, result: Result): string | undefined { - const { describeResult } = this.#options; - if (describeResult === null || describeResult === undefined) return undefined; - return typeof describeResult === "function" - ? describeResult({ - ...this.#client.context, - args, - initial, - current, - result, - }) - : describeResult; - } - - /** Calling the mutation in a global scope. Errors and successes are turned into UI toasts. */ - run(...args: Args): void { - this.#runAndReturn(args, true).catch((error) => { - const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; - this.#client.reportError(message, error); - }); - } - - /** Calls the mutation, treating the errors as promise rejection. */ - runAndReturn(...args: Args): Promise { - return this.#runAndReturn(args, false); - } - - #runAndReturn(args: Args, reportSuccessGlobally: boolean): Promise { - const key = this.key(args); - const channel = this.#getOrPutChannel(key); - - // If this is the first call in the batch, take a snapshot - if (channel.initial === null) { - channel.initial = this.#options.getValue(this.#client.context, ...args); - channel.firstArgs = args; - } - - // Apply optimistic update - let expired = false; - const onRestore = (cb: () => void) => { - if (expired) { - throw new Error( - "Can only call onRestore from within the optimistic update function.", - ); - } - channel.rollbacks.push(cb); - }; - const onRefetch = (cb: () => Promise) => { - if (expired) { - throw new Error( - "Can only call onRefetch from within the optimistic update function.", - ); - } - channel.refetches.push(cb); - }; - - try { - this.#options.optimistic( - { - ...this.#client.context, - onRestore, - helpers: this.#client.getOptimisticHelpers({ - onRestore, - onRefetch, - }), - }, - ...args, - ); - } catch (error) { - expired = true; - // Rollback just this call's rollbacks - // We don't know how many were added, so we can't do partial rollback easily - // For simplicity, rollback everything and reject - let next; - while ((next = channel.rollbacks.pop())) next(); - this.#resetBatchState(channel); - return Promise.reject(error); - } - expired = true; - - // Create promise for this caller - const { promise, resolve, reject } = Promise.withResolvers(); - channel.pending.push({ args, resolve, reject, reportSuccessGlobally }); - - // Set status to waiting and notify - if (channel.status === "idle") { - channel.status = "waiting"; - this.#notify(channel, "waiting"); - } - - // Schedule commit based on mode - this.#scheduleCommit(key, channel); - - return promise; - } - - #scheduleCommit( - key: string, - channel: BatchChannel, - ) { - const time = this.#options.time ?? 200; - - if (this.#options.mode !== "throttle") { - // Debounce: reset timer on each call - if (channel.timer !== null) { - clearTimeout(channel.timer); - } - channel.timer = setTimeout(() => this.#commit(key, channel), time); - } else { - // Throttle: commit immediately if enough time passed, otherwise wait - // Use status to track if a commit is in progress - if (channel.timer === null && channel.status === "waiting") { - const elapsed = Date.now() - channel.lastCommitTime; - if (elapsed >= time) { - // Enough time has passed, commit immediately - this.#commit(key, channel); - } else { - // Wait for remaining time - channel.timer = setTimeout( - () => this.#commit(key, channel), - time - elapsed, - ); - } - } - // If timer exists or commit is in progress, do nothing - will commit when ready - } - } - - #commit(key: string, channel: BatchChannel) { - // Clear timer - if (channel.timer !== null) { - clearTimeout(channel.timer); - channel.timer = null; - } - - // Safety check - if (channel.firstArgs === null || channel.initial === null) { - this.#setIdle(key, channel); - return; - } - - const firstArgs = channel.firstArgs; - const initial = channel.initial; - const pendingItems = [...channel.pending]; - const rollbacks = [...channel.rollbacks]; - const refetchCallbacks = [...channel.refetches]; - - // Get current snapshot - const current = this.#options.getValue( - this.#client.context, - ...firstArgs, - ); - - // Check if anything changed - if (this.#client.deepEquals(initial, current)) { - // No change - resolve all pending with a null result and reset - pendingItems.forEach(({ resolve }) => resolve(null as Result)); - this.#resetBatchState(channel); - this.#setIdle(key, channel); - return; - } - - // Set status to mutating - channel.status = "mutating"; - this.#notify(channel, "mutating"); - - // Clear batch state before async operation (but keep rollbacks/refetches for error case) - channel.initial = null; - channel.firstArgs = null; - channel.pending = []; - channel.rollbacks = []; - channel.refetches = []; - - // Call commit - this.#options - .commit({ - ...this.#client.context, - args: firstArgs, - initial, - current, - }) - .then((result) => { - // Success - rollbacks are discarded (optimistic was correct) - // Resolve all pending promises - pendingItems.forEach(({ resolve }) => resolve(result)); - - // Report success globally if any of the pending items requested it - const shouldReportSuccess = pendingItems.some((item) => item.reportSuccessGlobally); - if (shouldReportSuccess) { - const message = this.#describeResult(firstArgs, initial, current, result); - if (message && this.#client.reportSuccess) { - this.#client.reportSuccess(message); - } - } - - // Record commit time for throttle mode - channel.lastCommitTime = Date.now(); - - // Refetch - channel.status = "refetching"; - this.#notify(channel, "refetching", result); - // Call refetch and all refetch callbacks in parallel - Promise.allSettled([ - this.#options.refetch?.(), - ...refetchCallbacks.map((cb) => cb()), - ]).then((results) => { - // Report any errors from refetch or callbacks - results.forEach((result) => { - if (result.status === "rejected") { - const message = `Failed to refetch after ${this.describe(...firstArgs)}: ${errMessage(result.reason)}`; - this.#client.reportError(message, result.reason); - } - }); - }).finally(() => { - // Check if new calls came in during the commit - if (channel.pending.length > 0) { - // There are pending calls that need to be committed - channel.status = "waiting"; - this.#notify(channel, "waiting"); - this.#scheduleCommit(key, channel); - } else { - this.#setIdle(key, channel); - } - }); - }) - .catch((error) => { - // Error - call all rollbacks in reverse order - let next; - const rollbacksCopy = [...rollbacks]; - while ((next = rollbacksCopy.pop())) next(); - - // Reject all pending promises - pendingItems.forEach(({ reject }) => reject(error)); - - // Notify listeners of the error - this.#notify(channel, "mutating", null, error); - - // Refetch to restore correct state - channel.status = "refetching"; - this.#notify(channel, "refetching", null, error); - // Call refetch and all refetch callbacks in parallel - Promise.allSettled([ - this.#options.refetch?.(), - ...refetchCallbacks.map((cb) => cb()), - ]).then((results) => { - // Report any errors from refetch or callbacks - results.forEach((result) => { - if (result.status === "rejected") { - const message = `Failed to refetch after ${this.describe(...firstArgs)}: ${errMessage(result.reason)}`; - this.#client.reportError(message, result.reason); - } - }); - }).finally(() => { - // Check if new calls came in during the commit - if (channel.pending.length > 0) { - // There are pending calls that need to be committed - channel.status = "waiting"; - this.#notify(channel, "waiting"); - this.#scheduleCommit(key, channel); - } else { - this.#setIdle(key, channel); - } - }); - }); - } -} diff --git a/src/blocking.ts b/src/blocking.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c06e3e148c1f4a64f0bbe2892999e1f65beaa5a --- /dev/null +++ b/src/blocking.ts @@ -0,0 +1,361 @@ +import type { MutationClient, MutationClientFromConfig } from "./client.ts"; +import type { MutationClientConfig } from "./client.ts"; +import type { Mutation, MutationEvent } from "./types.ts"; +import { message as errMessage } from "@clo/lib/error.ts"; + +/** + * Argument to `defineBlocking`. + * @template Args - the parameters to the mutation + * @template Result - the result of the API call + * @template Config - global values and helpers from `MutationContext` + */ +export interface BlockingMutationOptions< + Args extends unknown[], + Result, + Config extends MutationClientConfig, +> { + /** + * This function is only responsible for performing the underlying API call, + * syncronizing the optimistic state with reality. Throw on failure. A rest + * params type is used to allow type inference. Place this function first to + * ensure TypeScript correctly infers the argument type for the rest of the + * functions. + * + * In practice, optimistic context is never needed in this function, but it + * is provided as the `this` value if you truly desire it. + */ + mutate: (this: Config["context"], ...args: Args) => Promise; + /** + * Used in error messages and debug tools. + * Phrase it considering the template `Failed to ${describe(...)}` + */ + describe: string | ((context: Config["context"] & { args: Args }) => string); + /** + * Used in success messages. + * Phrase it as a complete success message, e.g., "Deleted item successfully" + */ + describeResult: string | ((context: Config["context"] & { args: Args; result: Result }) => string); + /** + * Specifying the optimistic strategy is required. To disable, pass an empty + * function with a comment to document why it isn't needed. + */ + optimistic: (context: BlockingOptimisticContext) => void; + /** + * Refetch all of the data this mutation could have affected. + * Normally, optimistic helpers will perform + * This is called automatically on errors. + */ + refetch?: (context: Config["context"] & { args: Args }) => Promise; + /** + * If the optimistic updator function is perfect, then this may be set to false. + * @default true + */ + refetchOnSuccess?: boolean; + /** + * A key to associate related items. For example, returning a user ID. If + * specifying, then all mutations of the same key will evaluate in serial, + * but optimistic updates will apply instantly. + */ + key?: (context: Config["context"] & { args: Args }) => string | string[]; +} + +export type BlockingOptimisticContext< + Args extends unknown[], + Result, + Config extends MutationClientConfig, +> = Config["context"] & { + args: Args; + helpers: Config["optimisticHelpers"]; + /** Add an event listener to roll back the update */ + onRestore: (cb: () => void) => void; + /** Add an event listener to apply `Result` to the store. */ + onSuccess: (cb: (result: Result) => void) => void; +}; + +interface BlockingChannel { + listeners: Set<(update: MutationEvent) => void>; + status: "idle" | "mutating" | "refetching"; + rollbacks: Array<() => void>; + refetches: Array<() => Promise>; + queue: Array>; + // Shared optimistic helpers instance for the channel + helpers: OptimisticHelpers | null; +} + +interface Item { + args: Args; + rollbacks: number; + onSuccess: Array<(result: Result) => void>; + resolve: (result: Result) => void; + reject: (error: unknown) => void; +} + +export class BlockingMutation< + Args extends unknown[], + Result, + Config extends MutationClientConfig, +> implements Mutation { + #options: BlockingMutationOptions; + #client: MutationClientFromConfig; + #channels: Map> = new Map(); + client: MutationClientFromConfig; + + constructor( + client: MutationClient, + options: BlockingMutationOptions, + ) { + this.#options = options; + this.#client = client; + this.client = client; + } + + key(args: Args) { + const k = this.#options.key?.({ ...this.#client.context, args }) ?? + "shared"; + return JSON.stringify(k); + } + + #getOrPutChannel(key: string) { + let channel = this.#channels.get(key); + if (!channel) { + const rollbacks: Array<() => []> = []; + channel = { + listeners: new Set(), + status: "idle", + rollbacks, + refetches: [], + queue: [], + helpers: null, + }; + this.#channels.set(key, channel); + } + return channel; + } + + subscribe( + key: string, + cb: (update: MutationEvent) => void, + ): () => void { + const channel = this.#getOrPutChannel(key); + channel.listeners.add(cb); + return () => channel?.listeners.delete(cb); + } + + #notify( + channel: BlockingChannel, + status: MutationEvent["status"], + result: Result | null = null, + error: unknown = null, + ) { + const event: MutationEvent = { status, result, error }; + channel.listeners.forEach((cb) => cb(event)); + } + + #setIdle(key: string, channel: BlockingChannel) { + channel.status = "idle"; + // Discard any unconsumed refetch callbacks + channel.refetches = []; + this.#notify(channel, "idle", null, null); + // Clean up the channel if there are no listeners + if (channel.listeners.size === 0) { + this.#channels.delete(key); + } + } + + describe(...args: Args): string { + const { describe } = this.#options; + return typeof describe === "function" + ? describe({ ...this.#client.context, args }) + : describe; + } + + describeResult(args: Args, result: Result): string { + const { describeResult } = this.#options; + return typeof describeResult === "function" + ? describeResult({ ...this.#client.context, args, result }) + : describeResult; + } + + /** Calling the mutation in a global scope. Errors are turned into UI toasts. */ + run(...args: Args) { + if (!this.#client.enabled) { + throw new Error( + "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?", + ); + } + this.runAndReturn(...args).then((result) => { + const message = this.describeResult(args, result); + if (message && this.#client.reportSuccess) { + this.#client.reportSuccess(message); + } + }).catch((error) => { + const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; + this.#client.reportError(message, error); + }); + } + + /** Calls the mutation, treating the errors as promise rejection. */ + runAndReturn(...args: Args): Promise { + if (!this.#client.enabled) { + throw new Error( + "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?", + ); + } + const key = this.key(args); + const channel = this.#getOrPutChannel(key); + + // Create shared optimistic helpers instance for the channel if it doesn't exist + if (channel.helpers === null) { + const onRefetch = (cb: () => Promise) => { + channel.refetches.push(cb); + }; + + channel.helpers = this.#client.getOptimisticHelpers({ + onRestore: (cb: () => void) => { + channel.rollbacks.push(cb); + }, + onRefetch, + }); + } + + const onSuccess: Array<(result: Result) => void> = []; + let expired = false; + let rollbacks = 0; + const onRestore = (cb: () => void) => { + if (expired) { + throw new Error( + "Can only call onRestore from within the optimistic update function.", + ); + } + channel.rollbacks.push(cb); + rollbacks += 1; + }; + + try { + this.#options.optimistic({ + args, + helpers: channel.helpers, + onRestore, + onSuccess(cb) { + if (expired) { + throw new Error( + "Can only call onSuccess from within the optimistic update function.", + ); + } + onSuccess.push(cb); + }, + }); + } catch (error) { + expired = true; + let next; + while ( + next = + channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0] + ) { + next(); + } + return Promise.reject(error); + } + expired = true; + + const { promise, resolve, reject } = Promise.withResolvers(); + channel.queue.push({ + args, + rollbacks, + onSuccess, + resolve, + reject, + }); + + if (channel.status === "idle") { + this.#executeNext(key, channel); + } + + return promise; + } + + #executeNext(key: string, channel: BlockingChannel) { + const item = channel.queue.shift(); + if (!item) { + this.#setIdle(key, channel); + return; + } + + const { args, onSuccess, resolve, reject } = item; + channel.status = "mutating"; + this.#notify(channel, "mutating"); + + this.#options.mutate.call(this.#client.context, ...args).then((result) => { + // remove rollbacks and apply optimistic success handlers + channel.rollbacks.splice(0, item.rollbacks); + onSuccess.forEach((cb) => cb(result)); + + if (this.#options.refetchOnSuccess !== false) { + channel.status = "refetching"; + this.#notify(channel, "refetching", result); + // Call refetch and all refetch callbacks in parallel + const refetchCallbacks = channel.refetches.splice(0); + Promise.allSettled([ + this.#options.refetch?.({ + ...this.#client.context, + args, + }), + ...refetchCallbacks.map((cb) => cb()), + ]).then((results) => { + // Report any errors from refetch or callbacks + results.forEach((result) => { + if (result.status === "rejected") { + const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`; + this.#client.reportError(message, result.reason); + } + }); + }).finally(() => { + this.#executeNext(key, channel); + }); + } else { + // Discard refetch callbacks if refetchOnSuccess is false + channel.refetches = []; + this.#executeNext(key, channel); + } + resolve(result); + }, (error) => { + // if an error happens, then every rollback is called in reverse order + let next; + while (next = channel.rollbacks.pop()) next(); + + // Cancel all remaining items in the channel + const remainingItems = channel.queue.splice(0); + remainingItems.forEach((queuedItem) => { + queuedItem.reject(error); + }); + + // Notify listeners of the error + this.#notify(channel, "mutating", null, error); + + // Refetch to restore correct state + channel.status = "refetching"; + this.#notify(channel, "refetching", null, error); + // Call refetch and all refetch callbacks in parallel + const refetchCallbacks = channel.refetches.splice(0); + Promise.allSettled([ + this.#options.refetch?.({ + ...this.#client.context, + args, + }), + ...refetchCallbacks.map((cb) => cb()), + ]).then((results) => { + // Report any errors from refetch or callbacks + results.forEach((result) => { + if (result.status === "rejected") { + const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`; + this.#client.reportError(message, result.reason); + } + }); + }).finally(() => { + this.#setIdle(key, channel); + }); + + reject(error); + }); + } +} diff --git a/src/client.ts b/src/client.ts index edda210b0d76bb76be7a0daf48e76fafdc7fbedf..1d7a8394d7e5bc911aa6a7579a40021b1a760c75 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,5 @@ -import { BatchMutation, type BatchMutationOptions } from "./batch.ts"; -import { type MutationOptions, QueuedMutation } from "./queued.ts"; +import { DebouncedMutation, type DebouncedMutationOptions } from "./debounced.ts"; +import { type BlockingMutationOptions, BlockingMutation } from "./blocking.ts"; import type { Mutation } from "./types.ts"; export interface MutationClientConfig { @@ -24,11 +24,17 @@ export interface MutationClientOptions< reportError: (message: string, error: unknown) => void; reportSuccess?: (message: string) => void; /** - * Compare two values for deep equality. Used by BatchMutation to determine + * Compare two values for deep equality. Used by DebouncedMutation to determine * if the optimistic state has changed from the initial snapshot. * @default JSON.stringify based comparison */ deepEquals?: (a: unknown, b: unknown) => boolean; + /** + * When false, all mutation run functions will throw an error. + * Useful for preventing mutations during SSR. + * @default true + */ + enabled?: boolean; } export interface OptimisticEvents { @@ -45,6 +51,7 @@ export class MutationClient< reportError: (message: string, error: unknown) => void; reportSuccess?: (message: string) => void; deepEquals: (a: unknown, b: unknown) => boolean; + enabled: boolean; constructor(options: MutationClientOptions) { this.context = options.context; @@ -52,21 +59,22 @@ export class MutationClient< this.reportError = options.reportError; this.reportSuccess = options.reportSuccess; this.deepEquals = options.deepEquals ?? defaultDeepEquals; + this.enabled = options.enabled ?? true; } /** - * Define a queued mutation. A mutation blocks the UI until it is complete. + * Define a blocking mutation. A mutation blocks the UI until it is complete. * You press a button, a pending state appears, then it completes. This works * great for forms, and is similar to React Query's mutation system. */ defineBlocking( - options: MutationOptions< + options: BlockingMutationOptions< Args, Result, { context: Context; optimisticHelpers: OptimisticHelpers } >, ): Mutation { - return new QueuedMutation< + return new BlockingMutation< Args, Result, { context: Context; optimisticHelpers: OptimisticHelpers } @@ -74,21 +82,21 @@ export class MutationClient< } /** - * Define a batched mutation. Each call to the mutation applies new optimistic + * Define a debounced mutation. Each call to the mutation applies new optimistic * state, and after a debounce or throttle, the new optimistic state is - * committed to the API. UI never shows a pending state for batches. This + * committed to the API. UI never shows a pending state for debounced mutations. This * works great for auto-saving input fields, follow buttons, and is preferred * whenever possible. */ defineDebounced( - options: BatchMutationOptions< + options: DebouncedMutationOptions< Args, Result, Optimistic, { context: Context; optimisticHelpers: OptimisticHelpers } >, ): Mutation { - return new BatchMutation< + return new DebouncedMutation< Args, Result, Optimistic, diff --git a/src/debounced.ts b/src/debounced.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb8869a3826b8ccfc080e1d7a01c7ae20ed92c33 --- /dev/null +++ b/src/debounced.ts @@ -0,0 +1,562 @@ +import type { MutationClient, MutationClientFromConfig } from "./client.ts"; +import type { MutationClientConfig } from "./client.ts"; +import type { Mutation, MutationEvent } from "./types.ts"; +import { message as errMessage } from "@clo/lib/error.ts"; + +export interface DebouncedMutationOptions< + Args extends unknown[], + Result, + Optimistic, + Config extends MutationClientConfig, +> { + /** + * Update the UI for one call to the optimistic function. + * A rest params type is used to allow type inference. Place this function first to + * ensure TypeScript correctly infers the argument type for the rest of the functions. + */ + optimistic: (context: DebouncedOptimisticContext, ...args: Args) => void; + /** + * Retrieve the current/optimistic value of the mutation. When this returns + * the same thing as when the mutation started, it means that `mutate` does + * not need to be called since the data is the same. + * + * Don't snapshot unrelated state that this mutation isn't concerned with. + */ + getValue: (context: Config["context"] & { args: Args }) => Optimistic; + + /** + * @default "debounce" + */ + mode?: "debounce" | "throttle"; + /** + * Milliseconds + * @default 200 + */ + time?: number; + + /** A key to associate debounced items. For example, returning a user ID */ + key: ( + context: Config["context"] & { args: NoInfer }, + ) => string | string[]; + + /** + * Commit the optimistic state. Throw on failure. + */ + commit: ( + context: DebouncedCommitContext, Optimistic, Config>, + ) => Promise; + /** + * Used in error messages and debug tools. + * "Failed to {action}" + */ + describe: + | string + | (( + context: DebouncedCommitContext, Optimistic, Config>, + ) => string); + /** + * Used in success messages. + * Phrase it as a complete success message, e.g., "Renamed item successfully" + */ + describeResult: + | string + | (( + context: DebouncedCommitContext, Optimistic, Config> & { + result: Result; + }, + ) => string); + /** + * Refetch all of the data this mutation could have affected. + */ + refetch?: () => Promise; +} + +export type DebouncedOptimisticContext = + & Config["context"] + & { + /** Add an event listener to roll back the update */ + onRestore: (cb: () => void) => void; + helpers: Config["optimisticHelpers"]; + }; + +export type DebouncedCommitContext< + Args, + Optimistic, + Config extends MutationClientConfig, +> = Config["context"] & { + /** One of the arguments. Use this only to extract the shared key */ + args: Args; + /** The initial snapshot */ + initial: Optimistic; + /** The compared snapshot */ + current: Optimistic; +}; + +interface DebouncedChannel< + Args extends unknown[], + Result, + Optimistic, + OptimisticHelpers, +> { + listeners: Set<(update: MutationEvent) => void>; + status: "idle" | "waiting" | "mutating" | "refetching"; + + // Snapshot before first call in current debounced run + initial: Optimistic | null; + // First args in debounced run (used for commit/describe/getValue) + firstArgs: Args | null; + rollbacks: Array<() => void>; + refetches: Array<() => Promise>; + timer: ReturnType | null; + + // Track last commit time for throttle mode + lastCommitTime: number; + + // Shared optimistic helpers instance for the current debounced run + helpers: OptimisticHelpers | null; + + // Pending promises from callers in current debounced run + pending: Array<{ + args: Args; + resolve: (result: Result) => void; + reject: (error: unknown) => void; + reportSuccessGlobally?: boolean; + }>; +} + +export class DebouncedMutation< + Args extends unknown[], + Result, + Optimistic, + Config extends MutationClientConfig, +> implements Mutation { + #options: DebouncedMutationOptions; + #client: MutationClientFromConfig; + #channels: Map< + string, + DebouncedChannel + > = new Map(); + client: MutationClientFromConfig; + + constructor( + client: MutationClient, + options: DebouncedMutationOptions, + ) { + this.#options = options; + this.#client = client; + this.client = client; + } + + key(args: Args): string { + const k = this.#options.key({ ...this.#client.context, args }); + return JSON.stringify(k); + } + + #getOrPutChannel( + key: string, + ): DebouncedChannel { + let channel = this.#channels.get(key); + if (!channel) { + channel = { + listeners: new Set(), + status: "idle", + initial: null, + firstArgs: null, + rollbacks: [], + refetches: [], + timer: null, + lastCommitTime: 0, + helpers: null, + pending: [], + }; + this.#channels.set(key, channel); + } + return channel; + } + + subscribe( + key: string, + cb: (update: MutationEvent) => void, + ): () => void { + const channel = this.#getOrPutChannel(key); + channel.listeners.add(cb); + return () => channel?.listeners.delete(cb); + } + + #notify( + channel: DebouncedChannel< + Args, + Result, + Optimistic, + Config["optimisticHelpers"] + >, + status: MutationEvent["status"], + result: Result | null = null, + error: unknown = null, + ) { + const event: MutationEvent = { status, result, error }; + channel.listeners.forEach((cb) => cb(event)); + } + + #setIdle( + key: string, + channel: DebouncedChannel< + Args, + Result, + Optimistic, + Config["optimisticHelpers"] + >, + ) { + channel.status = "idle"; + this.#notify(channel, "idle", null, null); + // Clean up the channel if there are no listeners + if (channel.listeners.size === 0) { + this.#channels.delete(key); + } + } + + #resetDebouncedState( + channel: DebouncedChannel< + Args, + Result, + Optimistic, + Config["optimisticHelpers"] + >, + ) { + channel.initial = null; + channel.firstArgs = null; + channel.rollbacks = []; + channel.refetches = []; + channel.helpers = null; + channel.pending = []; + if (channel.timer !== null) { + clearTimeout(channel.timer); + channel.timer = null; + } + } + + describe(...args: Args): string { + const { describe } = this.#options; + if (typeof describe === "function") { + // For describe, we need initial/current but may not have them yet + // Use placeholder values when called outside of commit context + return describe({ + ...this.#client.context, + args, + initial: null as unknown as Optimistic, + current: null as unknown as Optimistic, + }); + } + return describe; + } + + // Not available for debounced mutations - success reporting happens during commit + describeResult: undefined = undefined; + + #describeResult( + args: Args, + initial: Optimistic, + current: Optimistic, + result: Result, + ): string { + const { describeResult } = this.#options; + return typeof describeResult === "function" + ? describeResult({ + ...this.#client.context, + args, + initial, + current, + result, + }) + : describeResult; + } + + /** Calling the mutation in a global scope. Errors and successes are turned into UI toasts. */ + run(...args: Args): void { + if (!this.#client.enabled) { + throw new Error( + "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?", + ); + } + this.#runAndReturn(args, true).catch((error) => { + const message = `Failed to ${this.describe(...args)}: ${ + errMessage(error) + }`; + this.#client.reportError(message, error); + }); + } + + /** Calls the mutation, treating the errors as promise rejection. */ + runAndReturn(...args: Args): Promise { + if (!this.#client.enabled) { + throw new Error( + "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?", + ); + } + return this.#runAndReturn(args, false); + } + + #runAndReturn(args: Args, reportSuccessGlobally: boolean): Promise { + const key = this.key(args); + const channel = this.#getOrPutChannel(key); + + // If this is the first call in the debounced run, take a snapshot and create shared helpers + if (channel.initial === null) { + channel.initial = this.#options.getValue({ ...this.#client.context, args }); + channel.firstArgs = args; + + // Create shared onRefetch handler for the debounced run + const onRefetch = (cb: () => Promise) => { + channel.refetches.push(cb); + }; + + // Create shared optimistic helpers instance for this debounced run + channel.helpers = this.#client.getOptimisticHelpers({ + onRestore: (cb: () => void) => { + channel.rollbacks.push(cb); + }, + onRefetch, + }); + } + + // Apply optimistic update + let expired = false; + const onRestore = (cb: () => void) => { + if (expired) { + throw new Error( + "Can only call onRestore from within the optimistic update function.", + ); + } + channel.rollbacks.push(cb); + }; + + try { + this.#options.optimistic( + { + ...this.#client.context, + onRestore, + helpers: channel.helpers!, + }, + ...args, + ); + } catch (error) { + expired = true; + // We don't know how many were added, so we can't do partial rollback easily + // For simplicity, rollback everything and reject + let next; + while ((next = channel.rollbacks.pop())) next(); + this.#resetDebouncedState(channel); + return Promise.reject(error); + } + expired = true; + + // Create promise for this caller + const { promise, resolve, reject } = Promise.withResolvers(); + channel.pending.push({ args, resolve, reject, reportSuccessGlobally }); + + // Set status to waiting and notify + if (channel.status === "idle") { + channel.status = "waiting"; + this.#notify(channel, "waiting"); + } + + // Schedule commit based on mode + this.#scheduleCommit(key, channel); + + return promise; + } + + #scheduleCommit( + key: string, + channel: DebouncedChannel< + Args, + Result, + Optimistic, + Config["optimisticHelpers"] + >, + ) { + const time = this.#options.time ?? 200; + + if (this.#options.mode !== "throttle") { + // Debounce: reset timer on each call + if (channel.timer !== null) { + clearTimeout(channel.timer); + } + channel.timer = setTimeout(() => this.#commit(key, channel), time); + } else { + // Throttle: commit immediately if enough time passed, otherwise wait + // Use status to track if a commit is in progress + if (channel.timer === null && channel.status === "waiting") { + const elapsed = Date.now() - channel.lastCommitTime; + if (elapsed >= time) { + // Enough time has passed, commit immediately + this.#commit(key, channel); + } else { + // Wait for remaining time + channel.timer = setTimeout( + () => this.#commit(key, channel), + time - elapsed, + ); + } + } + // If timer exists or commit is in progress, do nothing - will commit when ready + } + } + + #commit( + key: string, + channel: DebouncedChannel< + Args, + Result, + Optimistic, + Config["optimisticHelpers"] + >, + ) { + // Clear timer + if (channel.timer !== null) { + clearTimeout(channel.timer); + channel.timer = null; + } + + // Safety check + if (channel.firstArgs === null || channel.initial === null) { + this.#setIdle(key, channel); + return; + } + + const firstArgs = channel.firstArgs; + const initial = channel.initial; + const pendingItems = [...channel.pending]; + const rollbacks = [...channel.rollbacks]; + const refetchCallbacks = [...channel.refetches]; + + // Get current snapshot + const current = this.#options.getValue({ + ...this.#client.context, + args: firstArgs, + }); + + // Check if anything changed + if (this.#client.deepEquals(initial, current)) { + // No change - resolve all pending with a null result and reset + pendingItems.forEach(({ resolve }) => resolve(null as Result)); + this.#resetDebouncedState(channel); + this.#setIdle(key, channel); + return; + } + + // Set status to mutating + channel.status = "mutating"; + this.#notify(channel, "mutating"); + + // Clear debounced state before async operation (but keep rollbacks/refetches for error case) + channel.initial = null; + channel.firstArgs = null; + channel.pending = []; + channel.rollbacks = []; + channel.refetches = []; + + // Call commit + this.#options + .commit({ + ...this.#client.context, + args: firstArgs, + initial, + current, + }) + .then((result) => { + // Success - rollbacks are discarded (optimistic was correct) + // Resolve all pending promises + pendingItems.forEach(({ resolve }) => resolve(result)); + + // Report success globally if any of the pending items requested it + const shouldReportSuccess = pendingItems.some((item) => + item.reportSuccessGlobally + ); + if (shouldReportSuccess) { + const message = this.#describeResult( + firstArgs, + initial, + current, + result, + ); + if (message && this.#client.reportSuccess) { + this.#client.reportSuccess(message); + } + } + + // Record commit time for throttle mode + channel.lastCommitTime = Date.now(); + + // Refetch + channel.status = "refetching"; + this.#notify(channel, "refetching", result); + // Call refetch and all refetch callbacks in parallel + Promise.allSettled([ + this.#options.refetch?.(), + ...refetchCallbacks.map((cb) => cb()), + ]).then((results) => { + // Report any errors from refetch or callbacks + results.forEach((result) => { + if (result.status === "rejected") { + const message = `Failed to refetch after ${ + this.describe(...firstArgs) + }: ${errMessage(result.reason)}`; + this.#client.reportError(message, result.reason); + } + }); + }).finally(() => { + // Check if new calls came in during the commit + if (channel.pending.length > 0) { + // There are pending calls that need to be committed + channel.status = "waiting"; + this.#notify(channel, "waiting"); + this.#scheduleCommit(key, channel); + } else { + this.#setIdle(key, channel); + } + }); + }) + .catch((error) => { + // Error - call all rollbacks in reverse order + let next; + const rollbacksCopy = [...rollbacks]; + while ((next = rollbacksCopy.pop())) next(); + + // Reject all pending promises + pendingItems.forEach(({ reject }) => reject(error)); + + // Notify listeners of the error + this.#notify(channel, "mutating", null, error); + + // Refetch to restore correct state + channel.status = "refetching"; + this.#notify(channel, "refetching", null, error); + // Call refetch and all refetch callbacks in parallel + Promise.allSettled([ + this.#options.refetch?.(), + ...refetchCallbacks.map((cb) => cb()), + ]).then((results) => { + // Report any errors from refetch or callbacks + results.forEach((result) => { + if (result.status === "rejected") { + const message = `Failed to refetch after ${ + this.describe(...firstArgs) + }: ${errMessage(result.reason)}`; + this.#client.reportError(message, result.reason); + } + }); + }).finally(() => { + // Check if new calls came in during the commit + if (channel.pending.length > 0) { + // There are pending calls that need to be committed + channel.status = "waiting"; + this.#notify(channel, "waiting"); + this.#scheduleCommit(key, channel); + } else { + this.#setIdle(key, channel); + } + }); + }); + } +} diff --git a/src/mod.ts b/src/mod.ts index 37cd2172f93dfefafdc97234dc9726ddd3f87201..df9eccb473ce97cc4fb3e9d044bf729185bfb76d 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -1,9 +1,12 @@ -export type { MutationOptions, OptimisticContext } from "./queued.ts"; export type { - BatchCommitContext, - BatchMutationOptions, - BatchOptimisticContext, -} from "./batch.ts"; + BlockingMutationOptions, + BlockingOptimisticContext, +} from "./blocking.ts"; +export type { + DebouncedCommitContext, + DebouncedMutationOptions, + DebouncedOptimisticContext, +} from "./debounced.ts"; export { MutationClient, type MutationClientConfig, @@ -13,6 +16,7 @@ export { export type { Mutation, MutationEvent } from "./types.ts"; export { createMutationButton, + type MutationButtonComponent, type MutationButtonProps, useMutate, type UseMutateError, @@ -20,4 +24,4 @@ export { type UseMutateResult, type UseMutateResultBase, type UseMutateSuccess, -} from "./react.tsx"; +} from "./react.ts"; diff --git a/src/queued.ts b/src/queued.ts deleted file mode 100644 index 992c3046121cd25533b87238041948f16526cf20..0000000000000000000000000000000000000000 --- a/src/queued.ts +++ /dev/null @@ -1,347 +0,0 @@ -import type { MutationClient, MutationClientFromConfig } from "./client.ts"; -import type { MutationClientConfig } from "./client.ts"; -import type { Mutation, MutationEvent } from "./types.ts"; -import { message as errMessage } from "@clo/lib/error.ts"; - -/** - * Argument to `defineMutation`. - * @template Args - the parameters to the mutation - * @template Result - the result of the API call - * @template Config - global values and helpers from `MutationContext` - */ -export interface MutationOptions< - Args extends unknown[], - Result, - Config extends MutationClientConfig, -> { - /** - * This function is only responsible for performing the underlying API call, - * syncronizing the optimistic state with reality. Throw on failure. A rest - * params type is used to allow type inference. Place this function first to - * ensure TypeScript correctly infers the argument type for the rest of the - * functions. - * - * In practice, optimistic context is never needed in this function, but it - * is provided as the `this` value if you truly desire it. - */ - mutate: (this: Config["context"], ...args: Args) => Promise; - /** - * Used in error messages and debug tools. - * Phrase it considering the template `Failed to ${describe(...)}` - */ - describe: string | ((context: Config["context"] & { args: Args }) => string); - /** - * Used in success messages. - * Phrase it as a complete success message, e.g., "Deleted item successfully" - * Set to null to suppress success reporting. - */ - describeResult?: string | ((context: Config["context"] & { args: Args; result: Result }) => string) | null; - /** - * Specifying the optimistic strategy is required. To disable, pass an empty - * function with a comment to document why it isn't needed. - */ - optimistic: (context: OptimisticContext) => void; - /** - * Refetch all of the data this mutation could have affected. - * Normally, optimistic helpers will perform - * This is called automatically on errors. - */ - refetch?: (context: Config["context"] & { args: Args }) => Promise; - /** - * If the optimistic updator function is perfect, then this may be set to false. - * @default true - */ - refetchOnSuccess?: boolean; - /** - * A key to associate related items. For example, returning a user ID. If - * specifying, then all mutations of the same key will evaluate in serial, - * but optimistic updates will apply instantly. - */ - key?: (context: Config["context"] & { args: Args }) => string | string[]; -} - -export type OptimisticContext< - Args extends unknown[], - Result, - Config extends MutationClientConfig, -> = Config["context"] & { - args: Args; - helpers: Config["optimisticHelpers"]; - /** Add an event listener to roll back the update */ - onRestore: (cb: () => void) => void; - /** Add an event listener to apply `Result` to the store. */ - onSuccess: (cb: (result: Result) => void) => void; -}; - -interface Channel { - listeners: Set<(update: MutationEvent) => void>; - status: "idle" | "mutating" | "refetching"; - rollbacks: Array<() => void>; - refetches: Array<() => Promise>; - queue: Array>; -} - -interface Item { - args: Args; - rollbacks: number; - onSuccess: Array<(result: Result) => void>; - resolve: (result: Result) => void; - reject: (error: unknown) => void; -} - -export class QueuedMutation< - Args extends unknown[], - Result, - Config extends MutationClientConfig, -> implements Mutation { - #options: MutationOptions; - #client: MutationClientFromConfig; - #queues: Map> = new Map(); - client: MutationClientFromConfig; - - constructor( - client: MutationClient, - options: MutationOptions, - ) { - this.#options = options; - this.#client = client; - this.client = client; - } - - key(args: Args) { - const k = this.#options.key?.({ ...this.#client.context, args }) ?? - "shared"; - return JSON.stringify(k); - } - - #getOrPutChannel(key: string) { - let channel = this.#queues.get(key); - if (!channel) { - const rollbacks: Array<() => []> = []; - channel = { - listeners: new Set(), - status: "idle", - rollbacks, - refetches: [], - queue: [], - }; - this.#queues.set(key, channel); - } - return channel; - } - - subscribe( - key: string, - cb: (update: MutationEvent) => void, - ): () => void { - const channel = this.#getOrPutChannel(key); - channel.listeners.add(cb); - return () => channel?.listeners.delete(cb); - } - - #notify( - channel: Channel, - status: MutationEvent["status"], - result: Result | null = null, - error: unknown = null, - ) { - const event: MutationEvent = { status, result, error }; - channel.listeners.forEach((cb) => cb(event)); - } - - #setIdle(key: string, channel: Channel) { - channel.status = "idle"; - // Discard any unconsumed refetch callbacks - channel.refetches = []; - this.#notify(channel, "idle", null, null); - // Clean up the channel if there are no listeners - if (channel.listeners.size === 0) { - this.#queues.delete(key); - } - } - - describe(...args: Args): string { - const { describe } = this.#options; - return typeof describe === "function" - ? describe({ ...this.#client.context, args }) - : describe; - } - - describeResult(args: Args, result: Result): string | undefined { - const { describeResult } = this.#options; - if (describeResult === null || describeResult === undefined) return undefined; - return typeof describeResult === "function" - ? describeResult({ ...this.#client.context, args, result }) - : describeResult; - } - - /** Calling the mutation in a global scope. Errors are turned into UI toasts. */ - run(...args: Args) { - this.runAndReturn(...args).then((result) => { - const message = this.describeResult(args, result); - if (message && this.#client.reportSuccess) { - this.#client.reportSuccess(message); - } - }).catch((error) => { - const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; - this.#client.reportError(message, error); - }); - } - - /** Calls the mutation, treating the errors as promise rejection. */ - runAndReturn(...args: Args): Promise { - const key = this.key(args); - const channel = this.#getOrPutChannel(key); - - const onSuccess: Array<(result: Result) => void> = []; - let expired = false; - let rollbacks = 0; - const onRestore = (cb: () => void) => { - if (expired) { - throw new Error( - "Can only call onRestore from within the optimistic update function.", - ); - } - channel.rollbacks.push(cb); - rollbacks += 1; - }; - const onRefetch = (cb: () => Promise) => { - if (expired) { - throw new Error( - "Can only call onRefetch from within the optimistic update function.", - ); - } - channel.refetches.push(cb); - }; - - try { - this.#options.optimistic({ - args, - helpers: this.#client.getOptimisticHelpers({ - onRestore, - onRefetch, - }), - onRestore, - onSuccess(cb) { - if (expired) { - throw new Error( - "Can only call onSuccess from within the optimistic update function.", - ); - } - onSuccess.push(cb); - }, - }); - } catch (error) { - expired = true; - let next; - while ( - next = - channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0] - ) { - next(); - } - return Promise.reject(error); - } - expired = true; - - const { promise, resolve, reject } = Promise.withResolvers(); - channel.queue.push({ - args, - rollbacks, - onSuccess, - resolve, - reject, - }); - - if (channel.status === "idle") { - this.#executeNext(key, channel); - } - - return promise; - } - - #executeNext(key: string, channel: Channel) { - const item = channel.queue.shift(); - if (!item) { - this.#setIdle(key, channel); - return; - } - - const { args, onSuccess, resolve, reject } = item; - channel.status = "mutating"; - this.#notify(channel, "mutating"); - - this.#options.mutate.call(this.#client.context, ...args).then((result) => { - // remove rollbacks and apply optimistic success handlers - channel.rollbacks.splice(0, item.rollbacks); - onSuccess.forEach((cb) => cb(result)); - - if (this.#options.refetchOnSuccess !== false) { - channel.status = "refetching"; - this.#notify(channel, "refetching", result); - // Call refetch and all refetch callbacks in parallel - const refetchCallbacks = channel.refetches.splice(0); - Promise.allSettled([ - this.#options.refetch?.({ - ...this.#client.context, - args, - }), - ...refetchCallbacks.map((cb) => cb()), - ]).then((results) => { - // Report any errors from refetch or callbacks - results.forEach((result) => { - if (result.status === "rejected") { - const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`; - this.#client.reportError(message, result.reason); - } - }); - }).finally(() => { - this.#executeNext(key, channel); - }); - } else { - // Discard refetch callbacks if refetchOnSuccess is false - channel.refetches = []; - this.#executeNext(key, channel); - } - resolve(result); - }, (error) => { - // if an error happens, then every rollback is called in reverse order - let next; - while (next = channel.rollbacks.pop()) next(); - - // Cancel all remaining items in the queue - const remainingItems = channel.queue.splice(0); - remainingItems.forEach((queuedItem) => { - queuedItem.reject(error); - }); - - // Notify listeners of the error - this.#notify(channel, "mutating", null, error); - - // Refetch to restore correct state - channel.status = "refetching"; - this.#notify(channel, "refetching", null, error); - // Call refetch and all refetch callbacks in parallel - const refetchCallbacks = channel.refetches.splice(0); - Promise.allSettled([ - this.#options.refetch?.({ - ...this.#client.context, - args, - }), - ...refetchCallbacks.map((cb) => cb()), - ]).then((results) => { - // Report any errors from refetch or callbacks - results.forEach((result) => { - if (result.status === "rejected") { - const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`; - this.#client.reportError(message, result.reason); - } - }); - }).finally(() => { - this.#setIdle(key, channel); - }); - - reject(error); - }); - } -} diff --git a/src/react.ts b/src/react.ts new file mode 100644 index 0000000000000000000000000000000000000000..528511caae7f100e7d60cdf60b3ddd2ca2c23796 --- /dev/null +++ b/src/react.ts @@ -0,0 +1,423 @@ +import { + type FC, + type MouseEvent, + type MouseEventHandler, + type ReactNode, + useCallback, + useEffect, + useState, +} from "react"; +import { message as errMessage } from "@clo/lib/error.ts"; +import type { Mutation } from "./types.ts"; +import { jsx } from "react/jsx-runtime"; + +/** + * Subscribe to a mutation's status, as well as accessing a local `run` method. + * The resulting mutation can be used directly or passed to a {@link createMutationButton|mutation button}. + */ +export function useMutate< + Args extends unknown[], + Result, +>( + mutation: Mutation | null, +): UseMutateResult { + const [_, setRerender] = useState(0); + const [observer] = useState(() => new Observer(setRerender)); + useEffect(() => () => void observer.reset(), []); + if (mutation !== observer.mutation) { + observer.mutation = mutation; + observer.reset(); + } + return observer.binding; +} + +export type UseMutateResult = + & UseMutateResultBase + & ( + | UseMutateSuccess + | UseMutateError + | UseMutateIdle + ); + +export interface UseMutateResultBase { + run: (...args: Args) => void; + runWithResult: (...args: Args) => Promise; + clear: () => void; +} + +export interface UseMutateSuccess { + status: "success"; + result: Result; + error: undefined; + errorMessage: undefined; + /** `true` when a `mutate` function is currently running. */ + isMutating: false; + /** `true` when a loading indicator should be shown. */ + isPending: false; + /** `true` when a mutation has completed and has a result. */ + isSuccess: true; + /** `true` when a mutation has failed. */ + isError: false; + /** `true` when there is optimistic state applied. */ + isOptimisticData: boolean; +} +export interface UseMutateError { + status: "error"; + result: undefined; + error: unknown; + /** User-friendly in this format: `Failed to {action}: {details}` */ + errorMessage: string; + /** `true` when a `mutate` function is currently running. */ + isMutating: false; + /** `true` when a loading indicator should be shown. */ + isPending: false; + /** `true` when a mutation has completed and has a result. */ + isSuccess: false; + /** `true` when a mutation has failed. */ + isError: true; + /** `true` when there is optimistic state applied. */ + isOptimisticData: boolean; +} +export interface UseMutateIdle { + status: "idle" | "mutating"; + result: undefined; + error: undefined; + errorMessage: undefined; + /** `true` when a `mutate` function is currently running. */ + isMutating: boolean; + /** `true` when a loading indicator should be shown. */ + isPending: boolean; + /** `true` when a mutation has completed and has a result. */ + isSuccess: false; + /** `true` when a mutation has failed. */ + isError: false; + /** `true` when there is optimistic state applied. */ + isOptimisticData: boolean; +} + +type AnyMutationStateWithoutRun = + & Omit< + UseMutateIdle, + "status" | "result" | "error" | "isSuccess" | "isError" | "errorMessage" + > + & { + status: "idle" | "mutating" | "error" | "success"; + result: undefined | Result; + error: undefined | unknown; + errorMessage: undefined | string; + isSuccess: boolean; + isError: boolean; + }; + +export type AnyMutationState = + & AnyMutationStateWithoutRun + & UseMutateResultBase; + +function initialState() { + return { + status: "idle", + result: undefined, + error: undefined, + errorMessage: undefined, + isMutating: false, + isPending: false, + isSuccess: false, + isError: false, + isOptimisticData: false, + } as const; +} + +class Observer { + setRerender: (fn: number) => void; + mutation: Mutation | null = null; + unsubscribe: (() => void) | null = null; + currentKey: string | null = null; + currentArgs: Args | null = null; + + constructor(setRerender: (fn: number) => void) { + this.setRerender = setRerender; + } + + watched: Set = new Set(); + state: AnyMutationStateWithoutRun = initialState(); + setState(newState: Partial>) { + let updateUi = false; + const current: Record = this.state; + for (const [key, value] of Object.entries(newState)) { + if (value !== current[key]) { + current[key] = value; + updateUi ||= this.watched.has(key); + } + } + if (updateUi) { + this.setRerender(Math.random()); + } + } + + reset() { + this.unsubscribe?.(); + this.unsubscribe = null; + this.currentKey = null; + this.state = initialState(); + } + + computeErrorMessage(error: unknown): string | undefined { + if (!error) return undefined; + const mutation = this.mutation; + if (!mutation || !this.currentArgs) return errMessage(error); + return `Failed to ${mutation.describe(...this.currentArgs)}: ${ + errMessage(error) + }`; + } + + run(...args: Args) { + const mutation = this.mutation; + if (!mutation) return; + this.currentArgs = args; + const key = mutation.key(args); + if (key !== this.currentKey) { + this.currentKey = key; + this.unsubscribe?.(); + this.unsubscribe = mutation.subscribe( + mutation.key(args), + ({ status, error, result }) => { + if (status === "idle") { + this.setState({ + isMutating: false, + isPending: false, + isOptimisticData: false, + }); + return; + } + const hasError = error != null; + const hasResult = result != null; + + this.setState({ + status: hasError + ? "error" + : hasResult + ? "success" + : status === "mutating" + ? "mutating" + : "idle", + error: error ?? undefined, + errorMessage: this.computeErrorMessage(error ?? undefined), + result: result ?? undefined, + isMutating: status === "mutating", + isPending: status === "mutating" || status === "refetching", + isSuccess: hasResult && !hasError, + isError: hasError, + isOptimisticData: status === "waiting" || status === "mutating" || + status === "refetching", + }); + }, + ); + } + // Use global error/success handling if this usage of the hook doesn't check for + // errors or success. This makes it act pretty awesome in terms of defaults. + // You don't have to worry about result UI, they'll surface exactly once. + const watchesError = this.watched.has("isError") || + this.watched.has("error") || this.watched.has("errorMessage"); + const watchesSuccess = this.watched.has("isSuccess") || + this.watched.has("result"); + const promise = mutation.runAndReturn(...args) + .then((result) => { + if (!watchesSuccess && mutation.describeResult) { + const message = mutation.describeResult(args, result); + if (message && mutation.client.reportSuccess) { + mutation.client.reportSuccess(message); + } + } + }); + promise.catch((err) => { + if (!watchesError) { + const message = `Failed to ${mutation.describe(...args)}: ${ + errMessage(err) + }`; + mutation.client.reportError(message, err); + } + }); + return promise; + } + + binding: UseMutateResult = ((self: this) => ({ + run(...args) { + return self.run(...args); + }, + runWithResult(...args) { + return self.run(...args); + }, + clear() { + self.setState({ + status: ["error", "success"].includes(self.state.status) + ? "idle" + : self.state.status, + isError: false, + isSuccess: false, + error: undefined, + errorMessage: undefined, + result: undefined, + }); + }, + get status() { + self.watched.add("status"); + return self.state.status; + }, + get result() { + self.watched.add("result"); + return self.state.result; + }, + get error() { + self.watched.add("error"); + return self.state.error; + }, + get errorMessage() { + self.watched.add("errorMessage"); + return self.state.errorMessage; + }, + get isMutating() { + self.watched.add("isMutating"); + return self.state.isMutating; + }, + get isPending() { + self.watched.add("isPending"); + return self.state.isPending; + }, + get isSuccess() { + self.watched.add("isSuccess"); + return self.state.isSuccess; + }, + get isError() { + self.watched.add("isError"); + return self.state.isError; + }, + get isOptimisticData() { + self.watched.add("isOptimisticData"); + return self.state.isOptimisticData; + }, + } as UseMutateResult))(this); +} + +interface BaseButtonProps { + onClick: MouseEventHandler | undefined; + isPending: boolean; +} + +export interface MutationButtonComponent { + ( + props: + & MutationButtonProps + & Props, + ): ReactNode; + displayName?: string; +} + +export interface MutationButtonProps { + mutation: + | Mutation + | UseMutateResult; + /** Preventing default will interrupt the mutation */ + args: Args | ((e: MouseEvent) => Args | null); + /** Preventing default will interrupt the mutation */ + onClick?: (e: MouseEvent) => void; + + /** Omitting this will use the global error handler */ + onError?: (result: unknown) => void; + /** Omitting this will use the global success handler */ + onSuccess?: (result: Result) => void; + + /** Global event handlers will still be called! */ + onSettled?: ( + event: { + status: "success"; + result: Result; + } | { + status: "error"; + error: unknown; + }, + ) => void; +} + +/** + * Wraps a custom button component with logic to execute a mutation. The wrapped + * component must accept `onClick` and an `isPending` property. When the inner + * component emits `onClick`, that will begin the mutation. This is a trival + * abstraction on top of `useMutate`, but with type gymnastics to allow safe + * types. + */ +export function createMutationButton( + // Prevent calling this function if missing `onClick` + base: Required extends BaseButtonProps ? FC + : "Base component is missing required props", +): MutationButtonComponent>> { + const Component = base as ResolveMutationButtonFc; + // apply the generics at a type level to allow `.bind` to work + type BareProps = Omit; + const bound = (GenericMutationButton) + // the `as` clause here converts the second and third generic parameter + // back into unspecified generics. + .bind(null, Component) as MutationButtonComponent; + // react devtools loves display names + bound.displayName = `MutationButton[${ + Component.displayName ?? Component.name + }]`; + + return bound; +} + +type Identity = T; +type Flatten = Identity<{ [K in keyof T]: T[K] }>; +type ResolveMutationButtonFc = FC< + & Omit> + & BaseButtonProps +>; + +function GenericMutationButton< + Props, + Args extends unknown[], + Result, +>( + Component: ResolveMutationButtonFc, + props: MutationButtonProps & Props, +) { + const { + mutation, + args, + onClick, + onError, + onSuccess, + onSettled, + ...forwarded + } = props; + forwarded satisfies Omit>; + + const localHook = useMutate("subscribe" in mutation ? mutation : null); + const state = "subscribe" in mutation ? localHook : mutation; + + if (onError) void state.isError; // subscribe to the events + if (onSuccess) void state.isSuccess; // subscribe to the events + + // NOTE: the JSR has trouble with JSX syntax for some reason. + return jsx( + Component, + { + ...forwarded, + onClick: useCallback((e: MouseEvent) => { + onClick?.(e); + if (e.defaultPrevented) return; + const computedArgs = typeof args === "function" ? args(e) : args; + if (!computedArgs || e.defaultPrevented) return; + state.runWithResult(...computedArgs) + .then((result) => { + onSuccess?.(result); + onSettled?.({ status: "success", result }); + }) + .catch((error) => { + onError?.(error); + onSettled?.({ status: "error", error }); + }); + }, [state]), + isPending: state.isPending, + } satisfies Parameters[0], + ); +} diff --git a/src/react.tsx b/src/react.tsx deleted file mode 100644 index 278ad496f818ef6efe35dca6b6bd262153191c1e..0000000000000000000000000000000000000000 --- a/src/react.tsx +++ /dev/null @@ -1,370 +0,0 @@ -import { - type FC, - type MouseEvent, - type MouseEventHandler, - type ReactNode, - useCallback, - useEffect, - useState, -} from "react"; -import { message as errMessage } from "@clo/lib/error.ts"; -import type { Mutation } from "./types.ts"; - -/** - * Subscribe to a mutation's status, as well as accessing a local `run` method. - * The resulting mutation can be used directly or passed to a {@link createMutationButton|mutation button}. - */ -export function useMutate< - Args extends unknown[], - Result, ->( - mutation: Mutation | null, -): UseMutateResult { - const [_, setRerender] = useState(0); - const [observer] = useState(() => new Observer(setRerender)); - useEffect(() => () => void observer.reset(), []); - if (mutation !== observer.mutation) { - observer.mutation = mutation; - observer.reset(); - } - return observer.binding; -} - -export type UseMutateResult = - & UseMutateResultBase - & ( - | UseMutateSuccess - | UseMutateError - | UseMutateIdle - ); - -export interface UseMutateResultBase { - run: (...args: Args) => void; - clear: () => void; -} - -export interface UseMutateSuccess { - status: "success"; - result: Result; - error: undefined; - errorMessage: undefined; - /** `true` when a `mutate` function is currently running. */ - isMutating: false; - /** `true` when a loading indicator should be shown. */ - isPending: false; - /** `true` when a mutation has completed and has a result. */ - isSuccess: true; - /** `true` when a mutation has failed. */ - isError: false; - /** `true` when there is optimistic state applied. */ - isOptimisticData: boolean; -} -export interface UseMutateError { - status: "error"; - result: undefined; - error: unknown; - /** User-friendly in this format: `Failed to {action}: {details}` */ - errorMessage: string; - /** `true` when a `mutate` function is currently running. */ - isMutating: false; - /** `true` when a loading indicator should be shown. */ - isPending: false; - /** `true` when a mutation has completed and has a result. */ - isSuccess: false; - /** `true` when a mutation has failed. */ - isError: true; - /** `true` when there is optimistic state applied. */ - isOptimisticData: boolean; -} -export interface UseMutateIdle { - status: "idle" | "mutating"; - result: undefined; - error: undefined; - errorMessage: undefined; - /** `true` when a `mutate` function is currently running. */ - isMutating: boolean; - /** `true` when a loading indicator should be shown. */ - isPending: boolean; - /** `true` when a mutation has completed and has a result. */ - isSuccess: false; - /** `true` when a mutation has failed. */ - isError: false; - /** `true` when there is optimistic state applied. */ - isOptimisticData: boolean; -} - -type AnyMutationState = - & Omit< - UseMutateIdle, - "status" | "result" | "error" | "isSuccess" | "isError" | "errorMessage" - > - & { - status: "idle" | "mutating" | "error" | "success"; - result: undefined | Result; - error: undefined | unknown; - errorMessage: undefined | string; - isSuccess: boolean; - isError: boolean; - }; - -function initialState() { - return { - status: "idle", - result: undefined, - error: undefined, - errorMessage: undefined, - isMutating: false, - isPending: false, - isSuccess: false, - isError: false, - isOptimisticData: false, - } as const; -} - -class Observer { - setRerender: (fn: number) => void; - mutation: Mutation | null = null; - unsubscribe: (() => void) | null = null; - currentKey: string | null = null; - currentArgs: Args | null = null; - - constructor(setRerender: (fn: number) => void) { - this.setRerender = setRerender; - } - - watched: Set = new Set(); - state: AnyMutationState = initialState(); - setState(newState: Partial>) { - let updateUi = false; - const current: Record = this.state; - for (const [key, value] of Object.entries(newState)) { - if (value !== current[key]) { - current[key] = value; - updateUi ||= this.watched.has(key); - } - } - if (updateUi) { - this.setRerender(Math.random()); - } - } - - reset() { - this.unsubscribe?.(); - this.unsubscribe = null; - this.currentKey = null; - this.state = initialState(); - } - - computeErrorMessage(error: unknown): string | undefined { - if (!error) return undefined; - const mutation = this.mutation; - if (!mutation || !this.currentArgs) return errMessage(error); - return `Failed to ${mutation.describe(...this.currentArgs)}: ${ - errMessage(error) - }`; - } - - binding: UseMutateResult = ((self: this) => ({ - run(...args: Args) { - const mutation = self.mutation; - if (!mutation) return; - self.currentArgs = args; - const key = mutation.key(args); - if (key !== self.currentKey) { - self.currentKey = key; - self.unsubscribe?.(); - self.unsubscribe = mutation.subscribe( - mutation.key(args), - ({ status, error, result }) => { - if (status === "idle") { - self.setState({ - isMutating: false, - isPending: false, - isOptimisticData: false, - }); - return; - } - const hasError = error != null; - const hasResult = result != null; - - self.setState({ - status: hasError - ? "error" - : hasResult - ? "success" - : status === "mutating" - ? "mutating" - : "idle", - error: error ?? undefined, - errorMessage: self.computeErrorMessage(error ?? undefined), - result: result ?? undefined, - isMutating: status === "mutating", - isPending: status === "mutating" || status === "refetching", - isSuccess: hasResult && !hasError, - isError: hasError, - isOptimisticData: status === "waiting" || status === "mutating" || - status === "refetching", - }); - }, - ); - } - // Use global error/success handling if this usage of the hook doesn't check for - // errors or success. This makes it act pretty awesome in terms of defaults. - // You don't have to worry about result UI, they'll surface exactly once. - const watchesError = self.watched.has("isError") || - self.watched.has("error") || self.watched.has("errorMessage"); - const watchesSuccess = self.watched.has("isSuccess") || - self.watched.has("result"); - mutation.runAndReturn(...args) - .then((result) => { - if (!watchesSuccess && mutation.describeResult) { - const message = mutation.describeResult(args, result); - if (message && mutation.client.reportSuccess) { - mutation.client.reportSuccess(message); - } - } - }) - .catch((err) => { - if (!watchesError) { - const message = `Failed to ${mutation.describe(...args)}: ${errMessage(err)}`; - mutation.client.reportError(message, err); - } - }); - }, - clear() { - self.setState({ - status: ["error", "success"].includes(self.state.status) - ? "idle" - : self.state.status, - isError: false, - isSuccess: false, - error: undefined, - errorMessage: undefined, - result: undefined, - }); - }, - get status() { - self.watched.add("status"); - return self.state.status; - }, - get result() { - self.watched.add("result"); - return self.state.result; - }, - get error() { - self.watched.add("error"); - return self.state.error; - }, - get errorMessage() { - self.watched.add("errorMessage"); - return self.state.errorMessage; - }, - get isMutating() { - self.watched.add("isMutating"); - return self.state.isMutating; - }, - get isPending() { - self.watched.add("isPending"); - return self.state.isPending; - }, - get isSuccess() { - self.watched.add("isSuccess"); - return self.state.isSuccess; - }, - get isError() { - self.watched.add("isError"); - return self.state.isError; - }, - get isOptimisticData() { - self.watched.add("isOptimisticData"); - return self.state.isOptimisticData; - }, - } as UseMutateResult))(this); -} - -interface BaseButtonProps { - onClick: MouseEventHandler | undefined; - isPending: boolean; -} - -interface MutationButtonComponent { - ( - props: - & MutationButtonProps - & Props, - ): ReactNode; - displayName?: string; -} - -export interface MutationButtonProps { - mutation: - | Mutation - | Pick, "run" | "status" | "isPending">; - /** Preventing default will interrupt the mutation */ - args: Args | ((e: MouseEvent) => Args | null); - /** Preventing default will interrupt the mutation */ - onClick?: (e: MouseEvent) => void; -} - -/** - * Wraps a custom button component with logic to execute a mutation. The wrapped - * component must accept `onClick` and an `isPending` property. When the inner - * component emits `onClick`, that will begin the mutation. This is a trival - * abstraction on top of `useMutate`, but with type gymnastics to allow safe - * types. - */ -export function createMutationButton( - // Prevent calling this function if missing `onClick` - base: Required extends BaseButtonProps ? FC - : "Base component is missing required props", -): MutationButtonComponent>> { - const Component = base as ResolveMutationButtonFc; - // apply the generics at a type level to allow `.bind` to work - type BareProps = Omit; - const bound = (GenericMutationButton) - // the `as` clause here converts the second and third generic parameter - // back into unspecified generics. - .bind(null, Component) as MutationButtonComponent; - // react devtools loves display names - bound.displayName = `MutationButton[${ - Component.displayName ?? Component.name - }]`; - - return bound; -} - -type Identity = T; -type Flatten = Identity<{ [K in keyof T]: T[K] }>; -type ResolveMutationButtonFc = FC< - & Omit> - & BaseButtonProps ->; - -function GenericMutationButton< - Props, - Args extends unknown[], - Result, ->( - Component: ResolveMutationButtonFc, - props: MutationButtonProps & Props, -) { - const { mutation, args, onClick, ...forwarded } = props; - forwarded satisfies Omit>; - - const localHook = useMutate("subscribe" in mutation ? mutation : null); - const state = "subscribe" in mutation ? localHook : mutation; - - return ( - { - onClick?.(e); - if (e.defaultPrevented) return; - const computedArgs = typeof args === "function" ? args(e) : args; - if (!computedArgs || e.defaultPrevented) return; - state.run(...computedArgs); - }, [state])} - isPending={state.isPending} - /> - ); -} diff --git a/test/batch.test.ts b/test/batch.test.ts deleted file mode 100644 index 50a6ca26a285d7d4fb1503b12531665295478fe5..0000000000000000000000000000000000000000 --- a/test/batch.test.ts +++ /dev/null @@ -1,1019 +0,0 @@ -import { assertEquals, assertRejects } from "@std/assert"; -import { MutationClient } from "../src/client.ts"; -import type { MutationEvent } from "../src/types.ts"; -import { test } from "vitest"; - -// Shared test store for optimistic updates -const testStore = new Map(); - -// Helper to create a test mutation client -function createTestClient() { - const errors: unknown[] = []; - const client = new MutationClient({ - context: { userId: "test-user" }, - getOptimisticHelpers({ onRestore }) { - return { - increment(key: string, amount: number) { - const oldValue = testStore.get(key) ?? 0; - testStore.set(key, oldValue + amount); - onRestore(() => testStore.set(key, oldValue)); - }, - setValue(key: string, value: number) { - const oldValue = testStore.get(key); - testStore.set(key, value); - onRestore(() => { - if (oldValue === undefined) { - testStore.delete(key); - } else { - testStore.set(key, oldValue); - } - }); - }, - }; - }, - reportError(message, error) { - errors.push(error); - }, - }); - - return { client, errors }; -} - -// Helper to track mutation events -function createEventTracker() { - const events: Array> = []; - const callback = (event: MutationEvent) => { - events.push({ ...event }); - }; - return { events, callback }; -} - -// Helper to wait for async operations -function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -// ============================================================================ -// Basic functionality tests -// ============================================================================ - -test("BatchMutation - basic mutation success with debounce", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let commitCallCount = 0; - let refetchCallCount = 0; - - const mutation = client.defineDebounced({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 50, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - commitCallCount++; - await delay(10); - return current - initial; - }, - describe: "increment counter", - async refetch() { - refetchCallCount++; - }, - }); - - const result = await mutation.runAndReturn(5); - await delay(20); // Wait for refetch - - assertEquals(result, 5); - assertEquals(commitCallCount, 1); - assertEquals(refetchCallCount, 1); - assertEquals(testStore.get("counter"), 5); -}); - -test("BatchMutation - run() catches errors", async () => { - const { client, errors } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - const mutation = client.defineDebounced({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit() { - throw new Error("commit failed"); - }, - describe: "failing mutation", - async refetch() {}, - }); - - mutation.run(5); - await delay(100); - - assertEquals(errors.length, 1); - assertEquals((errors[0] as Error).message, "commit failed"); -}); - -test("BatchMutation - runAndReturn() rejects on error", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - const mutation = client.defineDebounced({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 10, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit() { - throw new Error("commit failed"); - }, - describe: "failing mutation", - async refetch() {}, - }); - - await assertRejects( - () => mutation.runAndReturn(5), - Error, - "commit failed", - ); -}); - -// ============================================================================ -// Debounce mode tests -// ============================================================================ - -test("BatchMutation - debounce batches rapid calls", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let commitCallCount = 0; - const commitArgs: Array<{ initial: number; current: number }> = []; - - const mutation = client.defineDebounced({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 50, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - commitCallCount++; - commitArgs.push({ initial, current }); - return current - initial; - }, - describe: "increment counter", - async refetch() {}, - }); - - // Rapid calls within debounce window - const promise1 = mutation.runAndReturn(1); - const promise2 = mutation.runAndReturn(2); - const promise3 = mutation.runAndReturn(3); - - // Optimistic updates should be applied immediately - assertEquals(testStore.get("counter"), 6); - - const results = await Promise.all([promise1, promise2, promise3]); - - // All should resolve with the same result (total delta) - assertEquals(results, [6, 6, 6]); - - // Only one commit should have been made - assertEquals(commitCallCount, 1); - assertEquals(commitArgs, [{ initial: 0, current: 6 }]); -}); - -test("BatchMutation - debounce resets timer on each call", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let commitCallCount = 0; - - const mutation = client.defineDebounced({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 30, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - commitCallCount++; - return current - initial; - }, - describe: "increment counter", - async refetch() {}, - }); - - // First call - const promise1 = mutation.runAndReturn(1); - - // Wait less than debounce time - await delay(15); - - // Second call should reset the timer - const promise2 = mutation.runAndReturn(2); - - // Wait less than debounce time again - await delay(15); - - // Commit should not have happened yet - assertEquals(commitCallCount, 0); - - // Third call - const promise3 = mutation.runAndReturn(3); - - // Wait for all to complete - await Promise.all([promise1, promise2, promise3]); - - // Only one commit - assertEquals(commitCallCount, 1); -}); - -test("BatchMutation - debounce separates batches after timeout", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let commitCallCount = 0; - const commitArgs: Array<{ initial: number; current: number }> = []; - - const mutation = client.defineDebounced({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 30, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - commitCallCount++; - commitArgs.push({ initial, current }); - return current - initial; - }, - describe: "increment counter", - async refetch() {}, - }); - - // First batch - await mutation.runAndReturn(1); - await delay(50); // Wait for first batch to complete - - // Second batch (after timeout) - await mutation.runAndReturn(2); - await delay(50); - - // Two separate commits - assertEquals(commitCallCount, 2); - assertEquals(commitArgs, [ - { initial: 0, current: 1 }, - { initial: 1, current: 3 }, - ]); -}); - -// ============================================================================ -// Throttle mode tests -// ============================================================================ - -test("BatchMutation - throttle commits immediately on first call", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let commitTime = 0; - const startTime = Date.now(); - - const mutation = client.defineDebounced({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "throttle", - time: 100, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - commitTime = Date.now() - startTime; - return current - initial; - }, - describe: "increment counter", - async refetch() {}, - }); - - await mutation.runAndReturn(5); - - // First call should commit immediately (within a small tolerance) - assertEquals(commitTime < 20, true); -}); - -test("BatchMutation - throttle batches calls within time window", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let commitCallCount = 0; - const commitArgs: Array<{ initial: number; current: number }> = []; - - const mutation = client.defineDebounced({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "throttle", - time: 100, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - commitCallCount++; - commitArgs.push({ initial, current }); - await delay(10); - return current - initial; - }, - describe: "increment counter", - async refetch() {}, - }); - - // First call commits immediately - const promise1 = mutation.runAndReturn(1); - await delay(5); - - // Second call within throttle window - should batch - const promise2 = mutation.runAndReturn(2); - await delay(5); - - // Third call within throttle window - should batch with second - const promise3 = mutation.runAndReturn(3); - - // Wait for first to complete - await promise1; - - // First commit happened immediately - assertEquals(commitCallCount, 1); - assertEquals(commitArgs[0], { initial: 0, current: 1 }); - - // Wait for throttle window to pass and second batch to commit - await Promise.all([promise2, promise3]); - await delay(50); - - // Second batch committed - assertEquals(commitCallCount, 2); - assertEquals(commitArgs[1], { initial: 1, current: 6 }); -}); - -test("BatchMutation - throttle allows new batch after time window", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let commitCallCount = 0; - - const mutation = client.defineDebounced({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "throttle", - time: 50, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - commitCallCount++; - return current - initial; - }, - describe: "increment counter", - async refetch() {}, - }); - - // First call - await mutation.runAndReturn(1); - await delay(10); - - assertEquals(commitCallCount, 1); - - // Wait for throttle window to pass - await delay(60); - - // Second call should commit immediately - await mutation.runAndReturn(2); - await delay(10); - - assertEquals(commitCallCount, 2); -}); - -// ============================================================================ -// No-op detection tests -// ============================================================================ - -test("BatchMutation - skips commit when value unchanged", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 5); - - let commitCallCount = 0; - - const mutation = client.defineDebounced({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - commitCallCount++; - return current - initial; - }, - describe: "increment counter", - async refetch() {}, - }); - - // +5 and -5 cancel out - const promise1 = mutation.runAndReturn(5); - const promise2 = mutation.runAndReturn(-5); - - const [result1, result2] = await Promise.all([promise1, promise2]); - - // No commit should have been made - assertEquals(commitCallCount, 0); - - // Results should be null (no actual change) - assertEquals(result1, null); - assertEquals(result2, null); - - // Store should be unchanged - assertEquals(testStore.get("counter"), 5); -}); - -test("BatchMutation - uses deepEquals for comparison", async () => { - const errors: unknown[] = []; - const objectStore: { value: { count: number } | null } = { - value: { count: 0 }, - }; - - const client = new MutationClient({ - context: {}, - getOptimisticHelpers({ onRestore }) { - return { - setCount(count: number) { - const old = objectStore.value; - objectStore.value = { count }; - onRestore(() => { - objectStore.value = old; - }); - }, - }; - }, - reportError(message, error) { - errors.push(error); - }, - }); - - let commitCallCount = 0; - - const mutation = client.defineDebounced({ - optimistic({ helpers }, count: number) { - helpers.setCount(count); - }, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: (_) => objectStore.value, - async commit() { - commitCallCount++; - return null; - }, - describe: "set count", - async refetch() {}, - }); - - // Set to same value (different object reference but same content) - await mutation.runAndReturn(0); - await delay(30); - - // Should skip commit because value is deeply equal - assertEquals(commitCallCount, 0); -}); - -test("BatchMutation - custom deepEquals function", async () => { - const errors: unknown[] = []; - let compareCallCount = 0; - - const client = new MutationClient({ - context: {}, - getOptimisticHelpers({ onRestore }) { - return { - increment(key: string, amount: number) { - const old = testStore.get(key) ?? 0; - testStore.set(key, old + amount); - onRestore(() => testStore.set(key, old)); - }, - }; - }, - reportError(message, error) { - errors.push(error); - }, - deepEquals(a, b) { - compareCallCount++; - // Custom comparison - return a === b; - }, - }); - - testStore.clear(); - testStore.set("counter", 0); - - const mutation = client.defineDebounced({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 10, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit() { - throw new Error("commit failed"); - }, - describe: "failing mutation", - async refetch() {}, - }); - - await mutation.runAndReturn(5).catch(() => { - // Expected to fail due to commit error - }); - await delay(30); - - // Custom deepEquals should have been called - assertEquals(compareCallCount > 0, true); -}); - -// ============================================================================ -// Rollback tests -// ============================================================================ - -test("BatchMutation - rollback on commit error", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 10); - - const mutation = client.defineDebounced({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit() { - throw new Error("commit failed"); - }, - describe: "failing mutation", - async refetch() {}, - }); - - // Optimistic update applied - const promise = mutation.runAndReturn(5); - assertEquals(testStore.get("counter"), 15); - - await assertRejects(() => promise, Error, "commit failed"); - - // Should be rolled back - assertEquals(testStore.get("counter"), 10); -}); - -test("BatchMutation - error event includes error details", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - const tracker = createEventTracker(); - - const mutation = client.defineDebounced({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit() { - throw new Error("commit failed"); - }, - describe: "failing mutation", - async refetch() {}, - }); - - const key = mutation.key([5]); - mutation.subscribe(key, tracker.callback); - - await assertRejects(() => mutation.runAndReturn(5)); - await delay(30); - - // Should have error in events - const errorEvents = tracker.events.filter((e) => e.error !== null); - assertEquals(errorEvents.length > 0, true); - assertEquals((errorEvents[0]?.error as Error).message, "commit failed"); -}); - -// ============================================================================ -// Key handling tests -// ============================================================================ - -test("BatchMutation - key() returns JSON stringified key", () => { - const { client } = createTestClient(); - testStore.clear(); - - const mutation = client.defineDebounced({ - optimistic(_ctx, _id: string) {}, - mode: "debounce", - time: 20, - key: ({ args }) => args[0], - getValue: (_) => 0, - async commit() { - return null; - }, - describe: "test mutation", - async refetch() {}, - }); - - assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id")); -}); - -test("BatchMutation - key() can return array", () => { - const { client } = createTestClient(); - testStore.clear(); - - const mutation = client.defineDebounced({ - optimistic(_ctx, _id: string) {}, - mode: "debounce", - time: 20, - key: ({ args }) => ["user", args[0]], - getValue: (_) => 0, - async commit() { - return null; - }, - describe: "test mutation", - async refetch() {}, - }); - - assertEquals( - mutation.key(["123"]), - JSON.stringify(["user", "123"]), - ); -}); - -test("BatchMutation - different keys create separate batches", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter-a", 0); - testStore.set("counter-b", 0); - - let commitCallCount = 0; - - const mutation = client.defineDebounced({ - optimistic({ helpers }, key: string, amount: number) { - helpers.increment(`counter-${key}`, amount); - }, - mode: "debounce", - time: 20, - key: ({ args }) => args[0], - getValue: (_, key) => testStore.get(`counter-${key}`) ?? 0, - async commit({ current }) { - commitCallCount++; - return current; - }, - describe: "test mutation", - async refetch() {}, - }); - - // Two different keys - const promise1 = mutation.runAndReturn("a", 5); - const promise2 = mutation.runAndReturn("b", 10); - - await Promise.all([promise1, promise2]); - await delay(30); - - // Should have two separate commits - assertEquals(commitCallCount, 2); - assertEquals(testStore.get("counter-a"), 5); - assertEquals(testStore.get("counter-b"), 10); -}); - -// ============================================================================ -// Describe tests -// ============================================================================ - -test("BatchMutation - describe() with string", () => { - const { client } = createTestClient(); - testStore.clear(); - - const mutation = client.defineDebounced({ - optimistic(_ctx, _amount: number) {}, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: (_) => 0, - async commit() { - return null; - }, - describe: "update counter", - async refetch() {}, - }); - - assertEquals(mutation.describe(5), "update counter"); -}); - -test("BatchMutation - describe() with function", () => { - const { client } = createTestClient(); - testStore.clear(); - - const mutation = client.defineDebounced({ - optimistic(_ctx, _amount: number) {}, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: (_) => 0, - async commit() { - return null; - }, - describe: ({ args }) => `increment by ${args[0]}`, - async refetch() {}, - }); - - assertEquals(mutation.describe(5), "increment by 5"); -}); - -// ============================================================================ -// Promise resolution tests -// ============================================================================ - -test("BatchMutation - all pending promises resolve with same result", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - const mutation = client.defineDebounced({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 30, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - return { delta: current - initial, timestamp: Date.now() }; - }, - describe: "increment counter", - async refetch() {}, - }); - - const promise1 = mutation.runAndReturn(1); - const promise2 = mutation.runAndReturn(2); - const promise3 = mutation.runAndReturn(3); - - const [result1, result2, result3] = await Promise.all([ - promise1, - promise2, - promise3, - ]); - - // All should get the same result object - assertEquals(result1, result2); - assertEquals(result2, result3); - assertEquals(result1.delta, 6); -}); - -test("BatchMutation - all pending promises reject with same error", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - const mutation = client.defineDebounced({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 30, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit() { - throw new Error("batch commit failed"); - }, - describe: "increment counter", - async refetch() {}, - }); - - const promise1 = mutation.runAndReturn(1); - const promise2 = mutation.runAndReturn(2); - const promise3 = mutation.runAndReturn(3); - - const errors: Error[] = []; - await Promise.all([ - promise1.catch((e) => errors.push(e)), - promise2.catch((e) => errors.push(e)), - promise3.catch((e) => errors.push(e)), - ]); - - // All should get the same error - assertEquals(errors.length, 3); - assertEquals(errors[0].message, "batch commit failed"); - assertEquals(errors[1].message, "batch commit failed"); - assertEquals(errors[2].message, "batch commit failed"); -}); - -// ============================================================================ -// Edge case tests -// ============================================================================ - -test("BatchMutation - handles empty getValue result", async () => { - const { client } = createTestClient(); - testStore.clear(); - - let commitCallCount = 0; - - const mutation = client.defineDebounced({ - optimistic({ helpers }, amount: number) { - helpers.setValue("nonexistent", amount); - }, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: (_) => testStore.get("nonexistent"), - async commit({ initial, current }) { - commitCallCount++; - return { initial, current }; - }, - describe: "test mutation", - }); - - const result = await mutation.runAndReturn(5); - await delay(30); - - assertEquals(commitCallCount, 1); - assertEquals(result.initial, undefined); - assertEquals(result.current, 5); -}); - -test("BatchMutation - channel cleanup after idle with no listeners", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - const mutation = client.defineDebounced({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - return current - initial; - }, - describe: "test mutation", - async refetch() {}, - }); - - // Run mutation without subscribing - await mutation.runAndReturn(5); - await delay(30); - - // Run another mutation - should work fine (channel recreated if needed) - const result = await mutation.runAndReturn(3); - await delay(30); - - assertEquals(result, 3); - assertEquals(testStore.get("counter"), 8); -}); - -test("BatchMutation - default time is 200ms", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let commitTime: number | null = null; - const startTime = Date.now(); - - const mutation = client.defineDebounced({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - // time not specified, should default to 200 - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ initial, current }) { - commitTime = Date.now() - startTime; - return current - initial; - }, - describe: "test mutation", - async refetch() {}, - }); - - await mutation.runAndReturn(5); - - // Should commit after ~200ms (with some tolerance) - assertEquals(commitTime !== null, true); - assertEquals(commitTime! >= 180, true); - assertEquals(commitTime! <= 250, true); -}); - -test("BatchMutation - context is passed to getValue", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let receivedUserId: string | undefined; - - const mutation = client.defineDebounced({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: ({ userId }, _) => { - receivedUserId = userId; - return testStore.get("counter") ?? 0; - }, - async commit({ initial, current }) { - return current - initial; - }, - describe: "test mutation", - async refetch() {}, - }); - - await mutation.runAndReturn(5); - await delay(30); - - assertEquals(receivedUserId, "test-user"); -}); - -test("BatchMutation - context is passed to commit", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let receivedUserId: string | undefined; - - const mutation = client.defineDebounced({ - optimistic({ helpers }, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 20, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ userId, initial, current }) { - receivedUserId = userId; - return current - initial; - }, - describe: "test mutation", - async refetch() {}, - }); - - await mutation.runAndReturn(5); - await delay(30); - - assertEquals(receivedUserId, "test-user"); -}); - -test("BatchMutation - first args are used for commit", async () => { - const { client } = createTestClient(); - testStore.clear(); - testStore.set("counter", 0); - - let receivedArgs: [string, number] | undefined; - - const mutation = client.defineDebounced({ - optimistic({ helpers }, _label: string, amount: number) { - helpers.increment("counter", amount); - }, - mode: "debounce", - time: 30, - key: () => "test-key", - getValue: (_) => testStore.get("counter") ?? 0, - async commit({ args, initial, current }) { - receivedArgs = args; - return current - initial; - }, - describe: "test mutation", - async refetch() {}, - }); - - mutation.runAndReturn("first", 1); - mutation.runAndReturn("second", 2); - await mutation.runAndReturn("third", 3); - await delay(10); - - // Should use first args - assertEquals(receivedArgs, ["first", 1]); -}); diff --git a/test/blocking.test.ts b/test/blocking.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ccf3be0acfa14be711dcf835cdea7b843b1d14c --- /dev/null +++ b/test/blocking.test.ts @@ -0,0 +1,1006 @@ +import { assertEquals, assertRejects } from "@std/assert"; +import { MutationClient } from "../src/client.ts"; +import type { MutationEvent } from "../src/types.ts"; +import { test } from "vitest"; + +// Helper to create a test mutation client +function createTestClient() { + const errors: Array<{ message: string; error: unknown }> = []; + const successes: string[] = []; + const client = new MutationClient({ + context: { userId: "test-user" }, + getOptimisticHelpers({ onRestore }) { + return { + setValue(key: string, value: string) { + testStore.set(key, value); + onRestore(() => testStore.delete(key)); + }, + }; + }, + reportError(message, error) { + errors.push({ message, error }); + }, + reportSuccess(message) { + successes.push(message); + }, + }); + + return { client, errors, successes }; +} + +const testStore = new Map(); + +// Helper to track mutation events +function createEventTracker() { + const events: Array> = []; + const callback = (event: MutationEvent) => { + events.push(event); + }; + return { events, callback }; +} + +// Helper to wait for async operations +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +test("BlockingMutation - basic mutation success", async () => { + const { client } = createTestClient(); + let mutateCallCount = 0; + let refetchCallCount = 0; + + const mutation = client.defineBlocking({ + async mutate(value: string) { + mutateCallCount++; + await delay(10); + return `result-${value}`; + }, + describe: "test mutation", + describeResult: "Success", + optimistic() { + // Empty optimistic update + }, + async refetch() { + refetchCallCount++; + await delay(5); + }, + }); + + const result = await mutation.runAndReturn("test"); + // Wait for refetch to complete + await delay(20); + + assertEquals(result, "result-test"); + assertEquals(mutateCallCount, 1); + assertEquals(refetchCallCount, 1); +}); + +test("BlockingMutation - run() catches errors", async () => { + const { client, errors } = createTestClient(); + + const mutation = client.defineBlocking({ + async mutate(_value: string) { + throw new Error("mutation failed"); + }, + describe: "failing mutation", + describeResult: "Success", + optimistic() {}, + async refetch() {}, + }); + + mutation.run("test"); + await delay(50); + + assertEquals(errors.length, 1); + assertEquals((errors[0].error as Error).message, "mutation failed"); +}); + +test("BlockingMutation - runAndReturn() rejects on error", async () => { + const { client } = createTestClient(); + + const mutation = client.defineBlocking({ + async mutate(_value: string) { + throw new Error("mutation failed"); + }, + describe: "failing mutation", + describeResult: "Success", + optimistic() {}, + async refetch() {}, + }); + + await assertRejects( + () => mutation.runAndReturn("test"), + Error, + "mutation failed", + ); +}); + +test("BlockingMutation - optimistic updates are applied immediately", async () => { + const { client } = createTestClient(); + testStore.clear(); + + const mutation = client.defineBlocking({ + async mutate(_key: string, value: string) { + await delay(50); + return value; + }, + describe: "set value", + describeResult: "Success", + optimistic({ args, helpers }) { + const [key, value] = args; + helpers.setValue(key, value); + }, + async refetch() {}, + }); + + const promise = mutation.runAndReturn("key1", "value1"); + + // Optimistic update should be applied synchronously + assertEquals(testStore.get("key1"), "value1"); + + // Wait for mutation to complete + await promise; + await delay(10); +}); + +test("BlockingMutation - rollback on error", async () => { + const { client } = createTestClient(); + testStore.clear(); + + const mutation = client.defineBlocking({ + async mutate(_key: string, _value: string) { + await delay(10); + throw new Error("mutation failed"); + }, + describe: "failing mutation", + describeResult: "Success", + optimistic({ args, helpers }) { + const [key, value] = args; + helpers.setValue(key, value); + }, + async refetch() {}, + }); + + await assertRejects(() => mutation.runAndReturn("key1", "value1")); + + // Optimistic update should be rolled back + assertEquals(testStore.has("key1"), false); +}); + +test("BlockingMutation - onSuccess callback is called", async () => { + const { client } = createTestClient(); + const successResults: string[] = []; + + const mutation = client.defineBlocking({ + async mutate(value: string) { + return `result-${value}`; + }, + describe: "test mutation", + describeResult: "Success", + optimistic({ onSuccess }) { + onSuccess((result) => { + successResults.push(result); + }); + }, + async refetch() {}, + }); + + await mutation.runAndReturn("test"); + + assertEquals(successResults, ["result-test"]); +}); + +test("BlockingMutation - mutations with same key execute serially", async () => { + const { client } = createTestClient(); + const executionOrder: string[] = []; + + const mutation = client.defineBlocking({ + async mutate(id: string) { + executionOrder.push(`start-${id}`); + await delay(20); + executionOrder.push(`end-${id}`); + return id; + }, + describe: "test mutation", + describeResult: "Success", + optimistic() {}, + async refetch() {}, + refetchOnSuccess: false, + key() { + return "same-key"; + }, + }); + + // Start two mutations with the same key + const promise1 = mutation.runAndReturn("1"); + const promise2 = mutation.runAndReturn("2"); + + await Promise.all([promise1, promise2]); + await delay(10); + + // They should execute serially, not in parallel + assertEquals(executionOrder, ["start-1", "end-1", "start-2", "end-2"]); +}); + +test("BlockingMutation - mutations with different keys execute in parallel", async () => { + const { client } = createTestClient(); + const executionOrder: string[] = []; + + const mutation = client.defineBlocking({ + async mutate(id: string) { + executionOrder.push(`start-${id}`); + await delay(20); + executionOrder.push(`end-${id}`); + return id; + }, + describe: "test mutation", + describeResult: "Success", + optimistic() {}, + async refetch() {}, + key({ args }) { + const [id] = args; + return id; + }, + }); + + // Start two mutations with different keys + const promise1 = mutation.runAndReturn("key1"); + const promise2 = mutation.runAndReturn("key2"); + + await Promise.all([promise1, promise2]); + + // They should start in parallel + assertEquals(executionOrder.slice(0, 2).sort(), ["start-key1", "start-key2"]); +}); + +test("BlockingMutation - key() returns JSON stringified key", () => { + const { client } = createTestClient(); + + const mutation = client.defineBlocking({ + async mutate(id: string) { + return id; + }, + describe: "test mutation", + describeResult: "Success", + optimistic() {}, + async refetch() {}, + key({ args }) { + const [id] = args; + return id; + }, + }); + + assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id")); +}); + +test("BlockingMutation - key() defaults to 'shared' when no key function", () => { + const { client } = createTestClient(); + + const mutation = client.defineBlocking({ + async mutate(id: string) { + return id; + }, + describe: "test mutation", + describeResult: "Success", + optimistic() {}, + async refetch() {}, + }); + + assertEquals(mutation.key(["test-id"]), JSON.stringify("shared")); +}); + +test("BlockingMutation - key() can return array", () => { + const { client } = createTestClient(); + + const mutation = client.defineBlocking({ + async mutate(_userId: string, _itemId: string) { + return "result"; + }, + describe: "test mutation", + describeResult: "Success", + optimistic() {}, + async refetch() {}, + key({ args }) { + const [userId, itemId] = args; + return [userId, itemId]; + }, + }); + + assertEquals( + mutation.key(["user1", "item1"]), + JSON.stringify(["user1", "item1"]), + ); +}); + +test("BlockingMutation - describe() with string", () => { + const { client } = createTestClient(); + + const mutation = client.defineBlocking({ + async mutate(value: string) { + return value; + }, + describe: "create item", + describeResult: "Success", + optimistic() {}, + async refetch() {}, + }); + + assertEquals(mutation.describe("test"), "create item"); +}); + +test("BlockingMutation - describe() with function", () => { + const { client } = createTestClient(); + + const mutation = client.defineBlocking({ + async mutate(id: string) { + return id; + }, + describe({ args }) { + const [id] = args; + return `delete item ${id}`; + }, + optimistic() {}, + async refetch() {}, + }); + + assertEquals(mutation.describe("123"), "delete item 123"); +}); + +test("BlockingMutation - describe() receives context", () => { + const { client } = createTestClient(); + + const mutation = client.defineBlocking({ + async mutate(id: string) { + return id; + }, + describe({ userId, args }) { + const [id] = args; + return `user ${userId} editing item ${id}`; + }, + optimistic() {}, + async refetch() {}, + }); + + assertEquals( + mutation.describe("123"), + "user test-user editing item 123", + ); +}); + +test("BlockingMutation - subscribe() tracks mutation events", async () => { + const { client } = createTestClient(); + const tracker = createEventTracker(); + + const mutation = client.defineBlocking({ + async mutate(value: string) { + await delay(10); + return `result-${value}`; + }, + describe: "test mutation", + describeResult: "Success", + optimistic() {}, + async refetch() { + await delay(5); + }, + }); + + const key = mutation.key(["test"]); + mutation.subscribe(key, tracker.callback); + + await mutation.runAndReturn("test"); + // Wait for refetch to complete + await delay(20); + + // Should have received status updates + assertEquals(tracker.events.length >= 2, true); + assertEquals(tracker.events.some((e) => e.status === "mutating"), true); + assertEquals(tracker.events.some((e) => e.status === "refetching"), true); +}); + +test("BlockingMutation - unsubscribe stops receiving events", async () => { + const { client } = createTestClient(); + const tracker = createEventTracker(); + + const mutation = client.defineBlocking({ + async mutate(value: string) { + await delay(10); + return value; + }, + describe: "test mutation", + describeResult: "Success", + optimistic() {}, + async refetch() {}, + refetchOnSuccess: false, + }); + + const key = mutation.key(["test"]); + const unsubscribe = mutation.subscribe(key, tracker.callback); + + unsubscribe(); + + await mutation.runAndReturn("test"); + await delay(10); + + // Should not have received any events + assertEquals(tracker.events.length, 0); +}); + +test("BlockingMutation - refetchOnSuccess can be disabled", async () => { + const { client } = createTestClient(); + let refetchCallCount = 0; + + const mutation = client.defineBlocking({ + async mutate(_value: string) { + return _value; + }, + describe: "test mutation", + describeResult: "Success", + optimistic() {}, + async refetch() { + refetchCallCount++; + }, + refetchOnSuccess: false, + }); + + await mutation.runAndReturn("test"); + + assertEquals(refetchCallCount, 0); +}); + +test("BlockingMutation - refetch is called on error", async () => { + const { client } = createTestClient(); + let refetchCallCount = 0; + + const mutation = client.defineBlocking({ + async mutate(_value: string) { + throw new Error("mutation failed"); + }, + describe: "failing mutation", + describeResult: "Success", + optimistic() {}, + async refetch() { + refetchCallCount++; + }, + }); + + await assertRejects(() => mutation.runAndReturn("test")); + + assertEquals(refetchCallCount, 1); +}); + +test("BlockingMutation - queued mutations are cancelled on error", async () => { + const { client } = createTestClient(); + const executionOrder: string[] = []; + + const mutation = client.defineBlocking({ + async mutate(id: string) { + executionOrder.push(`start-${id}`); + await delay(10); + if (id === "1") { + throw new Error("first mutation failed"); + } + executionOrder.push(`end-${id}`); + return id; + }, + describe: "test mutation", + describeResult: "Success", + optimistic() {}, + async refetch() {}, + key() { + return "same-key"; + }, + }); + + const promise1 = mutation.runAndReturn("1"); + const promise2 = mutation.runAndReturn("2"); + const promise3 = mutation.runAndReturn("3"); + + await assertRejects(() => promise1, Error, "first mutation failed"); + await assertRejects(() => promise2, Error, "first mutation failed"); + await assertRejects(() => promise3, Error, "first mutation failed"); + + // Only the first mutation should start + assertEquals(executionOrder, ["start-1"]); +}); + +test("BlockingMutation - rollbacks are called in reverse order on error", async () => { + const { client } = createTestClient(); + const rollbackOrder: number[] = []; + + const mutation = client.defineBlocking({ + async mutate(_value: string) { + throw new Error("mutation failed"); + }, + describe: "failing mutation", + describeResult: "Success", + optimistic({ onRestore }) { + onRestore(() => rollbackOrder.push(1)); + onRestore(() => rollbackOrder.push(2)); + onRestore(() => rollbackOrder.push(3)); + }, + async refetch() {}, + }); + + await assertRejects(() => mutation.runAndReturn("test")); + + // Rollbacks should be called in reverse order + assertEquals(rollbackOrder, [3, 2, 1]); +}); + +test("BlockingMutation - multiple mutations: rollbacks only affect failed mutation", async () => { + const { client } = createTestClient(); + const rollbackOrder: string[] = []; + + const mutation = client.defineBlocking({ + async mutate(id: string) { + await delay(10); + if (id === "fail") { + throw new Error("mutation failed"); + } + return id; + }, + describe: "test mutation", + describeResult: "Success", + optimistic({ args: [id], onRestore }) { + onRestore(() => rollbackOrder.push(`rollback-${id}`)); + }, + async refetch() {}, + key() { + return "same-key"; + }, + }); + + // First mutation succeeds + await mutation.runAndReturn("success"); + + // Second mutation fails + await assertRejects(() => mutation.runAndReturn("fail")); + + // Only the failed mutation's rollback should be called + // And all rollbacks from queued items + assertEquals(rollbackOrder, ["rollback-fail"]); +}); + +test("BlockingMutation - onRestore throws error if called after optimistic phase", async () => { + const { client } = createTestClient(); + let capturedOnRestore: ((cb: () => void) => void) | null = null; + + const mutation = client.defineBlocking({ + async mutate(_value: string) { + return "result"; + }, + describe: "test mutation", + describeResult: "Success", + optimistic({ onRestore }) { + capturedOnRestore = onRestore; + }, + async refetch() {}, + }); + + await mutation.runAndReturn("test"); + + // Calling onRestore after the optimistic phase should throw + let error: Error | null = null; + try { + capturedOnRestore!(() => {}); + } catch (e) { + error = e as Error; + } + + assertEquals( + error?.message, + "Can only call onRestore from within the optimistic update function.", + ); +}); + +test("BlockingMutation - onSuccess throws error if called after optimistic phase", async () => { + const { client } = createTestClient(); + let capturedOnSuccess: ((cb: (result: string) => void) => void) | null = null; + + const mutation = client.defineBlocking({ + async mutate(_value: string) { + return "result"; + }, + describe: "test mutation", + describeResult: "Success", + optimistic({ onSuccess }) { + capturedOnSuccess = onSuccess; + }, + async refetch() {}, + }); + + await mutation.runAndReturn("test"); + + // Calling onSuccess after the optimistic phase should throw + let error: Error | null = null; + try { + capturedOnSuccess!(() => {}); + } catch (e) { + error = e as Error; + } + + assertEquals( + error?.message, + "Can only call onSuccess from within the optimistic update function.", + ); +}); + +test("BlockingMutation - error during optimistic update is rejected immediately", async () => { + const { client } = createTestClient(); + + const mutation = client.defineBlocking({ + async mutate(_value: string) { + return "result"; + }, + describe: "test mutation", + describeResult: "Success", + optimistic() { + throw new Error("optimistic update failed"); + }, + async refetch() {}, + }); + + await assertRejects( + () => mutation.runAndReturn("test"), + Error, + "optimistic update failed", + ); +}); + +test("BlockingMutation - error during optimistic update rolls back registered callbacks", async () => { + const { client } = createTestClient(); + const rollbackOrder: number[] = []; + + const mutation = client.defineBlocking({ + async mutate(_value: string) { + return "result"; + }, + describe: "test mutation", + describeResult: "Success", + optimistic({ onRestore }) { + onRestore(() => rollbackOrder.push(1)); + onRestore(() => rollbackOrder.push(2)); + throw new Error("optimistic update failed"); + }, + async refetch() {}, + }); + + await assertRejects(() => mutation.runAndReturn("test")); + + // Rollbacks should be called even though optimistic update failed + // Note: during optimistic error, rollbacks are executed in the order they were added + assertEquals(rollbackOrder, [1, 2]); +}); + +test("BlockingMutation - refetch errors are reported but don't fail mutation", async () => { + const { client, errors } = createTestClient(); + + const mutation = client.defineBlocking({ + async mutate(value: string) { + return value; + }, + describe: "test mutation", + describeResult: "Success", + optimistic() {}, + async refetch() { + throw new Error("refetch failed"); + }, + }); + + // Mutation should still succeed + const result = await mutation.runAndReturn("test"); + assertEquals(result, "test"); + + // But refetch error should be reported + await delay(20); + assertEquals(errors.length, 1); + assertEquals((errors[0].error as Error).message, "refetch failed"); +}); + +test("BlockingMutation - optimistic function receives args and helpers", async () => { + const { client } = createTestClient(); + let receivedArgs: unknown[] | undefined; + let receivedHelpers: unknown | undefined; + + const mutation = client.defineBlocking({ + async mutate(_value: string) { + return "result"; + }, + describe: "test mutation", + describeResult: "Success", + optimistic({ args, helpers }) { + receivedArgs = args; + receivedHelpers = helpers; + }, + async refetch() {}, + }); + + await mutation.runAndReturn("test"); + + assertEquals(receivedArgs, ["test"]); + assertEquals(typeof receivedHelpers, "object"); +}); + +test("BlockingMutation - refetch receives context and args", async () => { + const { client } = createTestClient(); + let receivedUserId: string | undefined; + let receivedArgs: unknown[] | undefined; + + const mutation = client.defineBlocking({ + async mutate(_id: string, value: string) { + return value; + }, + describe: "test mutation", + describeResult: "Success", + optimistic() {}, + async refetch({ userId, args }) { + receivedUserId = userId; + receivedArgs = args; + }, + }); + + await mutation.runAndReturn("test-id", "test-value"); + + assertEquals(receivedUserId, "test-user"); + assertEquals(receivedArgs, ["test-id", "test-value"]); +}); + +test("BlockingMutation - notifies error on mutation failure", async () => { + const { client } = createTestClient(); + const tracker = createEventTracker(); + + const mutation = client.defineBlocking({ + async mutate(_value: string) { + await delay(10); + throw new Error("mutation failed"); + }, + describe: "failing mutation", + describeResult: "Success", + optimistic() {}, + async refetch() {}, + }); + + const key = mutation.key(["test"]); + mutation.subscribe(key, tracker.callback); + + await assertRejects(() => mutation.runAndReturn("test")); + + // Should have error event + const errorEvents = tracker.events.filter((e) => + e.status === "mutating" && e.error + ); + assertEquals(errorEvents.length > 0, true); + assertEquals((errorEvents[0]?.error as Error).message, "mutation failed"); +}); + +test("BlockingMutation - multiple subscribers receive events", async () => { + const { client } = createTestClient(); + const tracker1 = createEventTracker(); + const tracker2 = createEventTracker(); + + const mutation = client.defineBlocking({ + async mutate(value: string) { + await delay(5); + return value; + }, + describe: "test mutation", + describeResult: "Success", + optimistic() {}, + async refetch() {}, + refetchOnSuccess: false, + }); + + const key = mutation.key(["test"]); + mutation.subscribe(key, tracker1.callback); + mutation.subscribe(key, tracker2.callback); + + await mutation.runAndReturn("test"); + await delay(10); + + // Both subscribers should receive events + assertEquals(tracker1.events.length, tracker2.events.length); + assertEquals(tracker1.events.length > 0, true); +}); + +test("BlockingMutation - onSuccess is called before mutation resolves", async () => { + const { client } = createTestClient(); + const callOrder: string[] = []; + + const mutation = client.defineBlocking({ + async mutate(value: string) { + return value; + }, + describe: "test mutation", + describeResult: "Success", + optimistic({ onSuccess }) { + onSuccess(() => { + callOrder.push("onSuccess"); + }); + }, + async refetch() {}, + refetchOnSuccess: false, + }); + + const promise = mutation.runAndReturn("test"); + promise.then(() => { + callOrder.push("then"); + }); + + await promise; + await delay(5); + + // onSuccess should be called before the promise resolves + assertEquals(callOrder, ["onSuccess", "then"]); +}); + +test("BlockingMutation - result is passed to notification on success", async () => { + const { client } = createTestClient(); + const tracker = createEventTracker(); + + const mutation = client.defineBlocking({ + async mutate(value: string) { + await delay(5); + return `result-${value}`; + }, + describe: "test mutation", + describeResult: "Success", + optimistic() {}, + async refetch() { + await delay(5); + }, + }); + + const key = mutation.key(["test"]); + mutation.subscribe(key, tracker.callback); + + await mutation.runAndReturn("test"); + await delay(20); + + // Should have refetching event with result + const refetchingEvents = tracker.events.filter((e) => + e.status === "refetching" + ); + assertEquals(refetchingEvents.length > 0, true); + assertEquals(refetchingEvents[0]?.result, "result-test"); +}); + +test("BlockingMutation - channel is reused for same key", async () => { + const { client } = createTestClient(); + const events: string[] = []; + + const mutation = client.defineBlocking({ + async mutate(value: string) { + events.push(`mutate-${value}`); + return value; + }, + describe: "test mutation", + describeResult: "Success", + optimistic() {}, + async refetch() {}, + refetchOnSuccess: false, + }); + + // First mutation + await mutation.runAndReturn("first"); + await delay(5); + + // Second mutation with same key + await mutation.runAndReturn("second"); + await delay(5); + + assertEquals(events, ["mutate-first", "mutate-second"]); +}); + +test("BlockingMutation - empty queue after all mutations complete", async () => { + const { client } = createTestClient(); + + const mutation = client.defineBlocking({ + async mutate(value: string) { + await delay(5); + return value; + }, + describe: "test mutation", + describeResult: "Success", + optimistic() {}, + async refetch() {}, + refetchOnSuccess: false, + key() { + return "test-key"; + }, + }); + + // Run multiple mutations + await mutation.runAndReturn("1"); + await mutation.runAndReturn("2"); + await mutation.runAndReturn("3"); + await delay(10); + + // All mutations should have completed + // (We can't directly check the queue, but we can verify by running another mutation) + const start = Date.now(); + await mutation.runAndReturn("4"); + const duration = Date.now() - start; + + // Should execute immediately, not be queued (< 10ms if not queued) + assertEquals(duration < 15, true); +}); + +test("BlockingMutation - multiple onSuccess callbacks are all called", async () => { + const { client } = createTestClient(); + const results: string[] = []; + + const mutation = client.defineBlocking({ + async mutate(value: string) { + return value; + }, + describe: "test mutation", + describeResult: "Success", + optimistic({ onSuccess }) { + onSuccess((result) => results.push(`first-${result}`)); + onSuccess((result) => results.push(`second-${result}`)); + onSuccess((result) => results.push(`third-${result}`)); + }, + async refetch() {}, + refetchOnSuccess: false, + }); + + await mutation.runAndReturn("test"); + + assertEquals(results, ["first-test", "second-test", "third-test"]); +}); + +test("BlockingMutation - refetchOnSuccess false skips refetch", async () => { + const { client } = createTestClient(); + let refetchCalled = false; + + const mutation = client.defineBlocking({ + async mutate(value: string) { + return value; + }, + describe: "test mutation", + describeResult: "Success", + optimistic() {}, + async refetch() { + refetchCalled = true; + }, + refetchOnSuccess: false, + }); + + await mutation.runAndReturn("test"); + await delay(10); + + // Refetch should not have been called + assertEquals(refetchCalled, false); +}); + +test("BlockingMutation - refetch error after mutation failure is reported", async () => { + const { client, errors } = createTestClient(); + + const mutation = client.defineBlocking({ + async mutate(_value: string) { + throw new Error("mutation failed"); + }, + describe: "failing mutation", + describeResult: "Success", + optimistic() {}, + async refetch() { + throw new Error("refetch also failed"); + }, + }); + + await assertRejects( + () => mutation.runAndReturn("test"), + Error, + "mutation failed", + ); + + // Wait for refetch to complete and error to be reported + await delay(20); + + // Should have both the mutation error and refetch error reported + assertEquals(errors.length >= 1, true); + assertEquals( + (errors[errors.length - 1].error as Error).message, + "refetch also failed", + ); +}); diff --git a/test/debounced.test.ts b/test/debounced.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..68139a968041fc8eb74ec2c645fd01f838cfbfd9 --- /dev/null +++ b/test/debounced.test.ts @@ -0,0 +1,1050 @@ +import { assertEquals, assertRejects } from "@std/assert"; +import { MutationClient } from "../src/client.ts"; +import type { MutationEvent } from "../src/types.ts"; +import { test } from "vitest"; + +// Shared test store for optimistic updates +const testStore = new Map(); + +// Helper to create a test mutation client +function createTestClient() { + const errors: Array<{ message: string; error: unknown }> = []; + const successes: string[] = []; + const client = new MutationClient({ + context: { userId: "test-user" }, + getOptimisticHelpers({ onRestore }) { + return { + increment(key: string, amount: number) { + const oldValue = testStore.get(key) ?? 0; + testStore.set(key, oldValue + amount); + onRestore(() => testStore.set(key, oldValue)); + }, + setValue(key: string, value: number) { + const oldValue = testStore.get(key); + testStore.set(key, value); + onRestore(() => { + if (oldValue === undefined) { + testStore.delete(key); + } else { + testStore.set(key, oldValue); + } + }); + }, + }; + }, + reportError(message, error) { + errors.push({ message, error }); + }, + reportSuccess(message) { + successes.push(message); + }, + }); + + return { client, errors, successes }; +} + +// Helper to track mutation events +function createEventTracker() { + const events: Array> = []; + const callback = (event: MutationEvent) => { + events.push({ ...event }); + }; + return { events, callback }; +} + +// Helper to wait for async operations +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// ============================================================================ +// Basic functionality tests +// ============================================================================ + +test("DebouncedMutation - basic mutation success with debounce", async () => { + const { client } = createTestClient(); + testStore.clear(); + testStore.set("counter", 0); + + let commitCallCount = 0; + let refetchCallCount = 0; + + const mutation = client.defineDebounced({ + optimistic({ helpers }, amount: number) { + helpers.increment("counter", amount); + }, + mode: "debounce", + time: 50, + key: () => "test-key", + getValue: (_) => testStore.get("counter") ?? 0, + async commit({ initial, current }) { + commitCallCount++; + await delay(10); + return current - initial; + }, + describe: "increment counter", + describeResult: "Success", + async refetch() { + refetchCallCount++; + }, + }); + + const result = await mutation.runAndReturn(5); + await delay(20); // Wait for refetch + + assertEquals(result, 5); + assertEquals(commitCallCount, 1); + assertEquals(refetchCallCount, 1); + assertEquals(testStore.get("counter"), 5); +}); + +test("DebouncedMutation - run() catches errors", async () => { + const { client, errors } = createTestClient(); + testStore.clear(); + testStore.set("counter", 0); + + const mutation = client.defineDebounced({ + optimistic({ helpers }, amount: number) { + helpers.increment("counter", amount); + }, + mode: "debounce", + time: 20, + key: () => "test-key", + getValue: (_) => testStore.get("counter") ?? 0, + async commit() { + throw new Error("commit failed"); + }, + describe: "failing mutation", + describeResult: "Success", + async refetch() {}, + }); + + mutation.run(5); + await delay(100); + + assertEquals(errors.length, 1); + assertEquals((errors[0].error as Error).message, "commit failed"); +}); + +test("DebouncedMutation - runAndReturn() rejects on error", async () => { + const { client } = createTestClient(); + testStore.clear(); + testStore.set("counter", 0); + + const mutation = client.defineDebounced({ + optimistic({ helpers }, amount: number) { + helpers.increment("counter", amount); + }, + mode: "debounce", + time: 10, + key: () => "test-key", + getValue: (_) => testStore.get("counter") ?? 0, + async commit() { + throw new Error("commit failed"); + }, + describe: "failing mutation", + describeResult: "Success", + async refetch() {}, + }); + + await assertRejects( + () => mutation.runAndReturn(5), + Error, + "commit failed", + ); +}); + +// ============================================================================ +// Debounce mode tests +// ============================================================================ + +test("DebouncedMutation - debounce batches rapid calls", async () => { + const { client } = createTestClient(); + testStore.clear(); + testStore.set("counter", 0); + + let commitCallCount = 0; + const commitArgs: Array<{ initial: number; current: number }> = []; + + const mutation = client.defineDebounced({ + optimistic({ helpers }, amount: number) { + helpers.increment("counter", amount); + }, + mode: "debounce", + time: 50, + key: () => "test-key", + getValue: (_) => testStore.get("counter") ?? 0, + async commit({ initial, current }) { + commitCallCount++; + commitArgs.push({ initial, current }); + return current - initial; + }, + describe: "increment counter", + describeResult: "Success", + async refetch() {}, + }); + + // Rapid calls within debounce window + const promise1 = mutation.runAndReturn(1); + const promise2 = mutation.runAndReturn(2); + const promise3 = mutation.runAndReturn(3); + + // Optimistic updates should be applied immediately + assertEquals(testStore.get("counter"), 6); + + const results = await Promise.all([promise1, promise2, promise3]); + + // All should resolve with the same result (total delta) + assertEquals(results, [6, 6, 6]); + + // Only one commit should have been made + assertEquals(commitCallCount, 1); + assertEquals(commitArgs, [{ initial: 0, current: 6 }]); +}); + +test("DebouncedMutation - debounce resets timer on each call", async () => { + const { client } = createTestClient(); + testStore.clear(); + testStore.set("counter", 0); + + let commitCallCount = 0; + + const mutation = client.defineDebounced({ + optimistic({ helpers }, amount: number) { + helpers.increment("counter", amount); + }, + mode: "debounce", + time: 30, + key: () => "test-key", + getValue: (_) => testStore.get("counter") ?? 0, + async commit({ initial, current }) { + commitCallCount++; + return current - initial; + }, + describe: "increment counter", + describeResult: "Success", + async refetch() {}, + }); + + // First call + const promise1 = mutation.runAndReturn(1); + + // Wait less than debounce time + await delay(15); + + // Second call should reset the timer + const promise2 = mutation.runAndReturn(2); + + // Wait less than debounce time again + await delay(15); + + // Commit should not have happened yet + assertEquals(commitCallCount, 0); + + // Third call + const promise3 = mutation.runAndReturn(3); + + // Wait for all to complete + await Promise.all([promise1, promise2, promise3]); + + // Only one commit + assertEquals(commitCallCount, 1); +}); + +test("DebouncedMutation - debounce separates batches after timeout", async () => { + const { client } = createTestClient(); + testStore.clear(); + testStore.set("counter", 0); + + let commitCallCount = 0; + const commitArgs: Array<{ initial: number; current: number }> = []; + + const mutation = client.defineDebounced({ + optimistic({ helpers }, amount: number) { + helpers.increment("counter", amount); + }, + mode: "debounce", + time: 30, + key: () => "test-key", + getValue: (_) => testStore.get("counter") ?? 0, + async commit({ initial, current }) { + commitCallCount++; + commitArgs.push({ initial, current }); + return current - initial; + }, + describe: "increment counter", + describeResult: "Success", + async refetch() {}, + }); + + // First batch + await mutation.runAndReturn(1); + await delay(50); // Wait for first batch to complete + + // Second batch (after timeout) + await mutation.runAndReturn(2); + await delay(50); + + // Two separate commits + assertEquals(commitCallCount, 2); + assertEquals(commitArgs, [ + { initial: 0, current: 1 }, + { initial: 1, current: 3 }, + ]); +}); + +// ============================================================================ +// Throttle mode tests +// ============================================================================ + +test("DebouncedMutation - throttle commits immediately on first call", async () => { + const { client } = createTestClient(); + testStore.clear(); + testStore.set("counter", 0); + + let commitTime = 0; + const startTime = Date.now(); + + const mutation = client.defineDebounced({ + optimistic({ helpers }, amount: number) { + helpers.increment("counter", amount); + }, + mode: "throttle", + time: 100, + key: () => "test-key", + getValue: (_) => testStore.get("counter") ?? 0, + async commit({ initial, current }) { + commitTime = Date.now() - startTime; + return current - initial; + }, + describe: "increment counter", + describeResult: "Success", + async refetch() {}, + }); + + await mutation.runAndReturn(5); + + // First call should commit immediately (within a small tolerance) + assertEquals(commitTime < 20, true); +}); + +test("DebouncedMutation - throttle batches calls within time window", async () => { + const { client } = createTestClient(); + testStore.clear(); + testStore.set("counter", 0); + + let commitCallCount = 0; + const commitArgs: Array<{ initial: number; current: number }> = []; + + const mutation = client.defineDebounced({ + optimistic({ helpers }, amount: number) { + helpers.increment("counter", amount); + }, + mode: "throttle", + time: 100, + key: () => "test-key", + getValue: (_) => testStore.get("counter") ?? 0, + async commit({ initial, current }) { + commitCallCount++; + commitArgs.push({ initial, current }); + await delay(10); + return current - initial; + }, + describe: "increment counter", + describeResult: "Success", + async refetch() {}, + }); + + // First call commits immediately + const promise1 = mutation.runAndReturn(1); + await delay(5); + + // Second call within throttle window - should batch + const promise2 = mutation.runAndReturn(2); + await delay(5); + + // Third call within throttle window - should batch with second + const promise3 = mutation.runAndReturn(3); + + // Wait for first to complete + await promise1; + + // First commit happened immediately + assertEquals(commitCallCount, 1); + assertEquals(commitArgs[0], { initial: 0, current: 1 }); + + // Wait for throttle window to pass and second batch to commit + await Promise.all([promise2, promise3]); + await delay(50); + + // Second batch committed + assertEquals(commitCallCount, 2); + assertEquals(commitArgs[1], { initial: 1, current: 6 }); +}); + +test("DebouncedMutation - throttle allows new batch after time window", async () => { + const { client } = createTestClient(); + testStore.clear(); + testStore.set("counter", 0); + + let commitCallCount = 0; + + const mutation = client.defineDebounced({ + optimistic({ helpers }, amount: number) { + helpers.increment("counter", amount); + }, + mode: "throttle", + time: 50, + key: () => "test-key", + getValue: (_) => testStore.get("counter") ?? 0, + async commit({ initial, current }) { + commitCallCount++; + return current - initial; + }, + describe: "increment counter", + describeResult: "Success", + async refetch() {}, + }); + + // First call + await mutation.runAndReturn(1); + await delay(10); + + assertEquals(commitCallCount, 1); + + // Wait for throttle window to pass + await delay(60); + + // Second call should commit immediately + await mutation.runAndReturn(2); + await delay(10); + + assertEquals(commitCallCount, 2); +}); + +// ============================================================================ +// No-op detection tests +// ============================================================================ + +test("DebouncedMutation - skips commit when value unchanged", async () => { + const { client } = createTestClient(); + testStore.clear(); + testStore.set("counter", 5); + + let commitCallCount = 0; + + const mutation = client.defineDebounced({ + optimistic({ helpers }, amount: number) { + helpers.increment("counter", amount); + }, + mode: "debounce", + time: 20, + key: () => "test-key", + getValue: (_) => testStore.get("counter") ?? 0, + async commit({ initial, current }) { + commitCallCount++; + return current - initial; + }, + describe: "increment counter", + describeResult: "Success", + async refetch() {}, + }); + + // +5 and -5 cancel out + const promise1 = mutation.runAndReturn(5); + const promise2 = mutation.runAndReturn(-5); + + const [result1, result2] = await Promise.all([promise1, promise2]); + + // No commit should have been made + assertEquals(commitCallCount, 0); + + // Results should be null (no actual change) + assertEquals(result1, null); + assertEquals(result2, null); + + // Store should be unchanged + assertEquals(testStore.get("counter"), 5); +}); + +test("DebouncedMutation - uses deepEquals for comparison", async () => { + const errors: unknown[] = []; + const objectStore: { value: { count: number } | null } = { + value: { count: 0 }, + }; + + const client = new MutationClient({ + context: {}, + getOptimisticHelpers({ onRestore }) { + return { + setCount(count: number) { + const old = objectStore.value; + objectStore.value = { count }; + onRestore(() => { + objectStore.value = old; + }); + }, + }; + }, + reportError(message, error) { + errors.push(error); + }, + }); + + let commitCallCount = 0; + + const mutation = client.defineDebounced({ + optimistic({ helpers }, count: number) { + helpers.setCount(count); + }, + mode: "debounce", + time: 20, + key: () => "test-key", + getValue: (_) => objectStore.value, + async commit() { + commitCallCount++; + return null; + }, + describe: "set count", + describeResult: "Success", + async refetch() {}, + }); + + // Set to same value (different object reference but same content) + await mutation.runAndReturn(0); + await delay(30); + + // Should skip commit because value is deeply equal + assertEquals(commitCallCount, 0); +}); + +test("DebouncedMutation - custom deepEquals function", async () => { + const errors: unknown[] = []; + let compareCallCount = 0; + + const client = new MutationClient({ + context: {}, + getOptimisticHelpers({ onRestore }) { + return { + increment(key: string, amount: number) { + const old = testStore.get(key) ?? 0; + testStore.set(key, old + amount); + onRestore(() => testStore.set(key, old)); + }, + }; + }, + reportError(message, error) { + errors.push(error); + }, + deepEquals(a, b) { + compareCallCount++; + // Custom comparison + return a === b; + }, + }); + + testStore.clear(); + testStore.set("counter", 0); + + const mutation = client.defineDebounced({ + optimistic({ helpers }, amount: number) { + helpers.increment("counter", amount); + }, + mode: "debounce", + time: 10, + key: () => "test-key", + getValue: (_) => testStore.get("counter") ?? 0, + async commit() { + throw new Error("commit failed"); + }, + describe: "failing mutation", + describeResult: "Success", + async refetch() {}, + }); + + await mutation.runAndReturn(5).catch(() => { + // Expected to fail due to commit error + }); + await delay(30); + + // Custom deepEquals should have been called + assertEquals(compareCallCount > 0, true); +}); + +// ============================================================================ +// Rollback tests +// ============================================================================ + +test("DebouncedMutation - rollback on commit error", async () => { + const { client } = createTestClient(); + testStore.clear(); + testStore.set("counter", 10); + + const mutation = client.defineDebounced({ + optimistic({ helpers }, amount: number) { + helpers.increment("counter", amount); + }, + mode: "debounce", + time: 20, + key: () => "test-key", + getValue: (_) => testStore.get("counter") ?? 0, + async commit() { + throw new Error("commit failed"); + }, + describe: "failing mutation", + describeResult: "Success", + async refetch() {}, + }); + + // Optimistic update applied + const promise = mutation.runAndReturn(5); + assertEquals(testStore.get("counter"), 15); + + await assertRejects(() => promise, Error, "commit failed"); + + // Should be rolled back + assertEquals(testStore.get("counter"), 10); +}); + +test("DebouncedMutation - error event includes error details", async () => { + const { client } = createTestClient(); + testStore.clear(); + testStore.set("counter", 0); + + const tracker = createEventTracker(); + + const mutation = client.defineDebounced({ + optimistic({ helpers }, amount: number) { + helpers.increment("counter", amount); + }, + mode: "debounce", + time: 20, + key: () => "test-key", + getValue: (_) => testStore.get("counter") ?? 0, + async commit() { + throw new Error("commit failed"); + }, + describe: "failing mutation", + describeResult: "Success", + async refetch() {}, + }); + + const key = mutation.key([5]); + mutation.subscribe(key, tracker.callback); + + await assertRejects(() => mutation.runAndReturn(5)); + await delay(30); + + // Should have error in events + const errorEvents = tracker.events.filter((e) => e.error !== null); + assertEquals(errorEvents.length > 0, true); + assertEquals((errorEvents[0]?.error as Error).message, "commit failed"); +}); + +// ============================================================================ +// Key handling tests +// ============================================================================ + +test("DebouncedMutation - key() returns JSON stringified key", () => { + const { client } = createTestClient(); + testStore.clear(); + + const mutation = client.defineDebounced({ + optimistic(_ctx, _id: string) {}, + mode: "debounce", + time: 20, + key: ({ args }) => args[0], + getValue: (_) => 0, + async commit() { + return null; + }, + describe: "test mutation", + describeResult: "Success", + async refetch() {}, + }); + + assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id")); +}); + +test("DebouncedMutation - key() can return array", () => { + const { client } = createTestClient(); + testStore.clear(); + + const mutation = client.defineDebounced({ + optimistic(_ctx, _id: string) {}, + mode: "debounce", + time: 20, + key: ({ args }) => ["user", args[0]], + getValue: (_) => 0, + async commit() { + return null; + }, + describe: "test mutation", + describeResult: "Success", + async refetch() {}, + }); + + assertEquals( + mutation.key(["123"]), + JSON.stringify(["user", "123"]), + ); +}); + +test("DebouncedMutation - different keys create separate batches", async () => { + const { client } = createTestClient(); + testStore.clear(); + testStore.set("counter-a", 0); + testStore.set("counter-b", 0); + + let commitCallCount = 0; + + const mutation = client.defineDebounced({ + optimistic({ helpers }, key: string, amount: number) { + helpers.increment(`counter-${key}`, amount); + }, + mode: "debounce", + time: 20, + key: ({ args }) => args[0], + getValue: ({ args: [key] }) => testStore.get(`counter-${key}`) ?? 0, + async commit({ current }) { + commitCallCount++; + return current; + }, + describe: "test mutation", + describeResult: "Success", + async refetch() {}, + }); + + // Two different keys + const promise1 = mutation.runAndReturn("a", 5); + const promise2 = mutation.runAndReturn("b", 10); + + await Promise.all([promise1, promise2]); + await delay(30); + + // Should have two separate commits + assertEquals(commitCallCount, 2); + assertEquals(testStore.get("counter-a"), 5); + assertEquals(testStore.get("counter-b"), 10); +}); + +// ============================================================================ +// Describe tests +// ============================================================================ + +test("DebouncedMutation - describe() with string", () => { + const { client } = createTestClient(); + testStore.clear(); + + const mutation = client.defineDebounced({ + optimistic(_ctx, _amount: number) {}, + mode: "debounce", + time: 20, + key: () => "test-key", + getValue: (_) => 0, + async commit() { + return null; + }, + describe: "update counter", + describeResult: "Success", + async refetch() {}, + }); + + assertEquals(mutation.describe(5), "update counter"); +}); + +test("DebouncedMutation - describe() with function", () => { + const { client } = createTestClient(); + testStore.clear(); + + const mutation = client.defineDebounced({ + optimistic(_ctx, _amount: number) {}, + mode: "debounce", + time: 20, + key: () => "test-key", + getValue: (_) => 0, + async commit() { + return null; + }, + describe: ({ args }) => `increment by ${args[0]}`, + describeResult: "Success", + async refetch() {}, + }); + + assertEquals(mutation.describe(5), "increment by 5"); +}); + +// ============================================================================ +// Promise resolution tests +// ============================================================================ + +test("DebouncedMutation - all pending promises resolve with same result", async () => { + const { client } = createTestClient(); + testStore.clear(); + testStore.set("counter", 0); + + const mutation = client.defineDebounced({ + optimistic({ helpers }, amount: number) { + helpers.increment("counter", amount); + }, + mode: "debounce", + time: 30, + key: () => "test-key", + getValue: (_) => testStore.get("counter") ?? 0, + async commit({ initial, current }) { + return { delta: current - initial, timestamp: Date.now() }; + }, + describe: "increment counter", + describeResult: "Success", + async refetch() {}, + }); + + const promise1 = mutation.runAndReturn(1); + const promise2 = mutation.runAndReturn(2); + const promise3 = mutation.runAndReturn(3); + + const [result1, result2, result3] = await Promise.all([ + promise1, + promise2, + promise3, + ]); + + // All should get the same result object + assertEquals(result1, result2); + assertEquals(result2, result3); + assertEquals(result1.delta, 6); +}); + +test("DebouncedMutation - all pending promises reject with same error", async () => { + const { client } = createTestClient(); + testStore.clear(); + testStore.set("counter", 0); + + const mutation = client.defineDebounced({ + optimistic({ helpers }, amount: number) { + helpers.increment("counter", amount); + }, + mode: "debounce", + time: 30, + key: () => "test-key", + getValue: (_) => testStore.get("counter") ?? 0, + async commit() { + throw new Error("batch commit failed"); + }, + describe: "increment counter", + describeResult: "Success", + async refetch() {}, + }); + + const promise1 = mutation.runAndReturn(1); + const promise2 = mutation.runAndReturn(2); + const promise3 = mutation.runAndReturn(3); + + const errors: Error[] = []; + await Promise.all([ + promise1.catch((e) => errors.push(e)), + promise2.catch((e) => errors.push(e)), + promise3.catch((e) => errors.push(e)), + ]); + + // All should get the same error + assertEquals(errors.length, 3); + assertEquals(errors[0].message, "batch commit failed"); + assertEquals(errors[1].message, "batch commit failed"); + assertEquals(errors[2].message, "batch commit failed"); +}); + +// ============================================================================ +// Edge case tests +// ============================================================================ + +test("DebouncedMutation - handles empty getValue result", async () => { + const { client } = createTestClient(); + testStore.clear(); + + let commitCallCount = 0; + + const mutation = client.defineDebounced({ + optimistic({ helpers }, amount: number) { + helpers.setValue("nonexistent", amount); + }, + mode: "debounce", + time: 20, + key: () => "test-key", + getValue: (_) => testStore.get("nonexistent"), + async commit({ initial, current }) { + commitCallCount++; + return { initial, current }; + }, + describe: "test mutation", + describeResult: "Success", + }); + + const result = await mutation.runAndReturn(5); + await delay(30); + + assertEquals(commitCallCount, 1); + assertEquals(result.initial, undefined); + assertEquals(result.current, 5); +}); + +test("DebouncedMutation - channel cleanup after idle with no listeners", async () => { + const { client } = createTestClient(); + testStore.clear(); + testStore.set("counter", 0); + + const mutation = client.defineDebounced({ + optimistic({ helpers }, amount: number) { + helpers.increment("counter", amount); + }, + mode: "debounce", + time: 20, + key: () => "test-key", + getValue: (_) => testStore.get("counter") ?? 0, + async commit({ initial, current }) { + return current - initial; + }, + describe: "test mutation", + describeResult: "Success", + async refetch() {}, + }); + + // Run mutation without subscribing + await mutation.runAndReturn(5); + await delay(30); + + // Run another mutation - should work fine (channel recreated if needed) + const result = await mutation.runAndReturn(3); + await delay(30); + + assertEquals(result, 3); + assertEquals(testStore.get("counter"), 8); +}); + +test("DebouncedMutation - default time is 200ms", async () => { + const { client } = createTestClient(); + testStore.clear(); + testStore.set("counter", 0); + + let commitTime: number | null = null; + const startTime = Date.now(); + + const mutation = client.defineDebounced({ + optimistic({ helpers }, amount: number) { + helpers.increment("counter", amount); + }, + mode: "debounce", + // time not specified, should default to 200 + key: () => "test-key", + getValue: (_) => testStore.get("counter") ?? 0, + async commit({ initial, current }) { + commitTime = Date.now() - startTime; + return current - initial; + }, + describe: "test mutation", + describeResult: "Success", + async refetch() {}, + }); + + await mutation.runAndReturn(5); + + // Should commit after ~200ms (with some tolerance) + assertEquals(commitTime !== null, true); + assertEquals(commitTime! >= 180, true); + assertEquals(commitTime! <= 250, true); +}); + +test("DebouncedMutation - context is passed to getValue", async () => { + const { client } = createTestClient(); + testStore.clear(); + testStore.set("counter", 0); + + let receivedUserId: string | undefined; + + const mutation = client.defineDebounced({ + optimistic({ helpers }, amount: number) { + helpers.increment("counter", amount); + }, + mode: "debounce", + time: 20, + key: () => "test-key", + getValue: ({ userId }) => { + receivedUserId = userId; + return testStore.get("counter") ?? 0; + }, + async commit({ initial, current }) { + return current - initial; + }, + describe: "test mutation", + describeResult: "Success", + async refetch() {}, + }); + + await mutation.runAndReturn(5); + await delay(30); + + assertEquals(receivedUserId, "test-user"); +}); + +test("DebouncedMutation - context is passed to commit", async () => { + const { client } = createTestClient(); + testStore.clear(); + testStore.set("counter", 0); + + let receivedUserId: string | undefined; + + const mutation = client.defineDebounced({ + optimistic({ helpers }, amount: number) { + helpers.increment("counter", amount); + }, + mode: "debounce", + time: 20, + key: () => "test-key", + getValue: (_) => testStore.get("counter") ?? 0, + async commit({ userId, initial, current }) { + receivedUserId = userId; + return current - initial; + }, + describe: "test mutation", + describeResult: "Success", + async refetch() {}, + }); + + await mutation.runAndReturn(5); + await delay(30); + + assertEquals(receivedUserId, "test-user"); +}); + +test("DebouncedMutation - first args are used for commit", async () => { + const { client } = createTestClient(); + testStore.clear(); + testStore.set("counter", 0); + + let receivedArgs: [string, number] | undefined; + + const mutation = client.defineDebounced({ + optimistic({ helpers }, _label: string, amount: number) { + helpers.increment("counter", amount); + }, + mode: "debounce", + time: 30, + key: () => "test-key", + getValue: (_) => testStore.get("counter") ?? 0, + async commit({ args, initial, current }) { + receivedArgs = args; + return current - initial; + }, + describe: "test mutation", + describeResult: "Success", + async refetch() {}, + }); + + mutation.runAndReturn("first", 1); + mutation.runAndReturn("second", 2); + await mutation.runAndReturn("third", 3); + await delay(10); + + // Should use first args + assertEquals(receivedArgs, ["first", 1]); +}); diff --git a/test/object-path-types.test.ts b/test/object-path-types.test.ts deleted file mode 100644 index e6fa6989ee5a8615949999f224bf996ded32061e..0000000000000000000000000000000000000000 --- a/test/object-path-types.test.ts +++ /dev/null @@ -1,407 +0,0 @@ -/** - * Type-level tests for object-path system - * These tests verify that TypeScript types work correctly at compile time - */ - -import type { AllObjectPaths, GetObjectPath } from "../src/object-path.ts"; - -// Type testing utilities -type Expect = T; -type Equal = (() => T extends X ? 1 : 2) extends - () => T extends Y ? 1 - : 2 ? true - : false; -type NotEqual = Equal extends true ? false : true; -type IsAny = 0 extends 1 & T ? true : false; -type NotAny = IsAny extends true ? false : true; - -// Test interface -interface TestData { - name: string; - count: number; - active: boolean; - settings: { - theme: string; - notifications: boolean; - }; - items: Array<{ id: number; label: string }>; - tags: string[]; - nested: { - deep: { - value: boolean; - config: { - enabled: true; - }; - }; - }; -} - -// ============================================================================ -// AllObjectPaths tests -// ============================================================================ - -// Should allow top-level paths -type TestPath1 = Expect< - Equal<["name"], Extract, ["name"]>> ->; -type TestPath2 = Expect< - Equal<["count"], Extract, ["count"]>> ->; - -// Should allow nested paths -type TestPath3 = Expect< - Equal< - ["settings", "theme"], - Extract, ["settings", "theme"]> - > ->; - -// Should allow deep nested paths -type TestPath4 = Expect< - Equal< - ["nested", "deep", "value"], - Extract, ["nested", "deep", "value"]> - > ->; - -// Should allow array index access -type TestPath5 = Expect< - Equal<[number], Extract, [number]>> ->; - -// Should allow array element property access -type TestPath6 = Expect< - Equal< - ["items", number, "id"], - Extract, ["items", number, "id"]> - > ->; - -// Should allow empty path for nested objects -type TestPath7 = Expect< - Equal<[], Extract, []>> ->; - -// ============================================================================ -// GetObjectPath tests -// ============================================================================ - -// Top-level property access -type GetTest1 = Expect, string>>; -type GetTest2 = Expect, number>>; -type GetTest3 = Expect, boolean>>; - -// Nested property access -type GetTest4 = Expect< - Equal< - GetObjectPath, - string - > ->; -type GetTest5 = Expect< - Equal< - GetObjectPath, - boolean - > ->; - -// Deep nested access -type GetTest6 = Expect< - Equal< - GetObjectPath, - boolean - > ->; -type GetTest7 = Expect< - Equal< - GetObjectPath, - true - > ->; - -// Array access -type GetTest8 = Expect< - Equal< - GetObjectPath, - string[] - > ->; -type GetTest9 = Expect< - Equal< - GetObjectPath, - string - > ->; -type GetTest10 = Expect< - Equal< - GetObjectPath, - Array<{ id: number; label: string }> - > ->; -type GetTest11 = Expect< - Equal< - GetObjectPath, - { id: number; label: string } - > ->; -type GetTest12 = Expect< - Equal< - GetObjectPath, - number - > ->; -type GetTest13 = Expect< - Equal< - GetObjectPath, - string - > ->; - -// Object access -type GetTest14 = Expect< - Equal< - GetObjectPath, - { theme: string; notifications: boolean } - > ->; - -// Empty path returns the whole object -type GetTest15 = Expect, TestData>>; - -// ============================================================================ -// Array element type extraction tests -// ============================================================================ - -type ArrayElement = T extends readonly (infer U)[] ? U : never; - -type ArrayTest1 = Expect, string>>; -type ArrayTest2 = Expect, number>>; -type ArrayTest3 = Expect< - Equal>, { id: number }> ->; -type ArrayTest4 = Expect< - Equal< - ArrayElement>, - { id: number; label: string } - > ->; -type ArrayTest5 = Expect< - Equal>, string> ->; - -// ============================================================================ -// Conditional type tests for helper functions -// ============================================================================ - -// Test that we can extract array element types from paths -type ExtractArrayElement< - Data extends object, - Path extends AllObjectPaths, -> = GetObjectPath extends readonly (infer T)[] ? T : never; - -type ElementTest1 = Expect< - Equal< - ExtractArrayElement, - { id: number; label: string } - > ->; -type ElementTest2 = Expect< - Equal, string> ->; - -// Test that non-array paths return never -type ElementTest3 = Expect< - Equal, never> ->; - -// Test number extraction -type IsNumber< - Data extends object, - Path extends AllObjectPaths, -> = GetObjectPath extends number ? true : false; - -type NumberTest1 = Expect, true>>; -type NumberTest2 = Expect, false>>; - -// Test boolean extraction -type IsBoolean< - Data extends object, - Path extends AllObjectPaths, -> = GetObjectPath extends boolean ? true : false; - -type BooleanTest1 = Expect, true>>; -type BooleanTest2 = Expect, false>>; - -// Test object extraction -type IsObject< - Data extends object, - Path extends AllObjectPaths, -> = GetObjectPath extends object ? true : false; - -type ObjectTest1 = Expect, true>>; -type ObjectTest2 = Expect, true>>; -type ObjectTest3 = Expect, false>>; - -// ============================================================================ -// Edge cases -// ============================================================================ - -// Readonly arrays should work -interface ReadonlyData { - readonly items: readonly { id: number }[]; -} - -type ReadonlyTest1 = Expect< - Equal< - GetObjectPath, - readonly { id: number }[] - > ->; -type ReadonlyTest2 = Expect< - Equal< - GetObjectPath, - { id: number } - > ->; -type ReadonlyTest3 = Expect< - Equal< - GetObjectPath, - number - > ->; - -// Optional properties -interface OptionalData { - required: string; - optional?: number; - nested?: { - value: boolean; - }; -} - -type OptionalTest1 = Expect< - Equal, string> ->; -type OptionalTest2 = Expect< - Equal, number | undefined> ->; - -// Union types -interface UnionData { - value: string | number; - items: Array<{ type: "a"; a: string } | { type: "b"; b: number }>; -} - -type UnionTest1 = Expect< - Equal, string | number> ->; - -// ============================================================================ -// Real-world usage simulation -// ============================================================================ - -// Simulate the actual helper function signatures -type ObjSetSignature< - Data extends object, - Path extends AllObjectPaths, -> = ( - path: Path, - value: - | Exclude, Function> - | ((prev: GetObjectPath) => GetObjectPath), -) => void; - -// This should accept string or function -declare const objSetName: ObjSetSignature; -objSetName(["name"], "test"); -objSetName(["name"], (prev) => prev.toUpperCase()); - -// This should accept number or function -declare const objSetCount: ObjSetSignature; -objSetCount(["count"], 42); -objSetCount(["count"], (n) => n + 1); - -// Array push signature -type ArrayPushSignature< - Data extends object, - Path extends AllObjectPaths, -> = GetObjectPath extends readonly (infer T)[] - ? (path: Path, ...items: T[]) => void - : never; - -// This should accept individual items, not arrays -declare const arrayPushItems: ArrayPushSignature; -arrayPushItems( - ["items"], - { id: 1, label: "first" }, - { id: 2, label: "second" }, -); - -declare const arrayPushTags: ArrayPushSignature; -arrayPushTags(["tags"], "alpha", "beta", "gamma"); - -// Array remove signature -type ArrayRemoveSignature< - Data extends object, - Path extends AllObjectPaths, -> = GetObjectPath extends readonly (infer T)[] - ? (path: Path, filter: (item: T, index: number) => boolean) => void - : never; - -declare const arrayRemoveItems: ArrayRemoveSignature; -arrayRemoveItems(["items"], (item) => item.id === 1); -arrayRemoveItems(["items"], (item, index) => index === 0); - -declare const arrayRemoveTags: ArrayRemoveSignature; -arrayRemoveTags(["tags"], (tag) => tag === "alpha"); - -// Increment signature -type IncrementSignature< - Data extends object, - Path extends AllObjectPaths, -> = GetObjectPath extends number - ? (path: Path, amount?: number) => void - : never; - -declare const increment: IncrementSignature; -increment(["count"]); -increment(["count"], 5); - -// Should not work on non-numbers (type should be never) -type IncrementNameTest = Expect< - Equal, never> ->; - -// Toggle signature -type ToggleSignature< - Data extends object, - Path extends AllObjectPaths, -> = GetObjectPath extends boolean ? (path: Path) => void - : never; - -declare const toggle: ToggleSignature; -toggle(["active"]); - -// Should not work on non-booleans (type should be never) -type ToggleCountTest = Expect< - Equal, never> ->; - -// ============================================================================ -// Verify no `any` types leaked through -// ============================================================================ - -type NoAnyTest1 = Expect>>; -type NoAnyTest2 = Expect>>; -type NoAnyTest3 = Expect>>; -type NoAnyTest4 = Expect< - NotAny> ->; - -export type { - ArrayPushSignature, - ArrayRemoveSignature, - IncrementSignature, - ObjSetSignature, - ToggleSignature, -}; diff --git a/test/object-path.types.ts b/test/object-path.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6fa6989ee5a8615949999f224bf996ded32061e --- /dev/null +++ b/test/object-path.types.ts @@ -0,0 +1,407 @@ +/** + * Type-level tests for object-path system + * These tests verify that TypeScript types work correctly at compile time + */ + +import type { AllObjectPaths, GetObjectPath } from "../src/object-path.ts"; + +// Type testing utilities +type Expect = T; +type Equal = (() => T extends X ? 1 : 2) extends + () => T extends Y ? 1 + : 2 ? true + : false; +type NotEqual = Equal extends true ? false : true; +type IsAny = 0 extends 1 & T ? true : false; +type NotAny = IsAny extends true ? false : true; + +// Test interface +interface TestData { + name: string; + count: number; + active: boolean; + settings: { + theme: string; + notifications: boolean; + }; + items: Array<{ id: number; label: string }>; + tags: string[]; + nested: { + deep: { + value: boolean; + config: { + enabled: true; + }; + }; + }; +} + +// ============================================================================ +// AllObjectPaths tests +// ============================================================================ + +// Should allow top-level paths +type TestPath1 = Expect< + Equal<["name"], Extract, ["name"]>> +>; +type TestPath2 = Expect< + Equal<["count"], Extract, ["count"]>> +>; + +// Should allow nested paths +type TestPath3 = Expect< + Equal< + ["settings", "theme"], + Extract, ["settings", "theme"]> + > +>; + +// Should allow deep nested paths +type TestPath4 = Expect< + Equal< + ["nested", "deep", "value"], + Extract, ["nested", "deep", "value"]> + > +>; + +// Should allow array index access +type TestPath5 = Expect< + Equal<[number], Extract, [number]>> +>; + +// Should allow array element property access +type TestPath6 = Expect< + Equal< + ["items", number, "id"], + Extract, ["items", number, "id"]> + > +>; + +// Should allow empty path for nested objects +type TestPath7 = Expect< + Equal<[], Extract, []>> +>; + +// ============================================================================ +// GetObjectPath tests +// ============================================================================ + +// Top-level property access +type GetTest1 = Expect, string>>; +type GetTest2 = Expect, number>>; +type GetTest3 = Expect, boolean>>; + +// Nested property access +type GetTest4 = Expect< + Equal< + GetObjectPath, + string + > +>; +type GetTest5 = Expect< + Equal< + GetObjectPath, + boolean + > +>; + +// Deep nested access +type GetTest6 = Expect< + Equal< + GetObjectPath, + boolean + > +>; +type GetTest7 = Expect< + Equal< + GetObjectPath, + true + > +>; + +// Array access +type GetTest8 = Expect< + Equal< + GetObjectPath, + string[] + > +>; +type GetTest9 = Expect< + Equal< + GetObjectPath, + string + > +>; +type GetTest10 = Expect< + Equal< + GetObjectPath, + Array<{ id: number; label: string }> + > +>; +type GetTest11 = Expect< + Equal< + GetObjectPath, + { id: number; label: string } + > +>; +type GetTest12 = Expect< + Equal< + GetObjectPath, + number + > +>; +type GetTest13 = Expect< + Equal< + GetObjectPath, + string + > +>; + +// Object access +type GetTest14 = Expect< + Equal< + GetObjectPath, + { theme: string; notifications: boolean } + > +>; + +// Empty path returns the whole object +type GetTest15 = Expect, TestData>>; + +// ============================================================================ +// Array element type extraction tests +// ============================================================================ + +type ArrayElement = T extends readonly (infer U)[] ? U : never; + +type ArrayTest1 = Expect, string>>; +type ArrayTest2 = Expect, number>>; +type ArrayTest3 = Expect< + Equal>, { id: number }> +>; +type ArrayTest4 = Expect< + Equal< + ArrayElement>, + { id: number; label: string } + > +>; +type ArrayTest5 = Expect< + Equal>, string> +>; + +// ============================================================================ +// Conditional type tests for helper functions +// ============================================================================ + +// Test that we can extract array element types from paths +type ExtractArrayElement< + Data extends object, + Path extends AllObjectPaths, +> = GetObjectPath extends readonly (infer T)[] ? T : never; + +type ElementTest1 = Expect< + Equal< + ExtractArrayElement, + { id: number; label: string } + > +>; +type ElementTest2 = Expect< + Equal, string> +>; + +// Test that non-array paths return never +type ElementTest3 = Expect< + Equal, never> +>; + +// Test number extraction +type IsNumber< + Data extends object, + Path extends AllObjectPaths, +> = GetObjectPath extends number ? true : false; + +type NumberTest1 = Expect, true>>; +type NumberTest2 = Expect, false>>; + +// Test boolean extraction +type IsBoolean< + Data extends object, + Path extends AllObjectPaths, +> = GetObjectPath extends boolean ? true : false; + +type BooleanTest1 = Expect, true>>; +type BooleanTest2 = Expect, false>>; + +// Test object extraction +type IsObject< + Data extends object, + Path extends AllObjectPaths, +> = GetObjectPath extends object ? true : false; + +type ObjectTest1 = Expect, true>>; +type ObjectTest2 = Expect, true>>; +type ObjectTest3 = Expect, false>>; + +// ============================================================================ +// Edge cases +// ============================================================================ + +// Readonly arrays should work +interface ReadonlyData { + readonly items: readonly { id: number }[]; +} + +type ReadonlyTest1 = Expect< + Equal< + GetObjectPath, + readonly { id: number }[] + > +>; +type ReadonlyTest2 = Expect< + Equal< + GetObjectPath, + { id: number } + > +>; +type ReadonlyTest3 = Expect< + Equal< + GetObjectPath, + number + > +>; + +// Optional properties +interface OptionalData { + required: string; + optional?: number; + nested?: { + value: boolean; + }; +} + +type OptionalTest1 = Expect< + Equal, string> +>; +type OptionalTest2 = Expect< + Equal, number | undefined> +>; + +// Union types +interface UnionData { + value: string | number; + items: Array<{ type: "a"; a: string } | { type: "b"; b: number }>; +} + +type UnionTest1 = Expect< + Equal, string | number> +>; + +// ============================================================================ +// Real-world usage simulation +// ============================================================================ + +// Simulate the actual helper function signatures +type ObjSetSignature< + Data extends object, + Path extends AllObjectPaths, +> = ( + path: Path, + value: + | Exclude, Function> + | ((prev: GetObjectPath) => GetObjectPath), +) => void; + +// This should accept string or function +declare const objSetName: ObjSetSignature; +objSetName(["name"], "test"); +objSetName(["name"], (prev) => prev.toUpperCase()); + +// This should accept number or function +declare const objSetCount: ObjSetSignature; +objSetCount(["count"], 42); +objSetCount(["count"], (n) => n + 1); + +// Array push signature +type ArrayPushSignature< + Data extends object, + Path extends AllObjectPaths, +> = GetObjectPath extends readonly (infer T)[] + ? (path: Path, ...items: T[]) => void + : never; + +// This should accept individual items, not arrays +declare const arrayPushItems: ArrayPushSignature; +arrayPushItems( + ["items"], + { id: 1, label: "first" }, + { id: 2, label: "second" }, +); + +declare const arrayPushTags: ArrayPushSignature; +arrayPushTags(["tags"], "alpha", "beta", "gamma"); + +// Array remove signature +type ArrayRemoveSignature< + Data extends object, + Path extends AllObjectPaths, +> = GetObjectPath extends readonly (infer T)[] + ? (path: Path, filter: (item: T, index: number) => boolean) => void + : never; + +declare const arrayRemoveItems: ArrayRemoveSignature; +arrayRemoveItems(["items"], (item) => item.id === 1); +arrayRemoveItems(["items"], (item, index) => index === 0); + +declare const arrayRemoveTags: ArrayRemoveSignature; +arrayRemoveTags(["tags"], (tag) => tag === "alpha"); + +// Increment signature +type IncrementSignature< + Data extends object, + Path extends AllObjectPaths, +> = GetObjectPath extends number + ? (path: Path, amount?: number) => void + : never; + +declare const increment: IncrementSignature; +increment(["count"]); +increment(["count"], 5); + +// Should not work on non-numbers (type should be never) +type IncrementNameTest = Expect< + Equal, never> +>; + +// Toggle signature +type ToggleSignature< + Data extends object, + Path extends AllObjectPaths, +> = GetObjectPath extends boolean ? (path: Path) => void + : never; + +declare const toggle: ToggleSignature; +toggle(["active"]); + +// Should not work on non-booleans (type should be never) +type ToggleCountTest = Expect< + Equal, never> +>; + +// ============================================================================ +// Verify no `any` types leaked through +// ============================================================================ + +type NoAnyTest1 = Expect>>; +type NoAnyTest2 = Expect>>; +type NoAnyTest3 = Expect>>; +type NoAnyTest4 = Expect< + NotAny> +>; + +export type { + ArrayPushSignature, + ArrayRemoveSignature, + IncrementSignature, + ObjSetSignature, + ToggleSignature, +}; diff --git a/test/queued.test.ts b/test/queued.test.ts deleted file mode 100644 index cc0f9dfee237302e0a90d14d67a00e72b2ee2e2e..0000000000000000000000000000000000000000 --- a/test/queued.test.ts +++ /dev/null @@ -1,967 +0,0 @@ -import { assertEquals, assertRejects } from "@std/assert"; -import { MutationClient } from "../src/client.ts"; -import type { MutationEvent } from "../src/types.ts"; -import { test } from "vitest"; - -// Helper to create a test mutation client -function createTestClient() { - const errors: unknown[] = []; - const client = new MutationClient({ - context: { userId: "test-user" }, - getOptimisticHelpers({ onRestore }) { - return { - setValue(key: string, value: string) { - testStore.set(key, value); - onRestore(() => testStore.delete(key)); - }, - }; - }, - reportError(message, error) { - errors.push(error); - }, - }); - - return { client, errors }; -} - -const testStore = new Map(); - -// Helper to track mutation events -function createEventTracker() { - const events: Array> = []; - const callback = (event: MutationEvent) => { - events.push(event); - }; - return { events, callback }; -} - -// Helper to wait for async operations -function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -test("QueuedMutation - basic mutation success", async () => { - const { client } = createTestClient(); - let mutateCallCount = 0; - let refetchCallCount = 0; - - const mutation = client.defineBlocking({ - async mutate(value: string) { - mutateCallCount++; - await delay(10); - return `result-${value}`; - }, - describe: "test mutation", - optimistic() { - // Empty optimistic update - }, - async refetch() { - refetchCallCount++; - await delay(5); - }, - }); - - const result = await mutation.runAndReturn("test"); - // Wait for refetch to complete - await delay(20); - - assertEquals(result, "result-test"); - assertEquals(mutateCallCount, 1); - assertEquals(refetchCallCount, 1); -}); - -test("QueuedMutation - run() catches errors", async () => { - const { client, errors } = createTestClient(); - - const mutation = client.defineBlocking({ - async mutate(_value: string) { - throw new Error("mutation failed"); - }, - describe: "failing mutation", - optimistic() {}, - async refetch() {}, - }); - - mutation.run("test"); - await delay(50); - - assertEquals(errors.length, 1); - assertEquals((errors[0] as Error).message, "mutation failed"); -}); - -test("QueuedMutation - runAndReturn() rejects on error", async () => { - const { client } = createTestClient(); - - const mutation = client.defineBlocking({ - async mutate(_value: string) { - throw new Error("mutation failed"); - }, - describe: "failing mutation", - optimistic() {}, - async refetch() {}, - }); - - await assertRejects( - () => mutation.runAndReturn("test"), - Error, - "mutation failed", - ); -}); - -test("QueuedMutation - optimistic updates are applied immediately", async () => { - const { client } = createTestClient(); - testStore.clear(); - - const mutation = client.defineBlocking({ - async mutate(_key: string, value: string) { - await delay(50); - return value; - }, - describe: "set value", - optimistic({ args, helpers }) { - const [key, value] = args; - helpers.setValue(key, value); - }, - async refetch() {}, - }); - - const promise = mutation.runAndReturn("key1", "value1"); - - // Optimistic update should be applied synchronously - assertEquals(testStore.get("key1"), "value1"); - - // Wait for mutation to complete - await promise; - await delay(10); -}); - -test("QueuedMutation - rollback on error", async () => { - const { client } = createTestClient(); - testStore.clear(); - - const mutation = client.defineBlocking({ - async mutate(_key: string, _value: string) { - await delay(10); - throw new Error("mutation failed"); - }, - describe: "failing mutation", - optimistic({ args, helpers }) { - const [key, value] = args; - helpers.setValue(key, value); - }, - async refetch() {}, - }); - - await assertRejects(() => mutation.runAndReturn("key1", "value1")); - - // Optimistic update should be rolled back - assertEquals(testStore.has("key1"), false); -}); - -test("QueuedMutation - onSuccess callback is called", async () => { - const { client } = createTestClient(); - const successResults: string[] = []; - - const mutation = client.defineBlocking({ - async mutate(value: string) { - return `result-${value}`; - }, - describe: "test mutation", - optimistic({ onSuccess }) { - onSuccess((result) => { - successResults.push(result); - }); - }, - async refetch() {}, - }); - - await mutation.runAndReturn("test"); - - assertEquals(successResults, ["result-test"]); -}); - -test("QueuedMutation - mutations with same key execute serially", async () => { - const { client } = createTestClient(); - const executionOrder: string[] = []; - - const mutation = client.defineBlocking({ - async mutate(id: string) { - executionOrder.push(`start-${id}`); - await delay(20); - executionOrder.push(`end-${id}`); - return id; - }, - describe: "test mutation", - optimistic() {}, - async refetch() {}, - refetchOnSuccess: false, - key() { - return "same-key"; - }, - }); - - // Start two mutations with the same key - const promise1 = mutation.runAndReturn("1"); - const promise2 = mutation.runAndReturn("2"); - - await Promise.all([promise1, promise2]); - await delay(10); - - // They should execute serially, not in parallel - assertEquals(executionOrder, ["start-1", "end-1", "start-2", "end-2"]); -}); - -test("QueuedMutation - mutations with different keys execute in parallel", async () => { - const { client } = createTestClient(); - const executionOrder: string[] = []; - - const mutation = client.defineBlocking({ - async mutate(id: string) { - executionOrder.push(`start-${id}`); - await delay(20); - executionOrder.push(`end-${id}`); - return id; - }, - describe: "test mutation", - optimistic() {}, - async refetch() {}, - key({ args }) { - const [id] = args; - return id; - }, - }); - - // Start two mutations with different keys - const promise1 = mutation.runAndReturn("key1"); - const promise2 = mutation.runAndReturn("key2"); - - await Promise.all([promise1, promise2]); - - // They should start in parallel - assertEquals(executionOrder.slice(0, 2).sort(), ["start-key1", "start-key2"]); -}); - -test("QueuedMutation - key() returns JSON stringified key", () => { - const { client } = createTestClient(); - - const mutation = client.defineBlocking({ - async mutate(id: string) { - return id; - }, - describe: "test mutation", - optimistic() {}, - async refetch() {}, - key({ args }) { - const [id] = args; - return id; - }, - }); - - assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id")); -}); - -test("QueuedMutation - key() defaults to 'shared' when no key function", () => { - const { client } = createTestClient(); - - const mutation = client.defineBlocking({ - async mutate(id: string) { - return id; - }, - describe: "test mutation", - optimistic() {}, - async refetch() {}, - }); - - assertEquals(mutation.key(["test-id"]), JSON.stringify("shared")); -}); - -test("QueuedMutation - key() can return array", () => { - const { client } = createTestClient(); - - const mutation = client.defineBlocking({ - async mutate(_userId: string, _itemId: string) { - return "result"; - }, - describe: "test mutation", - optimistic() {}, - async refetch() {}, - key({ args }) { - const [userId, itemId] = args; - return [userId, itemId]; - }, - }); - - assertEquals( - mutation.key(["user1", "item1"]), - JSON.stringify(["user1", "item1"]), - ); -}); - -test("QueuedMutation - describe() with string", () => { - const { client } = createTestClient(); - - const mutation = client.defineBlocking({ - async mutate(value: string) { - return value; - }, - describe: "create item", - optimistic() {}, - async refetch() {}, - }); - - assertEquals(mutation.describe("test"), "create item"); -}); - -test("QueuedMutation - describe() with function", () => { - const { client } = createTestClient(); - - const mutation = client.defineBlocking({ - async mutate(id: string) { - return id; - }, - describe({ args }) { - const [id] = args; - return `delete item ${id}`; - }, - optimistic() {}, - async refetch() {}, - }); - - assertEquals(mutation.describe("123"), "delete item 123"); -}); - -test("QueuedMutation - describe() receives context", () => { - const { client } = createTestClient(); - - const mutation = client.defineBlocking({ - async mutate(id: string) { - return id; - }, - describe({ userId, args }) { - const [id] = args; - return `user ${userId} editing item ${id}`; - }, - optimistic() {}, - async refetch() {}, - }); - - assertEquals( - mutation.describe("123"), - "user test-user editing item 123", - ); -}); - -test("QueuedMutation - subscribe() tracks mutation events", async () => { - const { client } = createTestClient(); - const tracker = createEventTracker(); - - const mutation = client.defineBlocking({ - async mutate(value: string) { - await delay(10); - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - async refetch() { - await delay(5); - }, - }); - - const key = mutation.key(["test"]); - mutation.subscribe(key, tracker.callback); - - await mutation.runAndReturn("test"); - // Wait for refetch to complete - await delay(20); - - // Should have received status updates - assertEquals(tracker.events.length >= 2, true); - assertEquals(tracker.events.some((e) => e.status === "mutating"), true); - assertEquals(tracker.events.some((e) => e.status === "refetching"), true); -}); - -test("QueuedMutation - unsubscribe stops receiving events", async () => { - const { client } = createTestClient(); - const tracker = createEventTracker(); - - const mutation = client.defineBlocking({ - async mutate(value: string) { - await delay(10); - return value; - }, - describe: "test mutation", - optimistic() {}, - async refetch() {}, - refetchOnSuccess: false, - }); - - const key = mutation.key(["test"]); - const unsubscribe = mutation.subscribe(key, tracker.callback); - - unsubscribe(); - - await mutation.runAndReturn("test"); - await delay(10); - - // Should not have received any events - assertEquals(tracker.events.length, 0); -}); - -test("QueuedMutation - refetchOnSuccess can be disabled", async () => { - const { client } = createTestClient(); - let refetchCallCount = 0; - - const mutation = client.defineBlocking({ - async mutate(_value: string) { - return _value; - }, - describe: "test mutation", - optimistic() {}, - async refetch() { - refetchCallCount++; - }, - refetchOnSuccess: false, - }); - - await mutation.runAndReturn("test"); - - assertEquals(refetchCallCount, 0); -}); - -test("QueuedMutation - refetch is called on error", async () => { - const { client } = createTestClient(); - let refetchCallCount = 0; - - const mutation = client.defineBlocking({ - async mutate(_value: string) { - throw new Error("mutation failed"); - }, - describe: "failing mutation", - optimistic() {}, - async refetch() { - refetchCallCount++; - }, - }); - - await assertRejects(() => mutation.runAndReturn("test")); - - assertEquals(refetchCallCount, 1); -}); - -test("QueuedMutation - queued mutations are cancelled on error", async () => { - const { client } = createTestClient(); - const executionOrder: string[] = []; - - const mutation = client.defineBlocking({ - async mutate(id: string) { - executionOrder.push(`start-${id}`); - await delay(10); - if (id === "1") { - throw new Error("first mutation failed"); - } - executionOrder.push(`end-${id}`); - return id; - }, - describe: "test mutation", - optimistic() {}, - async refetch() {}, - key() { - return "same-key"; - }, - }); - - const promise1 = mutation.runAndReturn("1"); - const promise2 = mutation.runAndReturn("2"); - const promise3 = mutation.runAndReturn("3"); - - await assertRejects(() => promise1, Error, "first mutation failed"); - await assertRejects(() => promise2, Error, "first mutation failed"); - await assertRejects(() => promise3, Error, "first mutation failed"); - - // Only the first mutation should start - assertEquals(executionOrder, ["start-1"]); -}); - -test("QueuedMutation - rollbacks are called in reverse order on error", async () => { - const { client } = createTestClient(); - const rollbackOrder: number[] = []; - - const mutation = client.defineBlocking({ - async mutate(_value: string) { - throw new Error("mutation failed"); - }, - describe: "failing mutation", - optimistic({ onRestore }) { - onRestore(() => rollbackOrder.push(1)); - onRestore(() => rollbackOrder.push(2)); - onRestore(() => rollbackOrder.push(3)); - }, - async refetch() {}, - }); - - await assertRejects(() => mutation.runAndReturn("test")); - - // Rollbacks should be called in reverse order - assertEquals(rollbackOrder, [3, 2, 1]); -}); - -test("QueuedMutation - multiple mutations: rollbacks only affect failed mutation", async () => { - const { client } = createTestClient(); - const rollbackOrder: string[] = []; - - const mutation = client.defineBlocking({ - async mutate(id: string) { - await delay(10); - if (id === "fail") { - throw new Error("mutation failed"); - } - return id; - }, - describe: "test mutation", - optimistic({ args: [id], onRestore }) { - onRestore(() => rollbackOrder.push(`rollback-${id}`)); - }, - async refetch() {}, - key() { - return "same-key"; - }, - }); - - // First mutation succeeds - await mutation.runAndReturn("success"); - - // Second mutation fails - await assertRejects(() => mutation.runAndReturn("fail")); - - // Only the failed mutation's rollback should be called - // And all rollbacks from queued items - assertEquals(rollbackOrder, ["rollback-fail"]); -}); - -test("QueuedMutation - onRestore throws error if called after optimistic phase", async () => { - const { client } = createTestClient(); - let capturedOnRestore: ((cb: () => void) => void) | null = null; - - const mutation = client.defineBlocking({ - async mutate(_value: string) { - return "result"; - }, - describe: "test mutation", - optimistic({ onRestore }) { - capturedOnRestore = onRestore; - }, - async refetch() {}, - }); - - await mutation.runAndReturn("test"); - - // Calling onRestore after the optimistic phase should throw - let error: Error | null = null; - try { - capturedOnRestore!(() => {}); - } catch (e) { - error = e as Error; - } - - assertEquals( - error?.message, - "Can only call onRestore from within the optimistic update function.", - ); -}); - -test("QueuedMutation - onSuccess throws error if called after optimistic phase", async () => { - const { client } = createTestClient(); - let capturedOnSuccess: ((cb: (result: string) => void) => void) | null = null; - - const mutation = client.defineBlocking({ - async mutate(_value: string) { - return "result"; - }, - describe: "test mutation", - optimistic({ onSuccess }) { - capturedOnSuccess = onSuccess; - }, - async refetch() {}, - }); - - await mutation.runAndReturn("test"); - - // Calling onSuccess after the optimistic phase should throw - let error: Error | null = null; - try { - capturedOnSuccess!(() => {}); - } catch (e) { - error = e as Error; - } - - assertEquals( - error?.message, - "Can only call onSuccess from within the optimistic update function.", - ); -}); - -test("QueuedMutation - error during optimistic update is rejected immediately", async () => { - const { client } = createTestClient(); - - const mutation = client.defineBlocking({ - async mutate(_value: string) { - return "result"; - }, - describe: "test mutation", - optimistic() { - throw new Error("optimistic update failed"); - }, - async refetch() {}, - }); - - await assertRejects( - () => mutation.runAndReturn("test"), - Error, - "optimistic update failed", - ); -}); - -test("QueuedMutation - error during optimistic update rolls back registered callbacks", async () => { - const { client } = createTestClient(); - const rollbackOrder: number[] = []; - - const mutation = client.defineBlocking({ - async mutate(_value: string) { - return "result"; - }, - describe: "test mutation", - optimistic({ onRestore }) { - onRestore(() => rollbackOrder.push(1)); - onRestore(() => rollbackOrder.push(2)); - throw new Error("optimistic update failed"); - }, - async refetch() {}, - }); - - await assertRejects(() => mutation.runAndReturn("test")); - - // Rollbacks should be called even though optimistic update failed - // Note: during optimistic error, rollbacks are executed in the order they were added - assertEquals(rollbackOrder, [1, 2]); -}); - -test("QueuedMutation - refetch errors are reported but don't fail mutation", async () => { - const { client, errors } = createTestClient(); - - const mutation = client.defineBlocking({ - async mutate(value: string) { - return value; - }, - describe: "test mutation", - optimistic() {}, - async refetch() { - throw new Error("refetch failed"); - }, - }); - - // Mutation should still succeed - const result = await mutation.runAndReturn("test"); - assertEquals(result, "test"); - - // But refetch error should be reported - await delay(20); - assertEquals(errors.length, 1); - assertEquals((errors[0] as Error).message, "refetch failed"); -}); - -test("QueuedMutation - optimistic function receives args and helpers", async () => { - const { client } = createTestClient(); - let receivedArgs: unknown[] | undefined; - let receivedHelpers: unknown | undefined; - - const mutation = client.defineBlocking({ - async mutate(_value: string) { - return "result"; - }, - describe: "test mutation", - optimistic({ args, helpers }) { - receivedArgs = args; - receivedHelpers = helpers; - }, - async refetch() {}, - }); - - await mutation.runAndReturn("test"); - - assertEquals(receivedArgs, ["test"]); - assertEquals(typeof receivedHelpers, "object"); -}); - -test("QueuedMutation - refetch receives context and args", async () => { - const { client } = createTestClient(); - let receivedUserId: string | undefined; - let receivedArgs: unknown[] | undefined; - - const mutation = client.defineBlocking({ - async mutate(_id: string, value: string) { - return value; - }, - describe: "test mutation", - optimistic() {}, - async refetch({ userId, args }) { - receivedUserId = userId; - receivedArgs = args; - }, - }); - - await mutation.runAndReturn("test-id", "test-value"); - - assertEquals(receivedUserId, "test-user"); - assertEquals(receivedArgs, ["test-id", "test-value"]); -}); - -test("QueuedMutation - notifies error on mutation failure", async () => { - const { client } = createTestClient(); - const tracker = createEventTracker(); - - const mutation = client.defineBlocking({ - async mutate(_value: string) { - await delay(10); - throw new Error("mutation failed"); - }, - describe: "failing mutation", - optimistic() {}, - async refetch() {}, - }); - - const key = mutation.key(["test"]); - mutation.subscribe(key, tracker.callback); - - await assertRejects(() => mutation.runAndReturn("test")); - - // Should have error event - const errorEvents = tracker.events.filter((e) => - e.status === "mutating" && e.error - ); - assertEquals(errorEvents.length > 0, true); - assertEquals((errorEvents[0]?.error as Error).message, "mutation failed"); -}); - -test("QueuedMutation - multiple subscribers receive events", async () => { - const { client } = createTestClient(); - const tracker1 = createEventTracker(); - const tracker2 = createEventTracker(); - - const mutation = client.defineBlocking({ - async mutate(value: string) { - await delay(5); - return value; - }, - describe: "test mutation", - optimistic() {}, - async refetch() {}, - refetchOnSuccess: false, - }); - - const key = mutation.key(["test"]); - mutation.subscribe(key, tracker1.callback); - mutation.subscribe(key, tracker2.callback); - - await mutation.runAndReturn("test"); - await delay(10); - - // Both subscribers should receive events - assertEquals(tracker1.events.length, tracker2.events.length); - assertEquals(tracker1.events.length > 0, true); -}); - -test("QueuedMutation - onSuccess is called before mutation resolves", async () => { - const { client } = createTestClient(); - const callOrder: string[] = []; - - const mutation = client.defineBlocking({ - async mutate(value: string) { - return value; - }, - describe: "test mutation", - optimistic({ onSuccess }) { - onSuccess(() => { - callOrder.push("onSuccess"); - }); - }, - async refetch() {}, - refetchOnSuccess: false, - }); - - const promise = mutation.runAndReturn("test"); - promise.then(() => { - callOrder.push("then"); - }); - - await promise; - await delay(5); - - // onSuccess should be called before the promise resolves - assertEquals(callOrder, ["onSuccess", "then"]); -}); - -test("QueuedMutation - result is passed to notification on success", async () => { - const { client } = createTestClient(); - const tracker = createEventTracker(); - - const mutation = client.defineBlocking({ - async mutate(value: string) { - await delay(5); - return `result-${value}`; - }, - describe: "test mutation", - optimistic() {}, - async refetch() { - await delay(5); - }, - }); - - const key = mutation.key(["test"]); - mutation.subscribe(key, tracker.callback); - - await mutation.runAndReturn("test"); - await delay(20); - - // Should have refetching event with result - const refetchingEvents = tracker.events.filter((e) => - e.status === "refetching" - ); - assertEquals(refetchingEvents.length > 0, true); - assertEquals(refetchingEvents[0]?.result, "result-test"); -}); - -test("QueuedMutation - channel is reused for same key", async () => { - const { client } = createTestClient(); - const events: string[] = []; - - const mutation = client.defineBlocking({ - async mutate(value: string) { - events.push(`mutate-${value}`); - return value; - }, - describe: "test mutation", - optimistic() {}, - async refetch() {}, - refetchOnSuccess: false, - }); - - // First mutation - await mutation.runAndReturn("first"); - await delay(5); - - // Second mutation with same key - await mutation.runAndReturn("second"); - await delay(5); - - assertEquals(events, ["mutate-first", "mutate-second"]); -}); - -test("QueuedMutation - empty queue after all mutations complete", async () => { - const { client } = createTestClient(); - - const mutation = client.defineBlocking({ - async mutate(value: string) { - await delay(5); - return value; - }, - describe: "test mutation", - optimistic() {}, - async refetch() {}, - refetchOnSuccess: false, - key() { - return "test-key"; - }, - }); - - // Run multiple mutations - await mutation.runAndReturn("1"); - await mutation.runAndReturn("2"); - await mutation.runAndReturn("3"); - await delay(10); - - // All mutations should have completed - // (We can't directly check the queue, but we can verify by running another mutation) - const start = Date.now(); - await mutation.runAndReturn("4"); - const duration = Date.now() - start; - - // Should execute immediately, not be queued (< 10ms if not queued) - assertEquals(duration < 15, true); -}); - -test("QueuedMutation - multiple onSuccess callbacks are all called", async () => { - const { client } = createTestClient(); - const results: string[] = []; - - const mutation = client.defineBlocking({ - async mutate(value: string) { - return value; - }, - describe: "test mutation", - optimistic({ onSuccess }) { - onSuccess((result) => results.push(`first-${result}`)); - onSuccess((result) => results.push(`second-${result}`)); - onSuccess((result) => results.push(`third-${result}`)); - }, - async refetch() {}, - refetchOnSuccess: false, - }); - - await mutation.runAndReturn("test"); - - assertEquals(results, ["first-test", "second-test", "third-test"]); -}); - -test("QueuedMutation - refetchOnSuccess false skips refetch", async () => { - const { client } = createTestClient(); - let refetchCalled = false; - - const mutation = client.defineBlocking({ - async mutate(value: string) { - return value; - }, - describe: "test mutation", - optimistic() {}, - async refetch() { - refetchCalled = true; - }, - refetchOnSuccess: false, - }); - - await mutation.runAndReturn("test"); - await delay(10); - - // Refetch should not have been called - assertEquals(refetchCalled, false); -}); - -test("QueuedMutation - refetch error after mutation failure is reported", async () => { - const { client, errors } = createTestClient(); - - const mutation = client.defineBlocking({ - async mutate(_value: string) { - throw new Error("mutation failed"); - }, - describe: "failing mutation", - optimistic() {}, - async refetch() { - throw new Error("refetch also failed"); - }, - }); - - await assertRejects( - () => mutation.runAndReturn("test"), - Error, - "mutation failed", - ); - - // Wait for refetch to complete and error to be reported - await delay(20); - - // Should have both the mutation error and refetch error reported - assertEquals(errors.length >= 1, true); - assertEquals( - (errors[errors.length - 1] as Error).message, - "refetch also failed", - ); -});