| author | |
| committer | |
| log | c6028dcd497631ff2d5a5a6c24b74b41f3ad21f4 |
| tree | 2dbbc9080d6a5266291a4fccc0383e7958816f52 |
| parent | 331a3e7e7454101d1933ba3d28996ad554a0bf6a |
| signature |
17 files changed, 3914 insertions(+), 3617 deletions(-)
example/src/App.tsx+4| ... | @@ -69,6 +69,10 @@ const mutIncrement = mutationClient.defineDebounced({ | ... | @@ -69,6 +69,10 @@ const mutIncrement = mutationClient.defineDebounced({ |
| 69 | }, | 69 | }, |
| 70 | 70 | ||
| 71 | describe: "update counter", | 71 | describe: "update counter", |
| 72 | describeResult: ({ initial, current }) => { | ||
| 73 | const delta = current - initial; | ||
| 74 | return `Counter updated by ${delta > 0 ? '+' : ''}${delta}`; | ||
| 75 | }, | ||
| 72 | }); | 76 | }); |
| 73 | 77 | ||
| 74 | function CustomButton( | 78 | function CustomButton( |
jsr.json+1-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | { | 1 | { |
| 2 | "name": "@clo/react-mutation", | 2 | "name": "@clo/react-mutation", |
| 3 | "version": "1.0.0-beta.2", | 3 | "version": "1.0.0-beta.3", |
| 4 | "exports": { | 4 | "exports": { |
| 5 | ".": "./src/mod.ts", | 5 | ".": "./src/mod.ts", |
| 6 | "./tanstack-query.ts": "./src/tanstack-query.ts", | 6 | "./tanstack-query.ts": "./src/tanstack-query.ts", |
readme.md+72-4| ... | @@ -15,7 +15,7 @@ The primary gains React Mutation provides are | ... | @@ -15,7 +15,7 @@ The primary gains React Mutation provides are |
| 15 | display a UI toast. Otherwise, the component can display the error locally. | 15 | display a UI toast. Otherwise, the component can display the error locally. |
| 16 | - Optimistic helpers allow defining rollbacks and refetching logic independant | 16 | - Optimistic helpers allow defining rollbacks and refetching logic independant |
| 17 | of the actual mutation. The [built in helpers for React Query](#React-Query-Optimistic-Helpers) show this power in more detail. | 17 | of the actual mutation. The [built in helpers for React Query](#React-Query-Optimistic-Helpers) show this power in more detail. |
| 18 | - Batched Mutations are just so awesome to use. | 18 | - Debounced Mutations are just so awesome to use. |
| 19 | 19 | ||
| 20 | ## Usage | 20 | ## Usage |
| 21 | 21 | ||
| ... | @@ -79,7 +79,7 @@ const queryItemList = queryOptions({ ... }); | ... | @@ -79,7 +79,7 @@ const queryItemList = queryOptions({ ... }); |
| 79 | const queryItem = (id: string) => queryOptions({ ... }); | 79 | const queryItem = (id: string) => queryOptions({ ... }); |
| 80 | 80 | ||
| 81 | // The convention is to name handlers starting with `mut` | 81 | // The convention is to name handlers starting with `mut` |
| 82 | const mutDeleteItem = mutations.defineQueued({ | 82 | const mutDeleteItem = mutations.defineBlocking({ |
| 83 | // `mutate` comes first, is only worried about syncing with the backend. | 83 | // `mutate` comes first, is only worried about syncing with the backend. |
| 84 | async mutate(id: string) { | 84 | async mutate(id: string) { |
| 85 | const response = await fetch(`/items/${id}`, { method: "delete" }); | 85 | const response = await fetch(`/items/${id}`, { method: "delete" }); |
| ... | @@ -122,7 +122,7 @@ export function Example({ id }: { id: string }) { | ... | @@ -122,7 +122,7 @@ export function Example({ id }: { id: string }) { |
| 122 | 122 | ||
| 123 | ### Debounced Mutations | 123 | ### Debounced Mutations |
| 124 | 124 | ||
| 125 | A debounced mutation is defined with `mutations.defineBatched`. | 125 | A debounced mutation is defined with `mutations.defineDebounced`. |
| 126 | 126 | ||
| 127 | ```tsx | 127 | ```tsx |
| 128 | const mutSetItemName = mutationClient.defineDebounced({ | 128 | const mutSetItemName = mutationClient.defineDebounced({ |
| ... | @@ -178,4 +178,72 @@ function Item({ id }: { id: string }) { | ... | @@ -178,4 +178,72 @@ function Item({ id }: { id: string }) { |
| 178 | } | 178 | } |
| 179 | ``` | 179 | ``` |
| 180 | 180 | ||
| 181 | ### | 181 | ### Optimistic Updates |
| 182 | |||
| 183 | The `optimistic` function is given an object with the following APIs | ||
| 184 | |||
| 185 | - All values from `MutationClient`'s `context`, spread. With React Query this is `get` and `client`. | ||
| 186 | - `helpers` - is the return type of `getOptimisticHelpers` (see next section) | ||
| 187 | - `args` - which is the arguments passed to the mutator | ||
| 188 | - `onSuccess` - add a callback to update queries after a success | ||
| 189 | - `onRestore` - add a callback to revert your optimistic update | ||
| 190 | - `onRefetch` - add a callback to fetch data after a success | ||
| 191 | |||
| 192 | ### React Query Optimistic Helpers | ||
| 193 | |||
| 194 | When using React Query, you can opt into some incredible helpers for making it | ||
| 195 | very easy to write Optimistic Updates. Our setup at work is with this client | ||
| 196 | configuration. | ||
| 197 | |||
| 198 | ```ts | ||
| 199 | import { MutationClient } from "@clo/react-mutation"; | ||
| 200 | import { | ||
| 201 | boundQueryClientGet, | ||
| 202 | queryClientOptimisticHelpers, | ||
| 203 | } from "@clo/react-mutation/tanstack-query.ts"; | ||
| 204 | import { isServer } from "@tanstack/react-query"; | ||
| 205 | import { getQueryClient, makeNewQueryClient } from "./react-query-client"; | ||
| 206 | |||
| 207 | const client = isServer ? makeNewQueryClient() : getQueryClient(); | ||
| 208 | export const mutations = new MutationClient({ | ||
| 209 | enabled: !isServer, | ||
| 210 | context: { | ||
| 211 | client, | ||
| 212 | get: boundQueryClientGet(client), | ||
| 213 | }, | ||
| 214 | getOptimisticHelpers: queryClientOptimisticHelpers(client), | ||
| 215 | reportError(message) { | ||
| 216 | showAlert(message, "error"); | ||
| 217 | }, | ||
| 218 | reportSuccess(message: string) { | ||
| 219 | showAlert(message, "success"); | ||
| 220 | }, | ||
| 221 | }); | ||
| 222 | ``` | ||
| 223 | |||
| 224 | Within optimistic updates, a `helpers` object is provided with many useful | ||
| 225 | helper functions. All helper functions take a `QueryKeyAndFn` (return type of | ||
| 226 | TanStack Query's `queryOptions`), and will track every query touched to | ||
| 227 | automatically implement `onRefetch` and `onRestore` callbacks. The current list of them is: | ||
| 228 | |||
| 229 | - `set` - overwrite an entire query | ||
| 230 | - `updateExisting` - overwrite an entire query only if it exists | ||
| 231 | - `removeQuery` - delete a query, but restore and refetch when rolled back. | ||
| 232 | - For queries that resolve to arrays: | ||
| 233 | - `arrayPush` - add items to the end | ||
| 234 | - `arrayUnshift` - add items to the start | ||
| 235 | - `arrayRemove` - remove items by a `filter` function | ||
| 236 | - `arrayUpdate` - update items by a `filter` + `update` function | ||
| 237 | - `arrayInsertIndex` - insert an item at an index | ||
| 238 | - **experimental**: Queries that are complex options. Each function takes a type-safe | ||
| 239 | json path to evaluate, but this system has type bugs. | ||
| 240 | - `objSet` - set a property | ||
| 241 | - `objSetMany` - set many properties at once | ||
| 242 | - `objIncrement` - increment a number | ||
| 243 | - `objDecrement` - decrement a number | ||
| 244 | - `objToggle` - toggle a boolean | ||
| 245 | - `objArrayPush` - add items to the end of an array | ||
| 246 | - `objArrayUnshift` - add items to the start of an array | ||
| 247 | - `objArrayRemove` - remove items from array by `filter` | ||
| 248 | - `objArrayUpdate` - update items in array by `filter` + `update` | ||
| 249 | - `objArrayInsertIndex` - insert an item in an array at an index |
src/batch.ts deleted-486| ... | @@ -1,486 +0,0 @@ | ||
| 1 | import type { MutationClient, MutationClientFromConfig } from "./client.ts"; | ||
| 2 | import type { MutationClientConfig } from "./client.ts"; | ||
| 3 | import type { Mutation, MutationEvent } from "./types.ts"; | ||
| 4 | import { message as errMessage } from "@clo/lib/error.ts"; | ||
| 5 | |||
| 6 | export interface BatchMutationOptions< | ||
| 7 | Args extends unknown[], | ||
| 8 | Result, | ||
| 9 | Optimistic, | ||
| 10 | Config extends MutationClientConfig, | ||
| 11 | > { | ||
| 12 | /** | ||
| 13 | * Update the UI for one call to the optimistic function. | ||
| 14 | * A rest params type is used to allow type inference. Place this function first to | ||
| 15 | * ensure TypeScript correctly infers the argument type for the rest of the functions. | ||
| 16 | */ | ||
| 17 | optimistic: (context: BatchOptimisticContext<Config>, ...args: Args) => void; | ||
| 18 | /** | ||
| 19 | * Retrieve the current/optimistic value of the mutation. When this returns | ||
| 20 | * the same thing as when the mutation started, it means that `mutate` does | ||
| 21 | * not need to be called since the data is the same. | ||
| 22 | * | ||
| 23 | * Don't snapshot unrelated state that this mutation isn't concerned with. | ||
| 24 | */ | ||
| 25 | getValue: (context: Config["context"], ...args: Args) => Optimistic; | ||
| 26 | |||
| 27 | /** | ||
| 28 | * @default "debounce" | ||
| 29 | */ | ||
| 30 | mode?: "debounce" | "throttle"; | ||
| 31 | /** | ||
| 32 | * Milliseconds | ||
| 33 | * @default 200 | ||
| 34 | */ | ||
| 35 | time?: number; | ||
| 36 | |||
| 37 | /** A key to associate batch items. For example, returning a user ID */ | ||
| 38 | key: ( | ||
| 39 | context: Config["context"] & { args: NoInfer<Args> }, | ||
| 40 | ) => string | string[]; | ||
| 41 | |||
| 42 | /** | ||
| 43 | * Commit the optimistic state. Throw on failure. | ||
| 44 | */ | ||
| 45 | commit: ( | ||
| 46 | context: BatchCommitContext<NoInfer<Args>, Optimistic, Config>, | ||
| 47 | ) => Promise<Result>; | ||
| 48 | /** | ||
| 49 | * Used in error messages and debug tools. | ||
| 50 | * "Failed to {action}" | ||
| 51 | */ | ||
| 52 | describe: | ||
| 53 | | string | ||
| 54 | | (( | ||
| 55 | context: BatchCommitContext<NoInfer<Args>, Optimistic, Config>, | ||
| 56 | ) => string); | ||
| 57 | /** | ||
| 58 | * Used in success messages. | ||
| 59 | * Phrase it as a complete success message, e.g., "Renamed item successfully" | ||
| 60 | * Set to null to suppress success reporting. | ||
| 61 | */ | ||
| 62 | describeResult?: | ||
| 63 | | string | ||
| 64 | | (( | ||
| 65 | context: BatchCommitContext<NoInfer<Args>, Optimistic, Config> & { result: Result }, | ||
| 66 | ) => string) | ||
| 67 | | null; | ||
| 68 | /** | ||
| 69 | * Refetch all of the data this mutation could have affected. | ||
| 70 | */ | ||
| 71 | refetch?: () => Promise<void>; | ||
| 72 | } | ||
| 73 | |||
| 74 | export type BatchOptimisticContext<Config extends MutationClientConfig> = | ||
| 75 | & Config["context"] | ||
| 76 | & { | ||
| 77 | /** Add an event listener to roll back the update */ | ||
| 78 | onRestore: (cb: () => void) => void; | ||
| 79 | helpers: Config["optimisticHelpers"]; | ||
| 80 | }; | ||
| 81 | |||
| 82 | export type BatchCommitContext< | ||
| 83 | Args, | ||
| 84 | Optimistic, | ||
| 85 | Config extends MutationClientConfig, | ||
| 86 | > = Config["context"] & { | ||
| 87 | /** One of the arguments. Use this only to extract the shared key */ | ||
| 88 | args: Args; | ||
| 89 | /** The initial snapshot */ | ||
| 90 | initial: Optimistic; | ||
| 91 | /** The compared snapshot */ | ||
| 92 | current: Optimistic; | ||
| 93 | }; | ||
| 94 | |||
| 95 | interface BatchChannel<Args extends unknown[], Result, Optimistic> { | ||
| 96 | listeners: Set<(update: MutationEvent<Result>) => void>; | ||
| 97 | status: "idle" | "waiting" | "mutating" | "refetching"; | ||
| 98 | |||
| 99 | // Snapshot before first call in current batch | ||
| 100 | initial: Optimistic | null; | ||
| 101 | // First args in batch (used for commit/describe/getValue) | ||
| 102 | firstArgs: Args | null; | ||
| 103 | rollbacks: Array<() => void>; | ||
| 104 | refetches: Array<() => Promise<void>>; | ||
| 105 | timer: ReturnType<typeof setTimeout> | null; | ||
| 106 | |||
| 107 | // Track last commit time for throttle mode | ||
| 108 | lastCommitTime: number; | ||
| 109 | |||
| 110 | // Pending promises from callers in current batch | ||
| 111 | pending: Array<{ | ||
| 112 | args: Args; | ||
| 113 | resolve: (result: Result) => void; | ||
| 114 | reject: (error: unknown) => void; | ||
| 115 | reportSuccessGlobally?: boolean; | ||
| 116 | }>; | ||
| 117 | } | ||
| 118 | |||
| 119 | export class BatchMutation< | ||
| 120 | Args extends unknown[], | ||
| 121 | Result, | ||
| 122 | Optimistic, | ||
| 123 | Config extends MutationClientConfig, | ||
| 124 | > implements Mutation<Args, Result> { | ||
| 125 | #options: BatchMutationOptions<Args, Result, Optimistic, Config>; | ||
| 126 | #client: MutationClientFromConfig<Config>; | ||
| 127 | #channels: Map<string, BatchChannel<Args, Result, Optimistic>> = new Map(); | ||
| 128 | client: MutationClientFromConfig<Config>; | ||
| 129 | |||
| 130 | constructor( | ||
| 131 | client: MutationClient<Config["context"], Config["optimisticHelpers"]>, | ||
| 132 | options: BatchMutationOptions<Args, Result, Optimistic, Config>, | ||
| 133 | ) { | ||
| 134 | this.#options = options; | ||
| 135 | this.#client = client; | ||
| 136 | this.client = client; | ||
| 137 | } | ||
| 138 | |||
| 139 | key(args: Args): string { | ||
| 140 | const k = this.#options.key({ ...this.#client.context, args }); | ||
| 141 | return JSON.stringify(k); | ||
| 142 | } | ||
| 143 | |||
| 144 | #getOrPutChannel(key: string): BatchChannel<Args, Result, Optimistic> { | ||
| 145 | let channel = this.#channels.get(key); | ||
| 146 | if (!channel) { | ||
| 147 | channel = { | ||
| 148 | listeners: new Set(), | ||
| 149 | status: "idle", | ||
| 150 | initial: null, | ||
| 151 | firstArgs: null, | ||
| 152 | rollbacks: [], | ||
| 153 | refetches: [], | ||
| 154 | timer: null, | ||
| 155 | lastCommitTime: 0, | ||
| 156 | pending: [], | ||
| 157 | }; | ||
| 158 | this.#channels.set(key, channel); | ||
| 159 | } | ||
| 160 | return channel; | ||
| 161 | } | ||
| 162 | |||
| 163 | subscribe( | ||
| 164 | key: string, | ||
| 165 | cb: (update: MutationEvent<Result>) => void, | ||
| 166 | ): () => void { | ||
| 167 | const channel = this.#getOrPutChannel(key); | ||
| 168 | channel.listeners.add(cb); | ||
| 169 | return () => channel?.listeners.delete(cb); | ||
| 170 | } | ||
| 171 | |||
| 172 | #notify( | ||
| 173 | channel: BatchChannel<Args, Result, Optimistic>, | ||
| 174 | status: MutationEvent<Result>["status"], | ||
| 175 | result: Result | null = null, | ||
| 176 | error: unknown = null, | ||
| 177 | ) { | ||
| 178 | const event: MutationEvent<Result> = { status, result, error }; | ||
| 179 | channel.listeners.forEach((cb) => cb(event)); | ||
| 180 | } | ||
| 181 | |||
| 182 | #setIdle(key: string, channel: BatchChannel<Args, Result, Optimistic>) { | ||
| 183 | channel.status = "idle"; | ||
| 184 | this.#notify(channel, "idle", null, null); | ||
| 185 | // Clean up the channel if there are no listeners | ||
| 186 | if (channel.listeners.size === 0) { | ||
| 187 | this.#channels.delete(key); | ||
| 188 | } | ||
| 189 | } | ||
| 190 | |||
| 191 | #resetBatchState(channel: BatchChannel<Args, Result, Optimistic>) { | ||
| 192 | channel.initial = null; | ||
| 193 | channel.firstArgs = null; | ||
| 194 | channel.rollbacks = []; | ||
| 195 | channel.refetches = []; | ||
| 196 | channel.pending = []; | ||
| 197 | if (channel.timer !== null) { | ||
| 198 | clearTimeout(channel.timer); | ||
| 199 | channel.timer = null; | ||
| 200 | } | ||
| 201 | } | ||
| 202 | |||
| 203 | describe(...args: Args): string { | ||
| 204 | const { describe } = this.#options; | ||
| 205 | if (typeof describe === "function") { | ||
| 206 | // For describe, we need initial/current but may not have them yet | ||
| 207 | // Use placeholder values when called outside of commit context | ||
| 208 | return describe({ | ||
| 209 | ...this.#client.context, | ||
| 210 | args, | ||
| 211 | initial: null as unknown as Optimistic, | ||
| 212 | current: null as unknown as Optimistic, | ||
| 213 | }); | ||
| 214 | } | ||
| 215 | return describe; | ||
| 216 | } | ||
| 217 | |||
| 218 | // Not available for batched mutations - success reporting happens during commit | ||
| 219 | describeResult: undefined = undefined; | ||
| 220 | |||
| 221 | #describeResult(args: Args, initial: Optimistic, current: Optimistic, result: Result): string | undefined { | ||
| 222 | const { describeResult } = this.#options; | ||
| 223 | if (describeResult === null || describeResult === undefined) return undefined; | ||
| 224 | return typeof describeResult === "function" | ||
| 225 | ? describeResult({ | ||
| 226 | ...this.#client.context, | ||
| 227 | args, | ||
| 228 | initial, | ||
| 229 | current, | ||
| 230 | result, | ||
| 231 | }) | ||
| 232 | : describeResult; | ||
| 233 | } | ||
| 234 | |||
| 235 | /** Calling the mutation in a global scope. Errors and successes are turned into UI toasts. */ | ||
| 236 | run(...args: Args): void { | ||
| 237 | this.#runAndReturn(args, true).catch((error) => { | ||
| 238 | const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; | ||
| 239 | this.#client.reportError(message, error); | ||
| 240 | }); | ||
| 241 | } | ||
| 242 | |||
| 243 | /** Calls the mutation, treating the errors as promise rejection. */ | ||
| 244 | runAndReturn(...args: Args): Promise<Result> { | ||
| 245 | return this.#runAndReturn(args, false); | ||
| 246 | } | ||
| 247 | |||
| 248 | #runAndReturn(args: Args, reportSuccessGlobally: boolean): Promise<Result> { | ||
| 249 | const key = this.key(args); | ||
| 250 | const channel = this.#getOrPutChannel(key); | ||
| 251 | |||
| 252 | // If this is the first call in the batch, take a snapshot | ||
| 253 | if (channel.initial === null) { | ||
| 254 | channel.initial = this.#options.getValue(this.#client.context, ...args); | ||
| 255 | channel.firstArgs = args; | ||
| 256 | } | ||
| 257 | |||
| 258 | // Apply optimistic update | ||
| 259 | let expired = false; | ||
| 260 | const onRestore = (cb: () => void) => { | ||
| 261 | if (expired) { | ||
| 262 | throw new Error( | ||
| 263 | "Can only call onRestore from within the optimistic update function.", | ||
| 264 | ); | ||
| 265 | } | ||
| 266 | channel.rollbacks.push(cb); | ||
| 267 | }; | ||
| 268 | const onRefetch = (cb: () => Promise<void>) => { | ||
| 269 | if (expired) { | ||
| 270 | throw new Error( | ||
| 271 | "Can only call onRefetch from within the optimistic update function.", | ||
| 272 | ); | ||
| 273 | } | ||
| 274 | channel.refetches.push(cb); | ||
| 275 | }; | ||
| 276 | |||
| 277 | try { | ||
| 278 | this.#options.optimistic( | ||
| 279 | { | ||
| 280 | ...this.#client.context, | ||
| 281 | onRestore, | ||
| 282 | helpers: this.#client.getOptimisticHelpers({ | ||
| 283 | onRestore, | ||
| 284 | onRefetch, | ||
| 285 | }), | ||
| 286 | }, | ||
| 287 | ...args, | ||
| 288 | ); | ||
| 289 | } catch (error) { | ||
| 290 | expired = true; | ||
| 291 | // Rollback just this call's rollbacks | ||
| 292 | // We don't know how many were added, so we can't do partial rollback easily | ||
| 293 | // For simplicity, rollback everything and reject | ||
| 294 | let next; | ||
| 295 | while ((next = channel.rollbacks.pop())) next(); | ||
| 296 | this.#resetBatchState(channel); | ||
| 297 | return Promise.reject(error); | ||
| 298 | } | ||
| 299 | expired = true; | ||
| 300 | |||
| 301 | // Create promise for this caller | ||
| 302 | const { promise, resolve, reject } = Promise.withResolvers<Result>(); | ||
| 303 | channel.pending.push({ args, resolve, reject, reportSuccessGlobally }); | ||
| 304 | |||
| 305 | // Set status to waiting and notify | ||
| 306 | if (channel.status === "idle") { | ||
| 307 | channel.status = "waiting"; | ||
| 308 | this.#notify(channel, "waiting"); | ||
| 309 | } | ||
| 310 | |||
| 311 | // Schedule commit based on mode | ||
| 312 | this.#scheduleCommit(key, channel); | ||
| 313 | |||
| 314 | return promise; | ||
| 315 | } | ||
| 316 | |||
| 317 | #scheduleCommit( | ||
| 318 | key: string, | ||
| 319 | channel: BatchChannel<Args, Result, Optimistic>, | ||
| 320 | ) { | ||
| 321 | const time = this.#options.time ?? 200; | ||
| 322 | |||
| 323 | if (this.#options.mode !== "throttle") { | ||
| 324 | // Debounce: reset timer on each call | ||
| 325 | if (channel.timer !== null) { | ||
| 326 | clearTimeout(channel.timer); | ||
| 327 | } | ||
| 328 | channel.timer = setTimeout(() => this.#commit(key, channel), time); | ||
| 329 | } else { | ||
| 330 | // Throttle: commit immediately if enough time passed, otherwise wait | ||
| 331 | // Use status to track if a commit is in progress | ||
| 332 | if (channel.timer === null && channel.status === "waiting") { | ||
| 333 | const elapsed = Date.now() - channel.lastCommitTime; | ||
| 334 | if (elapsed >= time) { | ||
| 335 | // Enough time has passed, commit immediately | ||
| 336 | this.#commit(key, channel); | ||
| 337 | } else { | ||
| 338 | // Wait for remaining time | ||
| 339 | channel.timer = setTimeout( | ||
| 340 | () => this.#commit(key, channel), | ||
| 341 | time - elapsed, | ||
| 342 | ); | ||
| 343 | } | ||
| 344 | } | ||
| 345 | // If timer exists or commit is in progress, do nothing - will commit when ready | ||
| 346 | } | ||
| 347 | } | ||
| 348 | |||
| 349 | #commit(key: string, channel: BatchChannel<Args, Result, Optimistic>) { | ||
| 350 | // Clear timer | ||
| 351 | if (channel.timer !== null) { | ||
| 352 | clearTimeout(channel.timer); | ||
| 353 | channel.timer = null; | ||
| 354 | } | ||
| 355 | |||
| 356 | // Safety check | ||
| 357 | if (channel.firstArgs === null || channel.initial === null) { | ||
| 358 | this.#setIdle(key, channel); | ||
| 359 | return; | ||
| 360 | } | ||
| 361 | |||
| 362 | const firstArgs = channel.firstArgs; | ||
| 363 | const initial = channel.initial; | ||
| 364 | const pendingItems = [...channel.pending]; | ||
| 365 | const rollbacks = [...channel.rollbacks]; | ||
| 366 | const refetchCallbacks = [...channel.refetches]; | ||
| 367 | |||
| 368 | // Get current snapshot | ||
| 369 | const current = this.#options.getValue( | ||
| 370 | this.#client.context, | ||
| 371 | ...firstArgs, | ||
| 372 | ); | ||
| 373 | |||
| 374 | // Check if anything changed | ||
| 375 | if (this.#client.deepEquals(initial, current)) { | ||
| 376 | // No change - resolve all pending with a null result and reset | ||
| 377 | pendingItems.forEach(({ resolve }) => resolve(null as Result)); | ||
| 378 | this.#resetBatchState(channel); | ||
| 379 | this.#setIdle(key, channel); | ||
| 380 | return; | ||
| 381 | } | ||
| 382 | |||
| 383 | // Set status to mutating | ||
| 384 | channel.status = "mutating"; | ||
| 385 | this.#notify(channel, "mutating"); | ||
| 386 | |||
| 387 | // Clear batch state before async operation (but keep rollbacks/refetches for error case) | ||
| 388 | channel.initial = null; | ||
| 389 | channel.firstArgs = null; | ||
| 390 | channel.pending = []; | ||
| 391 | channel.rollbacks = []; | ||
| 392 | channel.refetches = []; | ||
| 393 | |||
| 394 | // Call commit | ||
| 395 | this.#options | ||
| 396 | .commit({ | ||
| 397 | ...this.#client.context, | ||
| 398 | args: firstArgs, | ||
| 399 | initial, | ||
| 400 | current, | ||
| 401 | }) | ||
| 402 | .then((result) => { | ||
| 403 | // Success - rollbacks are discarded (optimistic was correct) | ||
| 404 | // Resolve all pending promises | ||
| 405 | pendingItems.forEach(({ resolve }) => resolve(result)); | ||
| 406 | |||
| 407 | // Report success globally if any of the pending items requested it | ||
| 408 | const shouldReportSuccess = pendingItems.some((item) => item.reportSuccessGlobally); | ||
| 409 | if (shouldReportSuccess) { | ||
| 410 | const message = this.#describeResult(firstArgs, initial, current, result); | ||
| 411 | if (message && this.#client.reportSuccess) { | ||
| 412 | this.#client.reportSuccess(message); | ||
| 413 | } | ||
| 414 | } | ||
| 415 | |||
| 416 | // Record commit time for throttle mode | ||
| 417 | channel.lastCommitTime = Date.now(); | ||
| 418 | |||
| 419 | // Refetch | ||
| 420 | channel.status = "refetching"; | ||
| 421 | this.#notify(channel, "refetching", result); | ||
| 422 | // Call refetch and all refetch callbacks in parallel | ||
| 423 | Promise.allSettled([ | ||
| 424 | this.#options.refetch?.(), | ||
| 425 | ...refetchCallbacks.map((cb) => cb()), | ||
| 426 | ]).then((results) => { | ||
| 427 | // Report any errors from refetch or callbacks | ||
| 428 | results.forEach((result) => { | ||
| 429 | if (result.status === "rejected") { | ||
| 430 | const message = `Failed to refetch after ${this.describe(...firstArgs)}: ${errMessage(result.reason)}`; | ||
| 431 | this.#client.reportError(message, result.reason); | ||
| 432 | } | ||
| 433 | }); | ||
| 434 | }).finally(() => { | ||
| 435 | // Check if new calls came in during the commit | ||
| 436 | if (channel.pending.length > 0) { | ||
| 437 | // There are pending calls that need to be committed | ||
| 438 | channel.status = "waiting"; | ||
| 439 | this.#notify(channel, "waiting"); | ||
| 440 | this.#scheduleCommit(key, channel); | ||
| 441 | } else { | ||
| 442 | this.#setIdle(key, channel); | ||
| 443 | } | ||
| 444 | }); | ||
| 445 | }) | ||
| 446 | .catch((error) => { | ||
| 447 | // Error - call all rollbacks in reverse order | ||
| 448 | let next; | ||
| 449 | const rollbacksCopy = [...rollbacks]; | ||
| 450 | while ((next = rollbacksCopy.pop())) next(); | ||
| 451 | |||
| 452 | // Reject all pending promises | ||
| 453 | pendingItems.forEach(({ reject }) => reject(error)); | ||
| 454 | |||
| 455 | // Notify listeners of the error | ||
| 456 | this.#notify(channel, "mutating", null, error); | ||
| 457 | |||
| 458 | // Refetch to restore correct state | ||
| 459 | channel.status = "refetching"; | ||
| 460 | this.#notify(channel, "refetching", null, error); | ||
| 461 | // Call refetch and all refetch callbacks in parallel | ||
| 462 | Promise.allSettled([ | ||
| 463 | this.#options.refetch?.(), | ||
| 464 | ...refetchCallbacks.map((cb) => cb()), | ||
| 465 | ]).then((results) => { | ||
| 466 | // Report any errors from refetch or callbacks | ||
| 467 | results.forEach((result) => { | ||
| 468 | if (result.status === "rejected") { | ||
| 469 | const message = `Failed to refetch after ${this.describe(...firstArgs)}: ${errMessage(result.reason)}`; | ||
| 470 | this.#client.reportError(message, result.reason); | ||
| 471 | } | ||
| 472 | }); | ||
| 473 | }).finally(() => { | ||
| 474 | // Check if new calls came in during the commit | ||
| 475 | if (channel.pending.length > 0) { | ||
| 476 | // There are pending calls that need to be committed | ||
| 477 | channel.status = "waiting"; | ||
| 478 | this.#notify(channel, "waiting"); | ||
| 479 | this.#scheduleCommit(key, channel); | ||
| 480 | } else { | ||
| 481 | this.#setIdle(key, channel); | ||
| 482 | } | ||
| 483 | }); | ||
| 484 | }); | ||
| 485 | } | ||
| 486 | } | ||
src/blocking.ts created+361| ... | @@ -0,0 +1,361 @@ | ||
| 1 | import type { MutationClient, MutationClientFromConfig } from "./client.ts"; | ||
| 2 | import type { MutationClientConfig } from "./client.ts"; | ||
| 3 | import type { Mutation, MutationEvent } from "./types.ts"; | ||
| 4 | import { message as errMessage } from "@clo/lib/error.ts"; | ||
| 5 | |||
| 6 | /** | ||
| 7 | * Argument to `defineBlocking`. | ||
| 8 | * @template Args - the parameters to the mutation | ||
| 9 | * @template Result - the result of the API call | ||
| 10 | * @template Config - global values and helpers from `MutationContext` | ||
| 11 | */ | ||
| 12 | export interface BlockingMutationOptions< | ||
| 13 | Args extends unknown[], | ||
| 14 | Result, | ||
| 15 | Config extends MutationClientConfig, | ||
| 16 | > { | ||
| 17 | /** | ||
| 18 | * This function is only responsible for performing the underlying API call, | ||
| 19 | * syncronizing the optimistic state with reality. Throw on failure. A rest | ||
| 20 | * params type is used to allow type inference. Place this function first to | ||
| 21 | * ensure TypeScript correctly infers the argument type for the rest of the | ||
| 22 | * functions. | ||
| 23 | * | ||
| 24 | * In practice, optimistic context is never needed in this function, but it | ||
| 25 | * is provided as the `this` value if you truly desire it. | ||
| 26 | */ | ||
| 27 | mutate: (this: Config["context"], ...args: Args) => Promise<Result>; | ||
| 28 | /** | ||
| 29 | * Used in error messages and debug tools. | ||
| 30 | * Phrase it considering the template `Failed to ${describe(...)}` | ||
| 31 | */ | ||
| 32 | describe: string | ((context: Config["context"] & { args: Args }) => string); | ||
| 33 | /** | ||
| 34 | * Used in success messages. | ||
| 35 | * Phrase it as a complete success message, e.g., "Deleted item successfully" | ||
| 36 | */ | ||
| 37 | describeResult: string | ((context: Config["context"] & { args: Args; result: Result }) => string); | ||
| 38 | /** | ||
| 39 | * Specifying the optimistic strategy is required. To disable, pass an empty | ||
| 40 | * function with a comment to document why it isn't needed. | ||
| 41 | */ | ||
| 42 | optimistic: (context: BlockingOptimisticContext<Args, Result, Config>) => void; | ||
| 43 | /** | ||
| 44 | * Refetch all of the data this mutation could have affected. | ||
| 45 | * Normally, optimistic helpers will perform | ||
| 46 | * This is called automatically on errors. | ||
| 47 | */ | ||
| 48 | refetch?: (context: Config["context"] & { args: Args }) => Promise<void>; | ||
| 49 | /** | ||
| 50 | * If the optimistic updator function is perfect, then this may be set to false. | ||
| 51 | * @default true | ||
| 52 | */ | ||
| 53 | refetchOnSuccess?: boolean; | ||
| 54 | /** | ||
| 55 | * A key to associate related items. For example, returning a user ID. If | ||
| 56 | * specifying, then all mutations of the same key will evaluate in serial, | ||
| 57 | * but optimistic updates will apply instantly. | ||
| 58 | */ | ||
| 59 | key?: (context: Config["context"] & { args: Args }) => string | string[]; | ||
| 60 | } | ||
| 61 | |||
| 62 | export type BlockingOptimisticContext< | ||
| 63 | Args extends unknown[], | ||
| 64 | Result, | ||
| 65 | Config extends MutationClientConfig, | ||
| 66 | > = Config["context"] & { | ||
| 67 | args: Args; | ||
| 68 | helpers: Config["optimisticHelpers"]; | ||
| 69 | /** Add an event listener to roll back the update */ | ||
| 70 | onRestore: (cb: () => void) => void; | ||
| 71 | /** Add an event listener to apply `Result` to the store. */ | ||
| 72 | onSuccess: (cb: (result: Result) => void) => void; | ||
| 73 | }; | ||
| 74 | |||
| 75 | interface BlockingChannel<Args extends unknown[], Result, OptimisticHelpers> { | ||
| 76 | listeners: Set<(update: MutationEvent<Result>) => void>; | ||
| 77 | status: "idle" | "mutating" | "refetching"; | ||
| 78 | rollbacks: Array<() => void>; | ||
| 79 | refetches: Array<() => Promise<void>>; | ||
| 80 | queue: Array<Item<Args, Result>>; | ||
| 81 | // Shared optimistic helpers instance for the channel | ||
| 82 | helpers: OptimisticHelpers | null; | ||
| 83 | } | ||
| 84 | |||
| 85 | interface Item<Args extends unknown[], Result> { | ||
| 86 | args: Args; | ||
| 87 | rollbacks: number; | ||
| 88 | onSuccess: Array<(result: Result) => void>; | ||
| 89 | resolve: (result: Result) => void; | ||
| 90 | reject: (error: unknown) => void; | ||
| 91 | } | ||
| 92 | |||
| 93 | export class BlockingMutation< | ||
| 94 | Args extends unknown[], | ||
| 95 | Result, | ||
| 96 | Config extends MutationClientConfig, | ||
| 97 | > implements Mutation<Args, Result> { | ||
| 98 | #options: BlockingMutationOptions<Args, Result, Config>; | ||
| 99 | #client: MutationClientFromConfig<Config>; | ||
| 100 | #channels: Map<string, BlockingChannel<Args, Result, Config["optimisticHelpers"]>> = new Map(); | ||
| 101 | client: MutationClientFromConfig<Config>; | ||
| 102 | |||
| 103 | constructor( | ||
| 104 | client: MutationClient<Config["context"], Config["optimisticHelpers"]>, | ||
| 105 | options: BlockingMutationOptions<Args, Result, Config>, | ||
| 106 | ) { | ||
| 107 | this.#options = options; | ||
| 108 | this.#client = client; | ||
| 109 | this.client = client; | ||
| 110 | } | ||
| 111 | |||
| 112 | key(args: Args) { | ||
| 113 | const k = this.#options.key?.({ ...this.#client.context, args }) ?? | ||
| 114 | "shared"; | ||
| 115 | return JSON.stringify(k); | ||
| 116 | } | ||
| 117 | |||
| 118 | #getOrPutChannel(key: string) { | ||
| 119 | let channel = this.#channels.get(key); | ||
| 120 | if (!channel) { | ||
| 121 | const rollbacks: Array<() => []> = []; | ||
| 122 | channel = { | ||
| 123 | listeners: new Set(), | ||
| 124 | status: "idle", | ||
| 125 | rollbacks, | ||
| 126 | refetches: [], | ||
| 127 | queue: [], | ||
| 128 | helpers: null, | ||
| 129 | }; | ||
| 130 | this.#channels.set(key, channel); | ||
| 131 | } | ||
| 132 | return channel; | ||
| 133 | } | ||
| 134 | |||
| 135 | subscribe( | ||
| 136 | key: string, | ||
| 137 | cb: (update: MutationEvent<Result>) => void, | ||
| 138 | ): () => void { | ||
| 139 | const channel = this.#getOrPutChannel(key); | ||
| 140 | channel.listeners.add(cb); | ||
| 141 | return () => channel?.listeners.delete(cb); | ||
| 142 | } | ||
| 143 | |||
| 144 | #notify( | ||
| 145 | channel: BlockingChannel<Args, Result, Config["optimisticHelpers"]>, | ||
| 146 | status: MutationEvent<Result>["status"], | ||
| 147 | result: Result | null = null, | ||
| 148 | error: unknown = null, | ||
| 149 | ) { | ||
| 150 | const event: MutationEvent<Result> = { status, result, error }; | ||
| 151 | channel.listeners.forEach((cb) => cb(event)); | ||
| 152 | } | ||
| 153 | |||
| 154 | #setIdle(key: string, channel: BlockingChannel<Args, Result, Config["optimisticHelpers"]>) { | ||
| 155 | channel.status = "idle"; | ||
| 156 | // Discard any unconsumed refetch callbacks | ||
| 157 | channel.refetches = []; | ||
| 158 | this.#notify(channel, "idle", null, null); | ||
| 159 | // Clean up the channel if there are no listeners | ||
| 160 | if (channel.listeners.size === 0) { | ||
| 161 | this.#channels.delete(key); | ||
| 162 | } | ||
| 163 | } | ||
| 164 | |||
| 165 | describe(...args: Args): string { | ||
| 166 | const { describe } = this.#options; | ||
| 167 | return typeof describe === "function" | ||
| 168 | ? describe({ ...this.#client.context, args }) | ||
| 169 | : describe; | ||
| 170 | } | ||
| 171 | |||
| 172 | describeResult(args: Args, result: Result): string { | ||
| 173 | const { describeResult } = this.#options; | ||
| 174 | return typeof describeResult === "function" | ||
| 175 | ? describeResult({ ...this.#client.context, args, result }) | ||
| 176 | : describeResult; | ||
| 177 | } | ||
| 178 | |||
| 179 | /** Calling the mutation in a global scope. Errors are turned into UI toasts. */ | ||
| 180 | run(...args: Args) { | ||
| 181 | if (!this.#client.enabled) { | ||
| 182 | throw new Error( | ||
| 183 | "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?", | ||
| 184 | ); | ||
| 185 | } | ||
| 186 | this.runAndReturn(...args).then((result) => { | ||
| 187 | const message = this.describeResult(args, result); | ||
| 188 | if (message && this.#client.reportSuccess) { | ||
| 189 | this.#client.reportSuccess(message); | ||
| 190 | } | ||
| 191 | }).catch((error) => { | ||
| 192 | const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; | ||
| 193 | this.#client.reportError(message, error); | ||
| 194 | }); | ||
| 195 | } | ||
| 196 | |||
| 197 | /** Calls the mutation, treating the errors as promise rejection. */ | ||
| 198 | runAndReturn(...args: Args): Promise<Result> { | ||
| 199 | if (!this.#client.enabled) { | ||
| 200 | throw new Error( | ||
| 201 | "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?", | ||
| 202 | ); | ||
| 203 | } | ||
| 204 | const key = this.key(args); | ||
| 205 | const channel = this.#getOrPutChannel(key); | ||
| 206 | |||
| 207 | // Create shared optimistic helpers instance for the channel if it doesn't exist | ||
| 208 | if (channel.helpers === null) { | ||
| 209 | const onRefetch = (cb: () => Promise<void>) => { | ||
| 210 | channel.refetches.push(cb); | ||
| 211 | }; | ||
| 212 | |||
| 213 | channel.helpers = this.#client.getOptimisticHelpers({ | ||
| 214 | onRestore: (cb: () => void) => { | ||
| 215 | channel.rollbacks.push(cb); | ||
| 216 | }, | ||
| 217 | onRefetch, | ||
| 218 | }); | ||
| 219 | } | ||
| 220 | |||
| 221 | const onSuccess: Array<(result: Result) => void> = []; | ||
| 222 | let expired = false; | ||
| 223 | let rollbacks = 0; | ||
| 224 | const onRestore = (cb: () => void) => { | ||
| 225 | if (expired) { | ||
| 226 | throw new Error( | ||
| 227 | "Can only call onRestore from within the optimistic update function.", | ||
| 228 | ); | ||
| 229 | } | ||
| 230 | channel.rollbacks.push(cb); | ||
| 231 | rollbacks += 1; | ||
| 232 | }; | ||
| 233 | |||
| 234 | try { | ||
| 235 | this.#options.optimistic({ | ||
| 236 | args, | ||
| 237 | helpers: channel.helpers, | ||
| 238 | onRestore, | ||
| 239 | onSuccess(cb) { | ||
| 240 | if (expired) { | ||
| 241 | throw new Error( | ||
| 242 | "Can only call onSuccess from within the optimistic update function.", | ||
| 243 | ); | ||
| 244 | } | ||
| 245 | onSuccess.push(cb); | ||
| 246 | }, | ||
| 247 | }); | ||
| 248 | } catch (error) { | ||
| 249 | expired = true; | ||
| 250 | let next; | ||
| 251 | while ( | ||
| 252 | next = | ||
| 253 | channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0] | ||
| 254 | ) { | ||
| 255 | next(); | ||
| 256 | } | ||
| 257 | return Promise.reject(error); | ||
| 258 | } | ||
| 259 | expired = true; | ||
| 260 | |||
| 261 | const { promise, resolve, reject } = Promise.withResolvers<Result>(); | ||
| 262 | channel.queue.push({ | ||
| 263 | args, | ||
| 264 | rollbacks, | ||
| 265 | onSuccess, | ||
| 266 | resolve, | ||
| 267 | reject, | ||
| 268 | }); | ||
| 269 | |||
| 270 | if (channel.status === "idle") { | ||
| 271 | this.#executeNext(key, channel); | ||
| 272 | } | ||
| 273 | |||
| 274 | return promise; | ||
| 275 | } | ||
| 276 | |||
| 277 | #executeNext(key: string, channel: BlockingChannel<Args, Result, Config["optimisticHelpers"]>) { | ||
| 278 | const item = channel.queue.shift(); | ||
| 279 | if (!item) { | ||
| 280 | this.#setIdle(key, channel); | ||
| 281 | return; | ||
| 282 | } | ||
| 283 | |||
| 284 | const { args, onSuccess, resolve, reject } = item; | ||
| 285 | channel.status = "mutating"; | ||
| 286 | this.#notify(channel, "mutating"); | ||
| 287 | |||
| 288 | this.#options.mutate.call(this.#client.context, ...args).then((result) => { | ||
| 289 | // remove rollbacks and apply optimistic success handlers | ||
| 290 | channel.rollbacks.splice(0, item.rollbacks); | ||
| 291 | onSuccess.forEach((cb) => cb(result)); | ||
| 292 | |||
| 293 | if (this.#options.refetchOnSuccess !== false) { | ||
| 294 | channel.status = "refetching"; | ||
| 295 | this.#notify(channel, "refetching", result); | ||
| 296 | // Call refetch and all refetch callbacks in parallel | ||
| 297 | const refetchCallbacks = channel.refetches.splice(0); | ||
| 298 | Promise.allSettled([ | ||
| 299 | this.#options.refetch?.({ | ||
| 300 | ...this.#client.context, | ||
| 301 | args, | ||
| 302 | }), | ||
| 303 | ...refetchCallbacks.map((cb) => cb()), | ||
| 304 | ]).then((results) => { | ||
| 305 | // Report any errors from refetch or callbacks | ||
| 306 | results.forEach((result) => { | ||
| 307 | if (result.status === "rejected") { | ||
| 308 | const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`; | ||
| 309 | this.#client.reportError(message, result.reason); | ||
| 310 | } | ||
| 311 | }); | ||
| 312 | }).finally(() => { | ||
| 313 | this.#executeNext(key, channel); | ||
| 314 | }); | ||
| 315 | } else { | ||
| 316 | // Discard refetch callbacks if refetchOnSuccess is false | ||
| 317 | channel.refetches = []; | ||
| 318 | this.#executeNext(key, channel); | ||
| 319 | } | ||
| 320 | resolve(result); | ||
| 321 | }, (error) => { | ||
| 322 | // if an error happens, then every rollback is called in reverse order | ||
| 323 | let next; | ||
| 324 | while (next = channel.rollbacks.pop()) next(); | ||
| 325 | |||
| 326 | // Cancel all remaining items in the channel | ||
| 327 | const remainingItems = channel.queue.splice(0); | ||
| 328 | remainingItems.forEach((queuedItem) => { | ||
| 329 | queuedItem.reject(error); | ||
| 330 | }); | ||
| 331 | |||
| 332 | // Notify listeners of the error | ||
| 333 | this.#notify(channel, "mutating", null, error); | ||
| 334 | |||
| 335 | // Refetch to restore correct state | ||
| 336 | channel.status = "refetching"; | ||
| 337 | this.#notify(channel, "refetching", null, error); | ||
| 338 | // Call refetch and all refetch callbacks in parallel | ||
| 339 | const refetchCallbacks = channel.refetches.splice(0); | ||
| 340 | Promise.allSettled([ | ||
| 341 | this.#options.refetch?.({ | ||
| 342 | ...this.#client.context, | ||
| 343 | args, | ||
| 344 | }), | ||
| 345 | ...refetchCallbacks.map((cb) => cb()), | ||
| 346 | ]).then((results) => { | ||
| 347 | // Report any errors from refetch or callbacks | ||
| 348 | results.forEach((result) => { | ||
| 349 | if (result.status === "rejected") { | ||
| 350 | const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`; | ||
| 351 | this.#client.reportError(message, result.reason); | ||
| 352 | } | ||
| 353 | }); | ||
| 354 | }).finally(() => { | ||
| 355 | this.#setIdle(key, channel); | ||
| 356 | }); | ||
| 357 | |||
| 358 | reject(error); | ||
| 359 | }); | ||
| 360 | } | ||
| 361 | } | ||
src/client.ts+18-10| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { BatchMutation, type BatchMutationOptions } from "./batch.ts"; | 1 | import { DebouncedMutation, type DebouncedMutationOptions } from "./debounced.ts"; |
| 2 | import { type MutationOptions, QueuedMutation } from "./queued.ts"; | 2 | import { type BlockingMutationOptions, BlockingMutation } from "./blocking.ts"; |
| 3 | import type { Mutation } from "./types.ts"; | 3 | import type { Mutation } from "./types.ts"; |
| 4 | 4 | ||
| 5 | export interface MutationClientConfig { | 5 | export interface MutationClientConfig { |
| ... | @@ -24,11 +24,17 @@ export interface MutationClientOptions< | ... | @@ -24,11 +24,17 @@ export interface MutationClientOptions< |
| 24 | reportError: (message: string, error: unknown) => void; | 24 | reportError: (message: string, error: unknown) => void; |
| 25 | reportSuccess?: (message: string) => void; | 25 | reportSuccess?: (message: string) => void; |
| 26 | /** | 26 | /** |
| 27 | * Compare two values for deep equality. Used by BatchMutation to determine | 27 | * Compare two values for deep equality. Used by DebouncedMutation to determine |
| 28 | * if the optimistic state has changed from the initial snapshot. | 28 | * if the optimistic state has changed from the initial snapshot. |
| 29 | * @default JSON.stringify based comparison | 29 | * @default JSON.stringify based comparison |
| 30 | */ | 30 | */ |
| 31 | deepEquals?: (a: unknown, b: unknown) => boolean; | 31 | deepEquals?: (a: unknown, b: unknown) => boolean; |
| 32 | /** | ||
| 33 | * When false, all mutation run functions will throw an error. | ||
| 34 | * Useful for preventing mutations during SSR. | ||
| 35 | * @default true | ||
| 36 | */ | ||
| 37 | enabled?: boolean; | ||
| 32 | } | 38 | } |
| 33 | 39 | ||
| 34 | export interface OptimisticEvents { | 40 | export interface OptimisticEvents { |
| ... | @@ -45,6 +51,7 @@ export class MutationClient< | ... | @@ -45,6 +51,7 @@ export class MutationClient< |
| 45 | reportError: (message: string, error: unknown) => void; | 51 | reportError: (message: string, error: unknown) => void; |
| 46 | reportSuccess?: (message: string) => void; | 52 | reportSuccess?: (message: string) => void; |
| 47 | deepEquals: (a: unknown, b: unknown) => boolean; | 53 | deepEquals: (a: unknown, b: unknown) => boolean; |
| 54 | enabled: boolean; | ||
| 48 | 55 | ||
| 49 | constructor(options: MutationClientOptions<Context, OptimisticHelpers>) { | 56 | constructor(options: MutationClientOptions<Context, OptimisticHelpers>) { |
| 50 | this.context = options.context; | 57 | this.context = options.context; |
| ... | @@ -52,21 +59,22 @@ export class MutationClient< | ... | @@ -52,21 +59,22 @@ export class MutationClient< |
| 52 | this.reportError = options.reportError; | 59 | this.reportError = options.reportError; |
| 53 | this.reportSuccess = options.reportSuccess; | 60 | this.reportSuccess = options.reportSuccess; |
| 54 | this.deepEquals = options.deepEquals ?? defaultDeepEquals; | 61 | this.deepEquals = options.deepEquals ?? defaultDeepEquals; |
| 62 | this.enabled = options.enabled ?? true; | ||
| 55 | } | 63 | } |
| 56 | 64 | ||
| 57 | /** | 65 | /** |
| 58 | * Define a queued mutation. A mutation blocks the UI until it is complete. | 66 | * Define a blocking mutation. A mutation blocks the UI until it is complete. |
| 59 | * You press a button, a pending state appears, then it completes. This works | 67 | * You press a button, a pending state appears, then it completes. This works |
| 60 | * great for forms, and is similar to React Query's mutation system. | 68 | * great for forms, and is similar to React Query's mutation system. |
| 61 | */ | 69 | */ |
| 62 | defineBlocking<const Args extends unknown[], Result>( | 70 | defineBlocking<const Args extends unknown[], Result>( |
| 63 | options: MutationOptions< | 71 | options: BlockingMutationOptions< |
| 64 | Args, | 72 | Args, |
| 65 | Result, | 73 | Result, |
| 66 | { context: Context; optimisticHelpers: OptimisticHelpers } | 74 | { context: Context; optimisticHelpers: OptimisticHelpers } |
| 67 | >, | 75 | >, |
| 68 | ): Mutation<Args, Result> { | 76 | ): Mutation<Args, Result> { |
| 69 | return new QueuedMutation< | 77 | return new BlockingMutation< |
| 70 | Args, | 78 | Args, |
| 71 | Result, | 79 | Result, |
| 72 | { context: Context; optimisticHelpers: OptimisticHelpers } | 80 | { context: Context; optimisticHelpers: OptimisticHelpers } |
| ... | @@ -74,21 +82,21 @@ export class MutationClient< | ... | @@ -74,21 +82,21 @@ export class MutationClient< |
| 74 | } | 82 | } |
| 75 | 83 | ||
| 76 | /** | 84 | /** |
| 77 | * Define a batched mutation. Each call to the mutation applies new optimistic | 85 | * Define a debounced mutation. Each call to the mutation applies new optimistic |
| 78 | * state, and after a debounce or throttle, the new optimistic state is | 86 | * state, and after a debounce or throttle, the new optimistic state is |
| 79 | * committed to the API. UI never shows a pending state for batches. This | 87 | * committed to the API. UI never shows a pending state for debounced mutations. This |
| 80 | * works great for auto-saving input fields, follow buttons, and is preferred | 88 | * works great for auto-saving input fields, follow buttons, and is preferred |
| 81 | * whenever possible. | 89 | * whenever possible. |
| 82 | */ | 90 | */ |
| 83 | defineDebounced<const Args extends unknown[], Result, Optimistic>( | 91 | defineDebounced<const Args extends unknown[], Result, Optimistic>( |
| 84 | options: BatchMutationOptions< | 92 | options: DebouncedMutationOptions< |
| 85 | Args, | 93 | Args, |
| 86 | Result, | 94 | Result, |
| 87 | Optimistic, | 95 | Optimistic, |
| 88 | { context: Context; optimisticHelpers: OptimisticHelpers } | 96 | { context: Context; optimisticHelpers: OptimisticHelpers } |
| 89 | >, | 97 | >, |
| 90 | ): Mutation<Args, Result> { | 98 | ): Mutation<Args, Result> { |
| 91 | return new BatchMutation< | 99 | return new DebouncedMutation< |
| 92 | Args, | 100 | Args, |
| 93 | Result, | 101 | Result, |
| 94 | Optimistic, | 102 | Optimistic, |
src/debounced.ts created+562| ... | @@ -0,0 +1,562 @@ | ||
| 1 | import type { MutationClient, MutationClientFromConfig } from "./client.ts"; | ||
| 2 | import type { MutationClientConfig } from "./client.ts"; | ||
| 3 | import type { Mutation, MutationEvent } from "./types.ts"; | ||
| 4 | import { message as errMessage } from "@clo/lib/error.ts"; | ||
| 5 | |||
| 6 | export interface DebouncedMutationOptions< | ||
| 7 | Args extends unknown[], | ||
| 8 | Result, | ||
| 9 | Optimistic, | ||
| 10 | Config extends MutationClientConfig, | ||
| 11 | > { | ||
| 12 | /** | ||
| 13 | * Update the UI for one call to the optimistic function. | ||
| 14 | * A rest params type is used to allow type inference. Place this function first to | ||
| 15 | * ensure TypeScript correctly infers the argument type for the rest of the functions. | ||
| 16 | */ | ||
| 17 | optimistic: (context: DebouncedOptimisticContext<Config>, ...args: Args) => void; | ||
| 18 | /** | ||
| 19 | * Retrieve the current/optimistic value of the mutation. When this returns | ||
| 20 | * the same thing as when the mutation started, it means that `mutate` does | ||
| 21 | * not need to be called since the data is the same. | ||
| 22 | * | ||
| 23 | * Don't snapshot unrelated state that this mutation isn't concerned with. | ||
| 24 | */ | ||
| 25 | getValue: (context: Config["context"] & { args: Args }) => Optimistic; | ||
| 26 | |||
| 27 | /** | ||
| 28 | * @default "debounce" | ||
| 29 | */ | ||
| 30 | mode?: "debounce" | "throttle"; | ||
| 31 | /** | ||
| 32 | * Milliseconds | ||
| 33 | * @default 200 | ||
| 34 | */ | ||
| 35 | time?: number; | ||
| 36 | |||
| 37 | /** A key to associate debounced items. For example, returning a user ID */ | ||
| 38 | key: ( | ||
| 39 | context: Config["context"] & { args: NoInfer<Args> }, | ||
| 40 | ) => string | string[]; | ||
| 41 | |||
| 42 | /** | ||
| 43 | * Commit the optimistic state. Throw on failure. | ||
| 44 | */ | ||
| 45 | commit: ( | ||
| 46 | context: DebouncedCommitContext<NoInfer<Args>, Optimistic, Config>, | ||
| 47 | ) => Promise<Result>; | ||
| 48 | /** | ||
| 49 | * Used in error messages and debug tools. | ||
| 50 | * "Failed to {action}" | ||
| 51 | */ | ||
| 52 | describe: | ||
| 53 | | string | ||
| 54 | | (( | ||
| 55 | context: DebouncedCommitContext<NoInfer<Args>, Optimistic, Config>, | ||
| 56 | ) => string); | ||
| 57 | /** | ||
| 58 | * Used in success messages. | ||
| 59 | * Phrase it as a complete success message, e.g., "Renamed item successfully" | ||
| 60 | */ | ||
| 61 | describeResult: | ||
| 62 | | string | ||
| 63 | | (( | ||
| 64 | context: DebouncedCommitContext<NoInfer<Args>, Optimistic, Config> & { | ||
| 65 | result: Result; | ||
| 66 | }, | ||
| 67 | ) => string); | ||
| 68 | /** | ||
| 69 | * Refetch all of the data this mutation could have affected. | ||
| 70 | */ | ||
| 71 | refetch?: () => Promise<void>; | ||
| 72 | } | ||
| 73 | |||
| 74 | export type DebouncedOptimisticContext<Config extends MutationClientConfig> = | ||
| 75 | & Config["context"] | ||
| 76 | & { | ||
| 77 | /** Add an event listener to roll back the update */ | ||
| 78 | onRestore: (cb: () => void) => void; | ||
| 79 | helpers: Config["optimisticHelpers"]; | ||
| 80 | }; | ||
| 81 | |||
| 82 | export type DebouncedCommitContext< | ||
| 83 | Args, | ||
| 84 | Optimistic, | ||
| 85 | Config extends MutationClientConfig, | ||
| 86 | > = Config["context"] & { | ||
| 87 | /** One of the arguments. Use this only to extract the shared key */ | ||
| 88 | args: Args; | ||
| 89 | /** The initial snapshot */ | ||
| 90 | initial: Optimistic; | ||
| 91 | /** The compared snapshot */ | ||
| 92 | current: Optimistic; | ||
| 93 | }; | ||
| 94 | |||
| 95 | interface DebouncedChannel< | ||
| 96 | Args extends unknown[], | ||
| 97 | Result, | ||
| 98 | Optimistic, | ||
| 99 | OptimisticHelpers, | ||
| 100 | > { | ||
| 101 | listeners: Set<(update: MutationEvent<Result>) => void>; | ||
| 102 | status: "idle" | "waiting" | "mutating" | "refetching"; | ||
| 103 | |||
| 104 | // Snapshot before first call in current debounced run | ||
| 105 | initial: Optimistic | null; | ||
| 106 | // First args in debounced run (used for commit/describe/getValue) | ||
| 107 | firstArgs: Args | null; | ||
| 108 | rollbacks: Array<() => void>; | ||
| 109 | refetches: Array<() => Promise<void>>; | ||
| 110 | timer: ReturnType<typeof setTimeout> | null; | ||
| 111 | |||
| 112 | // Track last commit time for throttle mode | ||
| 113 | lastCommitTime: number; | ||
| 114 | |||
| 115 | // Shared optimistic helpers instance for the current debounced run | ||
| 116 | helpers: OptimisticHelpers | null; | ||
| 117 | |||
| 118 | // Pending promises from callers in current debounced run | ||
| 119 | pending: Array<{ | ||
| 120 | args: Args; | ||
| 121 | resolve: (result: Result) => void; | ||
| 122 | reject: (error: unknown) => void; | ||
| 123 | reportSuccessGlobally?: boolean; | ||
| 124 | }>; | ||
| 125 | } | ||
| 126 | |||
| 127 | export class DebouncedMutation< | ||
| 128 | Args extends unknown[], | ||
| 129 | Result, | ||
| 130 | Optimistic, | ||
| 131 | Config extends MutationClientConfig, | ||
| 132 | > implements Mutation<Args, Result> { | ||
| 133 | #options: DebouncedMutationOptions<Args, Result, Optimistic, Config>; | ||
| 134 | #client: MutationClientFromConfig<Config>; | ||
| 135 | #channels: Map< | ||
| 136 | string, | ||
| 137 | DebouncedChannel<Args, Result, Optimistic, Config["optimisticHelpers"]> | ||
| 138 | > = new Map(); | ||
| 139 | client: MutationClientFromConfig<Config>; | ||
| 140 | |||
| 141 | constructor( | ||
| 142 | client: MutationClient<Config["context"], Config["optimisticHelpers"]>, | ||
| 143 | options: DebouncedMutationOptions<Args, Result, Optimistic, Config>, | ||
| 144 | ) { | ||
| 145 | this.#options = options; | ||
| 146 | this.#client = client; | ||
| 147 | this.client = client; | ||
| 148 | } | ||
| 149 | |||
| 150 | key(args: Args): string { | ||
| 151 | const k = this.#options.key({ ...this.#client.context, args }); | ||
| 152 | return JSON.stringify(k); | ||
| 153 | } | ||
| 154 | |||
| 155 | #getOrPutChannel( | ||
| 156 | key: string, | ||
| 157 | ): DebouncedChannel<Args, Result, Optimistic, Config["optimisticHelpers"]> { | ||
| 158 | let channel = this.#channels.get(key); | ||
| 159 | if (!channel) { | ||
| 160 | channel = { | ||
| 161 | listeners: new Set(), | ||
| 162 | status: "idle", | ||
| 163 | initial: null, | ||
| 164 | firstArgs: null, | ||
| 165 | rollbacks: [], | ||
| 166 | refetches: [], | ||
| 167 | timer: null, | ||
| 168 | lastCommitTime: 0, | ||
| 169 | helpers: null, | ||
| 170 | pending: [], | ||
| 171 | }; | ||
| 172 | this.#channels.set(key, channel); | ||
| 173 | } | ||
| 174 | return channel; | ||
| 175 | } | ||
| 176 | |||
| 177 | subscribe( | ||
| 178 | key: string, | ||
| 179 | cb: (update: MutationEvent<Result>) => void, | ||
| 180 | ): () => void { | ||
| 181 | const channel = this.#getOrPutChannel(key); | ||
| 182 | channel.listeners.add(cb); | ||
| 183 | return () => channel?.listeners.delete(cb); | ||
| 184 | } | ||
| 185 | |||
| 186 | #notify( | ||
| 187 | channel: DebouncedChannel< | ||
| 188 | Args, | ||
| 189 | Result, | ||
| 190 | Optimistic, | ||
| 191 | Config["optimisticHelpers"] | ||
| 192 | >, | ||
| 193 | status: MutationEvent<Result>["status"], | ||
| 194 | result: Result | null = null, | ||
| 195 | error: unknown = null, | ||
| 196 | ) { | ||
| 197 | const event: MutationEvent<Result> = { status, result, error }; | ||
| 198 | channel.listeners.forEach((cb) => cb(event)); | ||
| 199 | } | ||
| 200 | |||
| 201 | #setIdle( | ||
| 202 | key: string, | ||
| 203 | channel: DebouncedChannel< | ||
| 204 | Args, | ||
| 205 | Result, | ||
| 206 | Optimistic, | ||
| 207 | Config["optimisticHelpers"] | ||
| 208 | >, | ||
| 209 | ) { | ||
| 210 | channel.status = "idle"; | ||
| 211 | this.#notify(channel, "idle", null, null); | ||
| 212 | // Clean up the channel if there are no listeners | ||
| 213 | if (channel.listeners.size === 0) { | ||
| 214 | this.#channels.delete(key); | ||
| 215 | } | ||
| 216 | } | ||
| 217 | |||
| 218 | #resetDebouncedState( | ||
| 219 | channel: DebouncedChannel< | ||
| 220 | Args, | ||
| 221 | Result, | ||
| 222 | Optimistic, | ||
| 223 | Config["optimisticHelpers"] | ||
| 224 | >, | ||
| 225 | ) { | ||
| 226 | channel.initial = null; | ||
| 227 | channel.firstArgs = null; | ||
| 228 | channel.rollbacks = []; | ||
| 229 | channel.refetches = []; | ||
| 230 | channel.helpers = null; | ||
| 231 | channel.pending = []; | ||
| 232 | if (channel.timer !== null) { | ||
| 233 | clearTimeout(channel.timer); | ||
| 234 | channel.timer = null; | ||
| 235 | } | ||
| 236 | } | ||
| 237 | |||
| 238 | describe(...args: Args): string { | ||
| 239 | const { describe } = this.#options; | ||
| 240 | if (typeof describe === "function") { | ||
| 241 | // For describe, we need initial/current but may not have them yet | ||
| 242 | // Use placeholder values when called outside of commit context | ||
| 243 | return describe({ | ||
| 244 | ...this.#client.context, | ||
| 245 | args, | ||
| 246 | initial: null as unknown as Optimistic, | ||
| 247 | current: null as unknown as Optimistic, | ||
| 248 | }); | ||
| 249 | } | ||
| 250 | return describe; | ||
| 251 | } | ||
| 252 | |||
| 253 | // Not available for debounced mutations - success reporting happens during commit | ||
| 254 | describeResult: undefined = undefined; | ||
| 255 | |||
| 256 | #describeResult( | ||
| 257 | args: Args, | ||
| 258 | initial: Optimistic, | ||
| 259 | current: Optimistic, | ||
| 260 | result: Result, | ||
| 261 | ): string { | ||
| 262 | const { describeResult } = this.#options; | ||
| 263 | return typeof describeResult === "function" | ||
| 264 | ? describeResult({ | ||
| 265 | ...this.#client.context, | ||
| 266 | args, | ||
| 267 | initial, | ||
| 268 | current, | ||
| 269 | result, | ||
| 270 | }) | ||
| 271 | : describeResult; | ||
| 272 | } | ||
| 273 | |||
| 274 | /** Calling the mutation in a global scope. Errors and successes are turned into UI toasts. */ | ||
| 275 | run(...args: Args): void { | ||
| 276 | if (!this.#client.enabled) { | ||
| 277 | throw new Error( | ||
| 278 | "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?", | ||
| 279 | ); | ||
| 280 | } | ||
| 281 | this.#runAndReturn(args, true).catch((error) => { | ||
| 282 | const message = `Failed to ${this.describe(...args)}: ${ | ||
| 283 | errMessage(error) | ||
| 284 | }`; | ||
| 285 | this.#client.reportError(message, error); | ||
| 286 | }); | ||
| 287 | } | ||
| 288 | |||
| 289 | /** Calls the mutation, treating the errors as promise rejection. */ | ||
| 290 | runAndReturn(...args: Args): Promise<Result> { | ||
| 291 | if (!this.#client.enabled) { | ||
| 292 | throw new Error( | ||
| 293 | "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?", | ||
| 294 | ); | ||
| 295 | } | ||
| 296 | return this.#runAndReturn(args, false); | ||
| 297 | } | ||
| 298 | |||
| 299 | #runAndReturn(args: Args, reportSuccessGlobally: boolean): Promise<Result> { | ||
| 300 | const key = this.key(args); | ||
| 301 | const channel = this.#getOrPutChannel(key); | ||
| 302 | |||
| 303 | // If this is the first call in the debounced run, take a snapshot and create shared helpers | ||
| 304 | if (channel.initial === null) { | ||
| 305 | channel.initial = this.#options.getValue({ ...this.#client.context, args }); | ||
| 306 | channel.firstArgs = args; | ||
| 307 | |||
| 308 | // Create shared onRefetch handler for the debounced run | ||
| 309 | const onRefetch = (cb: () => Promise<void>) => { | ||
| 310 | channel.refetches.push(cb); | ||
| 311 | }; | ||
| 312 | |||
| 313 | // Create shared optimistic helpers instance for this debounced run | ||
| 314 | channel.helpers = this.#client.getOptimisticHelpers({ | ||
| 315 | onRestore: (cb: () => void) => { | ||
| 316 | channel.rollbacks.push(cb); | ||
| 317 | }, | ||
| 318 | onRefetch, | ||
| 319 | }); | ||
| 320 | } | ||
| 321 | |||
| 322 | // Apply optimistic update | ||
| 323 | let expired = false; | ||
| 324 | const onRestore = (cb: () => void) => { | ||
| 325 | if (expired) { | ||
| 326 | throw new Error( | ||
| 327 | "Can only call onRestore from within the optimistic update function.", | ||
| 328 | ); | ||
| 329 | } | ||
| 330 | channel.rollbacks.push(cb); | ||
| 331 | }; | ||
| 332 | |||
| 333 | try { | ||
| 334 | this.#options.optimistic( | ||
| 335 | { | ||
| 336 | ...this.#client.context, | ||
| 337 | onRestore, | ||
| 338 | helpers: channel.helpers!, | ||
| 339 | }, | ||
| 340 | ...args, | ||
| 341 | ); | ||
| 342 | } catch (error) { | ||
| 343 | expired = true; | ||
| 344 | // We don't know how many were added, so we can't do partial rollback easily | ||
| 345 | // For simplicity, rollback everything and reject | ||
| 346 | let next; | ||
| 347 | while ((next = channel.rollbacks.pop())) next(); | ||
| 348 | this.#resetDebouncedState(channel); | ||
| 349 | return Promise.reject(error); | ||
| 350 | } | ||
| 351 | expired = true; | ||
| 352 | |||
| 353 | // Create promise for this caller | ||
| 354 | const { promise, resolve, reject } = Promise.withResolvers<Result>(); | ||
| 355 | channel.pending.push({ args, resolve, reject, reportSuccessGlobally }); | ||
| 356 | |||
| 357 | // Set status to waiting and notify | ||
| 358 | if (channel.status === "idle") { | ||
| 359 | channel.status = "waiting"; | ||
| 360 | this.#notify(channel, "waiting"); | ||
| 361 | } | ||
| 362 | |||
| 363 | // Schedule commit based on mode | ||
| 364 | this.#scheduleCommit(key, channel); | ||
| 365 | |||
| 366 | return promise; | ||
| 367 | } | ||
| 368 | |||
| 369 | #scheduleCommit( | ||
| 370 | key: string, | ||
| 371 | channel: DebouncedChannel< | ||
| 372 | Args, | ||
| 373 | Result, | ||
| 374 | Optimistic, | ||
| 375 | Config["optimisticHelpers"] | ||
| 376 | >, | ||
| 377 | ) { | ||
| 378 | const time = this.#options.time ?? 200; | ||
| 379 | |||
| 380 | if (this.#options.mode !== "throttle") { | ||
| 381 | // Debounce: reset timer on each call | ||
| 382 | if (channel.timer !== null) { | ||
| 383 | clearTimeout(channel.timer); | ||
| 384 | } | ||
| 385 | channel.timer = setTimeout(() => this.#commit(key, channel), time); | ||
| 386 | } else { | ||
| 387 | // Throttle: commit immediately if enough time passed, otherwise wait | ||
| 388 | // Use status to track if a commit is in progress | ||
| 389 | if (channel.timer === null && channel.status === "waiting") { | ||
| 390 | const elapsed = Date.now() - channel.lastCommitTime; | ||
| 391 | if (elapsed >= time) { | ||
| 392 | // Enough time has passed, commit immediately | ||
| 393 | this.#commit(key, channel); | ||
| 394 | } else { | ||
| 395 | // Wait for remaining time | ||
| 396 | channel.timer = setTimeout( | ||
| 397 | () => this.#commit(key, channel), | ||
| 398 | time - elapsed, | ||
| 399 | ); | ||
| 400 | } | ||
| 401 | } | ||
| 402 | // If timer exists or commit is in progress, do nothing - will commit when ready | ||
| 403 | } | ||
| 404 | } | ||
| 405 | |||
| 406 | #commit( | ||
| 407 | key: string, | ||
| 408 | channel: DebouncedChannel< | ||
| 409 | Args, | ||
| 410 | Result, | ||
| 411 | Optimistic, | ||
| 412 | Config["optimisticHelpers"] | ||
| 413 | >, | ||
| 414 | ) { | ||
| 415 | // Clear timer | ||
| 416 | if (channel.timer !== null) { | ||
| 417 | clearTimeout(channel.timer); | ||
| 418 | channel.timer = null; | ||
| 419 | } | ||
| 420 | |||
| 421 | // Safety check | ||
| 422 | if (channel.firstArgs === null || channel.initial === null) { | ||
| 423 | this.#setIdle(key, channel); | ||
| 424 | return; | ||
| 425 | } | ||
| 426 | |||
| 427 | const firstArgs = channel.firstArgs; | ||
| 428 | const initial = channel.initial; | ||
| 429 | const pendingItems = [...channel.pending]; | ||
| 430 | const rollbacks = [...channel.rollbacks]; | ||
| 431 | const refetchCallbacks = [...channel.refetches]; | ||
| 432 | |||
| 433 | // Get current snapshot | ||
| 434 | const current = this.#options.getValue({ | ||
| 435 | ...this.#client.context, | ||
| 436 | args: firstArgs, | ||
| 437 | }); | ||
| 438 | |||
| 439 | // Check if anything changed | ||
| 440 | if (this.#client.deepEquals(initial, current)) { | ||
| 441 | // No change - resolve all pending with a null result and reset | ||
| 442 | pendingItems.forEach(({ resolve }) => resolve(null as Result)); | ||
| 443 | this.#resetDebouncedState(channel); | ||
| 444 | this.#setIdle(key, channel); | ||
| 445 | return; | ||
| 446 | } | ||
| 447 | |||
| 448 | // Set status to mutating | ||
| 449 | channel.status = "mutating"; | ||
| 450 | this.#notify(channel, "mutating"); | ||
| 451 | |||
| 452 | // Clear debounced state before async operation (but keep rollbacks/refetches for error case) | ||
| 453 | channel.initial = null; | ||
| 454 | channel.firstArgs = null; | ||
| 455 | channel.pending = []; | ||
| 456 | channel.rollbacks = []; | ||
| 457 | channel.refetches = []; | ||
| 458 | |||
| 459 | // Call commit | ||
| 460 | this.#options | ||
| 461 | .commit({ | ||
| 462 | ...this.#client.context, | ||
| 463 | args: firstArgs, | ||
| 464 | initial, | ||
| 465 | current, | ||
| 466 | }) | ||
| 467 | .then((result) => { | ||
| 468 | // Success - rollbacks are discarded (optimistic was correct) | ||
| 469 | // Resolve all pending promises | ||
| 470 | pendingItems.forEach(({ resolve }) => resolve(result)); | ||
| 471 | |||
| 472 | // Report success globally if any of the pending items requested it | ||
| 473 | const shouldReportSuccess = pendingItems.some((item) => | ||
| 474 | item.reportSuccessGlobally | ||
| 475 | ); | ||
| 476 | if (shouldReportSuccess) { | ||
| 477 | const message = this.#describeResult( | ||
| 478 | firstArgs, | ||
| 479 | initial, | ||
| 480 | current, | ||
| 481 | result, | ||
| 482 | ); | ||
| 483 | if (message && this.#client.reportSuccess) { | ||
| 484 | this.#client.reportSuccess(message); | ||
| 485 | } | ||
| 486 | } | ||
| 487 | |||
| 488 | // Record commit time for throttle mode | ||
| 489 | channel.lastCommitTime = Date.now(); | ||
| 490 | |||
| 491 | // Refetch | ||
| 492 | channel.status = "refetching"; | ||
| 493 | this.#notify(channel, "refetching", result); | ||
| 494 | // Call refetch and all refetch callbacks in parallel | ||
| 495 | Promise.allSettled([ | ||
| 496 | this.#options.refetch?.(), | ||
| 497 | ...refetchCallbacks.map((cb) => cb()), | ||
| 498 | ]).then((results) => { | ||
| 499 | // Report any errors from refetch or callbacks | ||
| 500 | results.forEach((result) => { | ||
| 501 | if (result.status === "rejected") { | ||
| 502 | const message = `Failed to refetch after ${ | ||
| 503 | this.describe(...firstArgs) | ||
| 504 | }: ${errMessage(result.reason)}`; | ||
| 505 | this.#client.reportError(message, result.reason); | ||
| 506 | } | ||
| 507 | }); | ||
| 508 | }).finally(() => { | ||
| 509 | // Check if new calls came in during the commit | ||
| 510 | if (channel.pending.length > 0) { | ||
| 511 | // There are pending calls that need to be committed | ||
| 512 | channel.status = "waiting"; | ||
| 513 | this.#notify(channel, "waiting"); | ||
| 514 | this.#scheduleCommit(key, channel); | ||
| 515 | } else { | ||
| 516 | this.#setIdle(key, channel); | ||
| 517 | } | ||
| 518 | }); | ||
| 519 | }) | ||
| 520 | .catch((error) => { | ||
| 521 | // Error - call all rollbacks in reverse order | ||
| 522 | let next; | ||
| 523 | const rollbacksCopy = [...rollbacks]; | ||
| 524 | while ((next = rollbacksCopy.pop())) next(); | ||
| 525 | |||
| 526 | // Reject all pending promises | ||
| 527 | pendingItems.forEach(({ reject }) => reject(error)); | ||
| 528 | |||
| 529 | // Notify listeners of the error | ||
| 530 | this.#notify(channel, "mutating", null, error); | ||
| 531 | |||
| 532 | // Refetch to restore correct state | ||
| 533 | channel.status = "refetching"; | ||
| 534 | this.#notify(channel, "refetching", null, error); | ||
| 535 | // Call refetch and all refetch callbacks in parallel | ||
| 536 | Promise.allSettled([ | ||
| 537 | this.#options.refetch?.(), | ||
| 538 | ...refetchCallbacks.map((cb) => cb()), | ||
| 539 | ]).then((results) => { | ||
| 540 | // Report any errors from refetch or callbacks | ||
| 541 | results.forEach((result) => { | ||
| 542 | if (result.status === "rejected") { | ||
| 543 | const message = `Failed to refetch after ${ | ||
| 544 | this.describe(...firstArgs) | ||
| 545 | }: ${errMessage(result.reason)}`; | ||
| 546 | this.#client.reportError(message, result.reason); | ||
| 547 | } | ||
| 548 | }); | ||
| 549 | }).finally(() => { | ||
| 550 | // Check if new calls came in during the commit | ||
| 551 | if (channel.pending.length > 0) { | ||
| 552 | // There are pending calls that need to be committed | ||
| 553 | channel.status = "waiting"; | ||
| 554 | this.#notify(channel, "waiting"); | ||
| 555 | this.#scheduleCommit(key, channel); | ||
| 556 | } else { | ||
| 557 | this.#setIdle(key, channel); | ||
| 558 | } | ||
| 559 | }); | ||
| 560 | }); | ||
| 561 | } | ||
| 562 | } | ||
src/mod.ts+10-6| ... | @@ -1,9 +1,12 @@ | ... | @@ -1,9 +1,12 @@ |
| 1 | export type { MutationOptions, OptimisticContext } from "./queued.ts"; | ||
| 2 | export type { | 1 | export type { |
| 3 | BatchCommitContext, | 2 | BlockingMutationOptions, |
| 4 | BatchMutationOptions, | 3 | BlockingOptimisticContext, |
| 5 | BatchOptimisticContext, | 4 | } from "./blocking.ts"; |
| 6 | } from "./batch.ts"; | 5 | export type { |
| 6 | DebouncedCommitContext, | ||
| 7 | DebouncedMutationOptions, | ||
| 8 | DebouncedOptimisticContext, | ||
| 9 | } from "./debounced.ts"; | ||
| 7 | export { | 10 | export { |
| 8 | MutationClient, | 11 | MutationClient, |
| 9 | type MutationClientConfig, | 12 | type MutationClientConfig, |
| ... | @@ -13,6 +16,7 @@ export { | ... | @@ -13,6 +16,7 @@ export { |
| 13 | export type { Mutation, MutationEvent } from "./types.ts"; | 16 | export type { Mutation, MutationEvent } from "./types.ts"; |
| 14 | export { | 17 | export { |
| 15 | createMutationButton, | 18 | createMutationButton, |
| 19 | type MutationButtonComponent, | ||
| 16 | type MutationButtonProps, | 20 | type MutationButtonProps, |
| 17 | useMutate, | 21 | useMutate, |
| 18 | type UseMutateError, | 22 | type UseMutateError, |
| ... | @@ -20,4 +24,4 @@ export { | ... | @@ -20,4 +24,4 @@ export { |
| 20 | type UseMutateResult, | 24 | type UseMutateResult, |
| 21 | type UseMutateResultBase, | 25 | type UseMutateResultBase, |
| 22 | type UseMutateSuccess, | 26 | type UseMutateSuccess, |
| 23 | } from "./react.tsx"; | 27 | } from "./react.ts"; |
src/queued.ts deleted-347| ... | @@ -1,347 +0,0 @@ | ||
| 1 | import type { MutationClient, MutationClientFromConfig } from "./client.ts"; | ||
| 2 | import type { MutationClientConfig } from "./client.ts"; | ||
| 3 | import type { Mutation, MutationEvent } from "./types.ts"; | ||
| 4 | import { message as errMessage } from "@clo/lib/error.ts"; | ||
| 5 | |||
| 6 | /** | ||
| 7 | * Argument to `defineMutation`. | ||
| 8 | * @template Args - the parameters to the mutation | ||
| 9 | * @template Result - the result of the API call | ||
| 10 | * @template Config - global values and helpers from `MutationContext` | ||
| 11 | */ | ||
| 12 | export interface MutationOptions< | ||
| 13 | Args extends unknown[], | ||
| 14 | Result, | ||
| 15 | Config extends MutationClientConfig, | ||
| 16 | > { | ||
| 17 | /** | ||
| 18 | * This function is only responsible for performing the underlying API call, | ||
| 19 | * syncronizing the optimistic state with reality. Throw on failure. A rest | ||
| 20 | * params type is used to allow type inference. Place this function first to | ||
| 21 | * ensure TypeScript correctly infers the argument type for the rest of the | ||
| 22 | * functions. | ||
| 23 | * | ||
| 24 | * In practice, optimistic context is never needed in this function, but it | ||
| 25 | * is provided as the `this` value if you truly desire it. | ||
| 26 | */ | ||
| 27 | mutate: (this: Config["context"], ...args: Args) => Promise<Result>; | ||
| 28 | /** | ||
| 29 | * Used in error messages and debug tools. | ||
| 30 | * Phrase it considering the template `Failed to ${describe(...)}` | ||
| 31 | */ | ||
| 32 | describe: string | ((context: Config["context"] & { args: Args }) => string); | ||
| 33 | /** | ||
| 34 | * Used in success messages. | ||
| 35 | * Phrase it as a complete success message, e.g., "Deleted item successfully" | ||
| 36 | * Set to null to suppress success reporting. | ||
| 37 | */ | ||
| 38 | describeResult?: string | ((context: Config["context"] & { args: Args; result: Result }) => string) | null; | ||
| 39 | /** | ||
| 40 | * Specifying the optimistic strategy is required. To disable, pass an empty | ||
| 41 | * function with a comment to document why it isn't needed. | ||
| 42 | */ | ||
| 43 | optimistic: (context: OptimisticContext<Args, Result, Config>) => void; | ||
| 44 | /** | ||
| 45 | * Refetch all of the data this mutation could have affected. | ||
| 46 | * Normally, optimistic helpers will perform | ||
| 47 | * This is called automatically on errors. | ||
| 48 | */ | ||
| 49 | refetch?: (context: Config["context"] & { args: Args }) => Promise<void>; | ||
| 50 | /** | ||
| 51 | * If the optimistic updator function is perfect, then this may be set to false. | ||
| 52 | * @default true | ||
| 53 | */ | ||
| 54 | refetchOnSuccess?: boolean; | ||
| 55 | /** | ||
| 56 | * A key to associate related items. For example, returning a user ID. If | ||
| 57 | * specifying, then all mutations of the same key will evaluate in serial, | ||
| 58 | * but optimistic updates will apply instantly. | ||
| 59 | */ | ||
| 60 | key?: (context: Config["context"] & { args: Args }) => string | string[]; | ||
| 61 | } | ||
| 62 | |||
| 63 | export type OptimisticContext< | ||
| 64 | Args extends unknown[], | ||
| 65 | Result, | ||
| 66 | Config extends MutationClientConfig, | ||
| 67 | > = Config["context"] & { | ||
| 68 | args: Args; | ||
| 69 | helpers: Config["optimisticHelpers"]; | ||
| 70 | /** Add an event listener to roll back the update */ | ||
| 71 | onRestore: (cb: () => void) => void; | ||
| 72 | /** Add an event listener to apply `Result` to the store. */ | ||
| 73 | onSuccess: (cb: (result: Result) => void) => void; | ||
| 74 | }; | ||
| 75 | |||
| 76 | interface Channel<Args extends unknown[], Result> { | ||
| 77 | listeners: Set<(update: MutationEvent<Result>) => void>; | ||
| 78 | status: "idle" | "mutating" | "refetching"; | ||
| 79 | rollbacks: Array<() => void>; | ||
| 80 | refetches: Array<() => Promise<void>>; | ||
| 81 | queue: Array<Item<Args, Result>>; | ||
| 82 | } | ||
| 83 | |||
| 84 | interface Item<Args extends unknown[], Result> { | ||
| 85 | args: Args; | ||
| 86 | rollbacks: number; | ||
| 87 | onSuccess: Array<(result: Result) => void>; | ||
| 88 | resolve: (result: Result) => void; | ||
| 89 | reject: (error: unknown) => void; | ||
| 90 | } | ||
| 91 | |||
| 92 | export class QueuedMutation< | ||
| 93 | Args extends unknown[], | ||
| 94 | Result, | ||
| 95 | Config extends MutationClientConfig, | ||
| 96 | > implements Mutation<Args, Result> { | ||
| 97 | #options: MutationOptions<Args, Result, Config>; | ||
| 98 | #client: MutationClientFromConfig<Config>; | ||
| 99 | #queues: Map<string, Channel<Args, Result>> = new Map(); | ||
| 100 | client: MutationClientFromConfig<Config>; | ||
| 101 | |||
| 102 | constructor( | ||
| 103 | client: MutationClient<Config["context"], Config["optimisticHelpers"]>, | ||
| 104 | options: MutationOptions<Args, Result, Config>, | ||
| 105 | ) { | ||
| 106 | this.#options = options; | ||
| 107 | this.#client = client; | ||
| 108 | this.client = client; | ||
| 109 | } | ||
| 110 | |||
| 111 | key(args: Args) { | ||
| 112 | const k = this.#options.key?.({ ...this.#client.context, args }) ?? | ||
| 113 | "shared"; | ||
| 114 | return JSON.stringify(k); | ||
| 115 | } | ||
| 116 | |||
| 117 | #getOrPutChannel(key: string) { | ||
| 118 | let channel = this.#queues.get(key); | ||
| 119 | if (!channel) { | ||
| 120 | const rollbacks: Array<() => []> = []; | ||
| 121 | channel = { | ||
| 122 | listeners: new Set(), | ||
| 123 | status: "idle", | ||
| 124 | rollbacks, | ||
| 125 | refetches: [], | ||
| 126 | queue: [], | ||
| 127 | }; | ||
| 128 | this.#queues.set(key, channel); | ||
| 129 | } | ||
| 130 | return channel; | ||
| 131 | } | ||
| 132 | |||
| 133 | subscribe( | ||
| 134 | key: string, | ||
| 135 | cb: (update: MutationEvent<Result>) => void, | ||
| 136 | ): () => void { | ||
| 137 | const channel = this.#getOrPutChannel(key); | ||
| 138 | channel.listeners.add(cb); | ||
| 139 | return () => channel?.listeners.delete(cb); | ||
| 140 | } | ||
| 141 | |||
| 142 | #notify( | ||
| 143 | channel: Channel<Args, Result>, | ||
| 144 | status: MutationEvent<Result>["status"], | ||
| 145 | result: Result | null = null, | ||
| 146 | error: unknown = null, | ||
| 147 | ) { | ||
| 148 | const event: MutationEvent<Result> = { status, result, error }; | ||
| 149 | channel.listeners.forEach((cb) => cb(event)); | ||
| 150 | } | ||
| 151 | |||
| 152 | #setIdle(key: string, channel: Channel<Args, Result>) { | ||
| 153 | channel.status = "idle"; | ||
| 154 | // Discard any unconsumed refetch callbacks | ||
| 155 | channel.refetches = []; | ||
| 156 | this.#notify(channel, "idle", null, null); | ||
| 157 | // Clean up the channel if there are no listeners | ||
| 158 | if (channel.listeners.size === 0) { | ||
| 159 | this.#queues.delete(key); | ||
| 160 | } | ||
| 161 | } | ||
| 162 | |||
| 163 | describe(...args: Args): string { | ||
| 164 | const { describe } = this.#options; | ||
| 165 | return typeof describe === "function" | ||
| 166 | ? describe({ ...this.#client.context, args }) | ||
| 167 | : describe; | ||
| 168 | } | ||
| 169 | |||
| 170 | describeResult(args: Args, result: Result): string | undefined { | ||
| 171 | const { describeResult } = this.#options; | ||
| 172 | if (describeResult === null || describeResult === undefined) return undefined; | ||
| 173 | return typeof describeResult === "function" | ||
| 174 | ? describeResult({ ...this.#client.context, args, result }) | ||
| 175 | : describeResult; | ||
| 176 | } | ||
| 177 | |||
| 178 | /** Calling the mutation in a global scope. Errors are turned into UI toasts. */ | ||
| 179 | run(...args: Args) { | ||
| 180 | this.runAndReturn(...args).then((result) => { | ||
| 181 | const message = this.describeResult(args, result); | ||
| 182 | if (message && this.#client.reportSuccess) { | ||
| 183 | this.#client.reportSuccess(message); | ||
| 184 | } | ||
| 185 | }).catch((error) => { | ||
| 186 | const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`; | ||
| 187 | this.#client.reportError(message, error); | ||
| 188 | }); | ||
| 189 | } | ||
| 190 | |||
| 191 | /** Calls the mutation, treating the errors as promise rejection. */ | ||
| 192 | runAndReturn(...args: Args): Promise<Result> { | ||
| 193 | const key = this.key(args); | ||
| 194 | const channel = this.#getOrPutChannel(key); | ||
| 195 | |||
| 196 | const onSuccess: Array<(result: Result) => void> = []; | ||
| 197 | let expired = false; | ||
| 198 | let rollbacks = 0; | ||
| 199 | const onRestore = (cb: () => void) => { | ||
| 200 | if (expired) { | ||
| 201 | throw new Error( | ||
| 202 | "Can only call onRestore from within the optimistic update function.", | ||
| 203 | ); | ||
| 204 | } | ||
| 205 | channel.rollbacks.push(cb); | ||
| 206 | rollbacks += 1; | ||
| 207 | }; | ||
| 208 | const onRefetch = (cb: () => Promise<void>) => { | ||
| 209 | if (expired) { | ||
| 210 | throw new Error( | ||
| 211 | "Can only call onRefetch from within the optimistic update function.", | ||
| 212 | ); | ||
| 213 | } | ||
| 214 | channel.refetches.push(cb); | ||
| 215 | }; | ||
| 216 | |||
| 217 | try { | ||
| 218 | this.#options.optimistic({ | ||
| 219 | args, | ||
| 220 | helpers: this.#client.getOptimisticHelpers({ | ||
| 221 | onRestore, | ||
| 222 | onRefetch, | ||
| 223 | }), | ||
| 224 | onRestore, | ||
| 225 | onSuccess(cb) { | ||
| 226 | if (expired) { | ||
| 227 | throw new Error( | ||
| 228 | "Can only call onSuccess from within the optimistic update function.", | ||
| 229 | ); | ||
| 230 | } | ||
| 231 | onSuccess.push(cb); | ||
| 232 | }, | ||
| 233 | }); | ||
| 234 | } catch (error) { | ||
| 235 | expired = true; | ||
| 236 | let next; | ||
| 237 | while ( | ||
| 238 | next = | ||
| 239 | channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0] | ||
| 240 | ) { | ||
| 241 | next(); | ||
| 242 | } | ||
| 243 | return Promise.reject(error); | ||
| 244 | } | ||
| 245 | expired = true; | ||
| 246 | |||
| 247 | const { promise, resolve, reject } = Promise.withResolvers<Result>(); | ||
| 248 | channel.queue.push({ | ||
| 249 | args, | ||
| 250 | rollbacks, | ||
| 251 | onSuccess, | ||
| 252 | resolve, | ||
| 253 | reject, | ||
| 254 | }); | ||
| 255 | |||
| 256 | if (channel.status === "idle") { | ||
| 257 | this.#executeNext(key, channel); | ||
| 258 | } | ||
| 259 | |||
| 260 | return promise; | ||
| 261 | } | ||
| 262 | |||
| 263 | #executeNext(key: string, channel: Channel<Args, Result>) { | ||
| 264 | const item = channel.queue.shift(); | ||
| 265 | if (!item) { | ||
| 266 | this.#setIdle(key, channel); | ||
| 267 | return; | ||
| 268 | } | ||
| 269 | |||
| 270 | const { args, onSuccess, resolve, reject } = item; | ||
| 271 | channel.status = "mutating"; | ||
| 272 | this.#notify(channel, "mutating"); | ||
| 273 | |||
| 274 | this.#options.mutate.call(this.#client.context, ...args).then((result) => { | ||
| 275 | // remove rollbacks and apply optimistic success handlers | ||
| 276 | channel.rollbacks.splice(0, item.rollbacks); | ||
| 277 | onSuccess.forEach((cb) => cb(result)); | ||
| 278 | |||
| 279 | if (this.#options.refetchOnSuccess !== false) { | ||
| 280 | channel.status = "refetching"; | ||
| 281 | this.#notify(channel, "refetching", result); | ||
| 282 | // Call refetch and all refetch callbacks in parallel | ||
| 283 | const refetchCallbacks = channel.refetches.splice(0); | ||
| 284 | Promise.allSettled([ | ||
| 285 | this.#options.refetch?.({ | ||
| 286 | ...this.#client.context, | ||
| 287 | args, | ||
| 288 | }), | ||
| 289 | ...refetchCallbacks.map((cb) => cb()), | ||
| 290 | ]).then((results) => { | ||
| 291 | // Report any errors from refetch or callbacks | ||
| 292 | results.forEach((result) => { | ||
| 293 | if (result.status === "rejected") { | ||
| 294 | const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`; | ||
| 295 | this.#client.reportError(message, result.reason); | ||
| 296 | } | ||
| 297 | }); | ||
| 298 | }).finally(() => { | ||
| 299 | this.#executeNext(key, channel); | ||
| 300 | }); | ||
| 301 | } else { | ||
| 302 | // Discard refetch callbacks if refetchOnSuccess is false | ||
| 303 | channel.refetches = []; | ||
| 304 | this.#executeNext(key, channel); | ||
| 305 | } | ||
| 306 | resolve(result); | ||
| 307 | }, (error) => { | ||
| 308 | // if an error happens, then every rollback is called in reverse order | ||
| 309 | let next; | ||
| 310 | while (next = channel.rollbacks.pop()) next(); | ||
| 311 | |||
| 312 | // Cancel all remaining items in the queue | ||
| 313 | const remainingItems = channel.queue.splice(0); | ||
| 314 | remainingItems.forEach((queuedItem) => { | ||
| 315 | queuedItem.reject(error); | ||
| 316 | }); | ||
| 317 | |||
| 318 | // Notify listeners of the error | ||
| 319 | this.#notify(channel, "mutating", null, error); | ||
| 320 | |||
| 321 | // Refetch to restore correct state | ||
| 322 | channel.status = "refetching"; | ||
| 323 | this.#notify(channel, "refetching", null, error); | ||
| 324 | // Call refetch and all refetch callbacks in parallel | ||
| 325 | const refetchCallbacks = channel.refetches.splice(0); | ||
| 326 | Promise.allSettled([ | ||
| 327 | this.#options.refetch?.({ | ||
| 328 | ...this.#client.context, | ||
| 329 | args, | ||
| 330 | }), | ||
| 331 | ...refetchCallbacks.map((cb) => cb()), | ||
| 332 | ]).then((results) => { | ||
| 333 | // Report any errors from refetch or callbacks | ||
| 334 | results.forEach((result) => { | ||
| 335 | if (result.status === "rejected") { | ||
| 336 | const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`; | ||
| 337 | this.#client.reportError(message, result.reason); | ||
| 338 | } | ||
| 339 | }); | ||
| 340 | }).finally(() => { | ||
| 341 | this.#setIdle(key, channel); | ||
| 342 | }); | ||
| 343 | |||
| 344 | reject(error); | ||
| 345 | }); | ||
| 346 | } | ||
| 347 | } | ||
src/react.ts created+423| ... | @@ -0,0 +1,423 @@ | ||
| 1 | import { | ||
| 2 | type FC, | ||
| 3 | type MouseEvent, | ||
| 4 | type MouseEventHandler, | ||
| 5 | type ReactNode, | ||
| 6 | useCallback, | ||
| 7 | useEffect, | ||
| 8 | useState, | ||
| 9 | } from "react"; | ||
| 10 | import { message as errMessage } from "@clo/lib/error.ts"; | ||
| 11 | import type { Mutation } from "./types.ts"; | ||
| 12 | import { jsx } from "react/jsx-runtime"; | ||
| 13 | |||
| 14 | /** | ||
| 15 | * Subscribe to a mutation's status, as well as accessing a local `run` method. | ||
| 16 | * The resulting mutation can be used directly or passed to a {@link createMutationButton|mutation button}. | ||
| 17 | */ | ||
| 18 | export function useMutate< | ||
| 19 | Args extends unknown[], | ||
| 20 | Result, | ||
| 21 | >( | ||
| 22 | mutation: Mutation<Args, Result> | null, | ||
| 23 | ): UseMutateResult<Args, Result> { | ||
| 24 | const [_, setRerender] = useState(0); | ||
| 25 | const [observer] = useState(() => new Observer<Args, Result>(setRerender)); | ||
| 26 | useEffect(() => () => void observer.reset(), []); | ||
| 27 | if (mutation !== observer.mutation) { | ||
| 28 | observer.mutation = mutation; | ||
| 29 | observer.reset(); | ||
| 30 | } | ||
| 31 | return observer.binding; | ||
| 32 | } | ||
| 33 | |||
| 34 | export type UseMutateResult<Args extends unknown[], Result> = | ||
| 35 | & UseMutateResultBase<Args, Result> | ||
| 36 | & ( | ||
| 37 | | UseMutateSuccess<Result> | ||
| 38 | | UseMutateError | ||
| 39 | | UseMutateIdle | ||
| 40 | ); | ||
| 41 | |||
| 42 | export interface UseMutateResultBase<Args extends unknown[], Result> { | ||
| 43 | run: (...args: Args) => void; | ||
| 44 | runWithResult: (...args: Args) => Promise<Result>; | ||
| 45 | clear: () => void; | ||
| 46 | } | ||
| 47 | |||
| 48 | export interface UseMutateSuccess<Result> { | ||
| 49 | status: "success"; | ||
| 50 | result: Result; | ||
| 51 | error: undefined; | ||
| 52 | errorMessage: undefined; | ||
| 53 | /** `true` when a `mutate` function is currently running. */ | ||
| 54 | isMutating: false; | ||
| 55 | /** `true` when a loading indicator should be shown. */ | ||
| 56 | isPending: false; | ||
| 57 | /** `true` when a mutation has completed and has a result. */ | ||
| 58 | isSuccess: true; | ||
| 59 | /** `true` when a mutation has failed. */ | ||
| 60 | isError: false; | ||
| 61 | /** `true` when there is optimistic state applied. */ | ||
| 62 | isOptimisticData: boolean; | ||
| 63 | } | ||
| 64 | export interface UseMutateError { | ||
| 65 | status: "error"; | ||
| 66 | result: undefined; | ||
| 67 | error: unknown; | ||
| 68 | /** User-friendly in this format: `Failed to {action}: {details}` */ | ||
| 69 | errorMessage: string; | ||
| 70 | /** `true` when a `mutate` function is currently running. */ | ||
| 71 | isMutating: false; | ||
| 72 | /** `true` when a loading indicator should be shown. */ | ||
| 73 | isPending: false; | ||
| 74 | /** `true` when a mutation has completed and has a result. */ | ||
| 75 | isSuccess: false; | ||
| 76 | /** `true` when a mutation has failed. */ | ||
| 77 | isError: true; | ||
| 78 | /** `true` when there is optimistic state applied. */ | ||
| 79 | isOptimisticData: boolean; | ||
| 80 | } | ||
| 81 | export interface UseMutateIdle { | ||
| 82 | status: "idle" | "mutating"; | ||
| 83 | result: undefined; | ||
| 84 | error: undefined; | ||
| 85 | errorMessage: undefined; | ||
| 86 | /** `true` when a `mutate` function is currently running. */ | ||
| 87 | isMutating: boolean; | ||
| 88 | /** `true` when a loading indicator should be shown. */ | ||
| 89 | isPending: boolean; | ||
| 90 | /** `true` when a mutation has completed and has a result. */ | ||
| 91 | isSuccess: false; | ||
| 92 | /** `true` when a mutation has failed. */ | ||
| 93 | isError: false; | ||
| 94 | /** `true` when there is optimistic state applied. */ | ||
| 95 | isOptimisticData: boolean; | ||
| 96 | } | ||
| 97 | |||
| 98 | type AnyMutationStateWithoutRun<Result> = | ||
| 99 | & Omit< | ||
| 100 | UseMutateIdle, | ||
| 101 | "status" | "result" | "error" | "isSuccess" | "isError" | "errorMessage" | ||
| 102 | > | ||
| 103 | & { | ||
| 104 | status: "idle" | "mutating" | "error" | "success"; | ||
| 105 | result: undefined | Result; | ||
| 106 | error: undefined | unknown; | ||
| 107 | errorMessage: undefined | string; | ||
| 108 | isSuccess: boolean; | ||
| 109 | isError: boolean; | ||
| 110 | }; | ||
| 111 | |||
| 112 | export type AnyMutationState<Args extends unknown[], Result> = | ||
| 113 | & AnyMutationStateWithoutRun<Result> | ||
| 114 | & UseMutateResultBase<Args, Result>; | ||
| 115 | |||
| 116 | function initialState() { | ||
| 117 | return { | ||
| 118 | status: "idle", | ||
| 119 | result: undefined, | ||
| 120 | error: undefined, | ||
| 121 | errorMessage: undefined, | ||
| 122 | isMutating: false, | ||
| 123 | isPending: false, | ||
| 124 | isSuccess: false, | ||
| 125 | isError: false, | ||
| 126 | isOptimisticData: false, | ||
| 127 | } as const; | ||
| 128 | } | ||
| 129 | |||
| 130 | class Observer<Args extends unknown[], Result> { | ||
| 131 | setRerender: (fn: number) => void; | ||
| 132 | mutation: Mutation<Args, Result> | null = null; | ||
| 133 | unsubscribe: (() => void) | null = null; | ||
| 134 | currentKey: string | null = null; | ||
| 135 | currentArgs: Args | null = null; | ||
| 136 | |||
| 137 | constructor(setRerender: (fn: number) => void) { | ||
| 138 | this.setRerender = setRerender; | ||
| 139 | } | ||
| 140 | |||
| 141 | watched: Set<string> = new Set(); | ||
| 142 | state: AnyMutationStateWithoutRun<Result> = initialState(); | ||
| 143 | setState(newState: Partial<AnyMutationStateWithoutRun<Result>>) { | ||
| 144 | let updateUi = false; | ||
| 145 | const current: Record<string, unknown> = this.state; | ||
| 146 | for (const [key, value] of Object.entries(newState)) { | ||
| 147 | if (value !== current[key]) { | ||
| 148 | current[key] = value; | ||
| 149 | updateUi ||= this.watched.has(key); | ||
| 150 | } | ||
| 151 | } | ||
| 152 | if (updateUi) { | ||
| 153 | this.setRerender(Math.random()); | ||
| 154 | } | ||
| 155 | } | ||
| 156 | |||
| 157 | reset() { | ||
| 158 | this.unsubscribe?.(); | ||
| 159 | this.unsubscribe = null; | ||
| 160 | this.currentKey = null; | ||
| 161 | this.state = initialState(); | ||
| 162 | } | ||
| 163 | |||
| 164 | computeErrorMessage(error: unknown): string | undefined { | ||
| 165 | if (!error) return undefined; | ||
| 166 | const mutation = this.mutation; | ||
| 167 | if (!mutation || !this.currentArgs) return errMessage(error); | ||
| 168 | return `Failed to ${mutation.describe(...this.currentArgs)}: ${ | ||
| 169 | errMessage(error) | ||
| 170 | }`; | ||
| 171 | } | ||
| 172 | |||
| 173 | run(...args: Args) { | ||
| 174 | const mutation = this.mutation; | ||
| 175 | if (!mutation) return; | ||
| 176 | this.currentArgs = args; | ||
| 177 | const key = mutation.key(args); | ||
| 178 | if (key !== this.currentKey) { | ||
| 179 | this.currentKey = key; | ||
| 180 | this.unsubscribe?.(); | ||
| 181 | this.unsubscribe = mutation.subscribe( | ||
| 182 | mutation.key(args), | ||
| 183 | ({ status, error, result }) => { | ||
| 184 | if (status === "idle") { | ||
| 185 | this.setState({ | ||
| 186 | isMutating: false, | ||
| 187 | isPending: false, | ||
| 188 | isOptimisticData: false, | ||
| 189 | }); | ||
| 190 | return; | ||
| 191 | } | ||
| 192 | const hasError = error != null; | ||
| 193 | const hasResult = result != null; | ||
| 194 | |||
| 195 | this.setState({ | ||
| 196 | status: hasError | ||
| 197 | ? "error" | ||
| 198 | : hasResult | ||
| 199 | ? "success" | ||
| 200 | : status === "mutating" | ||
| 201 | ? "mutating" | ||
| 202 | : "idle", | ||
| 203 | error: error ?? undefined, | ||
| 204 | errorMessage: this.computeErrorMessage(error ?? undefined), | ||
| 205 | result: result ?? undefined, | ||
| 206 | isMutating: status === "mutating", | ||
| 207 | isPending: status === "mutating" || status === "refetching", | ||
| 208 | isSuccess: hasResult && !hasError, | ||
| 209 | isError: hasError, | ||
| 210 | isOptimisticData: status === "waiting" || status === "mutating" || | ||
| 211 | status === "refetching", | ||
| 212 | }); | ||
| 213 | }, | ||
| 214 | ); | ||
| 215 | } | ||
| 216 | // Use global error/success handling if this usage of the hook doesn't check for | ||
| 217 | // errors or success. This makes it act pretty awesome in terms of defaults. | ||
| 218 | // You don't have to worry about result UI, they'll surface exactly once. | ||
| 219 | const watchesError = this.watched.has("isError") || | ||
| 220 | this.watched.has("error") || this.watched.has("errorMessage"); | ||
| 221 | const watchesSuccess = this.watched.has("isSuccess") || | ||
| 222 | this.watched.has("result"); | ||
| 223 | const promise = mutation.runAndReturn(...args) | ||
| 224 | .then((result) => { | ||
| 225 | if (!watchesSuccess && mutation.describeResult) { | ||
| 226 | const message = mutation.describeResult(args, result); | ||
| 227 | if (message && mutation.client.reportSuccess) { | ||
| 228 | mutation.client.reportSuccess(message); | ||
| 229 | } | ||
| 230 | } | ||
| 231 | }); | ||
| 232 | promise.catch((err) => { | ||
| 233 | if (!watchesError) { | ||
| 234 | const message = `Failed to ${mutation.describe(...args)}: ${ | ||
| 235 | errMessage(err) | ||
| 236 | }`; | ||
| 237 | mutation.client.reportError(message, err); | ||
| 238 | } | ||
| 239 | }); | ||
| 240 | return promise; | ||
| 241 | } | ||
| 242 | |||
| 243 | binding: UseMutateResult<Args, Result> = ((self: this) => ({ | ||
| 244 | run(...args) { | ||
| 245 | return self.run(...args); | ||
| 246 | }, | ||
| 247 | runWithResult(...args) { | ||
| 248 | return self.run(...args); | ||
| 249 | }, | ||
| 250 | clear() { | ||
| 251 | self.setState({ | ||
| 252 | status: ["error", "success"].includes(self.state.status) | ||
| 253 | ? "idle" | ||
| 254 | : self.state.status, | ||
| 255 | isError: false, | ||
| 256 | isSuccess: false, | ||
| 257 | error: undefined, | ||
| 258 | errorMessage: undefined, | ||
| 259 | result: undefined, | ||
| 260 | }); | ||
| 261 | }, | ||
| 262 | get status() { | ||
| 263 | self.watched.add("status"); | ||
| 264 | return self.state.status; | ||
| 265 | }, | ||
| 266 | get result() { | ||
| 267 | self.watched.add("result"); | ||
| 268 | return self.state.result; | ||
| 269 | }, | ||
| 270 | get error() { | ||
| 271 | self.watched.add("error"); | ||
| 272 | return self.state.error; | ||
| 273 | }, | ||
| 274 | get errorMessage() { | ||
| 275 | self.watched.add("errorMessage"); | ||
| 276 | return self.state.errorMessage; | ||
| 277 | }, | ||
| 278 | get isMutating() { | ||
| 279 | self.watched.add("isMutating"); | ||
| 280 | return self.state.isMutating; | ||
| 281 | }, | ||
| 282 | get isPending() { | ||
| 283 | self.watched.add("isPending"); | ||
| 284 | return self.state.isPending; | ||
| 285 | }, | ||
| 286 | get isSuccess() { | ||
| 287 | self.watched.add("isSuccess"); | ||
| 288 | return self.state.isSuccess; | ||
| 289 | }, | ||
| 290 | get isError() { | ||
| 291 | self.watched.add("isError"); | ||
| 292 | return self.state.isError; | ||
| 293 | }, | ||
| 294 | get isOptimisticData() { | ||
| 295 | self.watched.add("isOptimisticData"); | ||
| 296 | return self.state.isOptimisticData; | ||
| 297 | }, | ||
| 298 | } as UseMutateResult<Args, Result>))(this); | ||
| 299 | } | ||
| 300 | |||
| 301 | interface BaseButtonProps { | ||
| 302 | onClick: MouseEventHandler<HTMLElement> | undefined; | ||
| 303 | isPending: boolean; | ||
| 304 | } | ||
| 305 | |||
| 306 | export interface MutationButtonComponent<Props> { | ||
| 307 | <Args extends unknown[], Result>( | ||
| 308 | props: | ||
| 309 | & MutationButtonProps<Args, Result> | ||
| 310 | & Props, | ||
| 311 | ): ReactNode; | ||
| 312 | displayName?: string; | ||
| 313 | } | ||
| 314 | |||
| 315 | export interface MutationButtonProps<Args extends unknown[], Result> { | ||
| 316 | mutation: | ||
| 317 | | Mutation<Args, Result> | ||
| 318 | | UseMutateResult<Args, Result>; | ||
| 319 | /** Preventing default will interrupt the mutation */ | ||
| 320 | args: Args | ((e: MouseEvent) => Args | null); | ||
| 321 | /** Preventing default will interrupt the mutation */ | ||
| 322 | onClick?: (e: MouseEvent) => void; | ||
| 323 | |||
| 324 | /** Omitting this will use the global error handler */ | ||
| 325 | onError?: (result: unknown) => void; | ||
| 326 | /** Omitting this will use the global success handler */ | ||
| 327 | onSuccess?: (result: Result) => void; | ||
| 328 | |||
| 329 | /** Global event handlers will still be called! */ | ||
| 330 | onSettled?: ( | ||
| 331 | event: { | ||
| 332 | status: "success"; | ||
| 333 | result: Result; | ||
| 334 | } | { | ||
| 335 | status: "error"; | ||
| 336 | error: unknown; | ||
| 337 | }, | ||
| 338 | ) => void; | ||
| 339 | } | ||
| 340 | |||
| 341 | /** | ||
| 342 | * Wraps a custom button component with logic to execute a mutation. The wrapped | ||
| 343 | * component must accept `onClick` and an `isPending` property. When the inner | ||
| 344 | * component emits `onClick`, that will begin the mutation. This is a trival | ||
| 345 | * abstraction on top of `useMutate`, but with type gymnastics to allow safe | ||
| 346 | * types. | ||
| 347 | */ | ||
| 348 | export function createMutationButton<Props>( | ||
| 349 | // Prevent calling this function if missing `onClick` | ||
| 350 | base: Required<Props> extends BaseButtonProps ? FC<Props> | ||
| 351 | : "Base component is missing required props", | ||
| 352 | ): MutationButtonComponent<Flatten<Omit<Props, keyof BaseButtonProps>>> { | ||
| 353 | const Component = base as ResolveMutationButtonFc<Props, unknown[], unknown>; | ||
| 354 | // apply the generics at a type level to allow `.bind` to work | ||
| 355 | type BareProps = Omit<Props, keyof BaseButtonProps>; | ||
| 356 | const bound = (GenericMutationButton<Props, unknown[], unknown>) | ||
| 357 | // the `as` clause here converts the second and third generic parameter | ||
| 358 | // back into unspecified generics. | ||
| 359 | .bind(null, Component) as MutationButtonComponent<BareProps>; | ||
| 360 | // react devtools loves display names | ||
| 361 | bound.displayName = `MutationButton[${ | ||
| 362 | Component.displayName ?? Component.name | ||
| 363 | }]`; | ||
| 364 | |||
| 365 | return bound; | ||
| 366 | } | ||
| 367 | |||
| 368 | type Identity<T> = T; | ||
| 369 | type Flatten<T> = Identity<{ [K in keyof T]: T[K] }>; | ||
| 370 | type ResolveMutationButtonFc<Props, Args extends unknown[], Result> = FC< | ||
| 371 | & Omit<Props, keyof MutationButtonProps<Args, Result>> | ||
| 372 | & BaseButtonProps | ||
| 373 | >; | ||
| 374 | |||
| 375 | function GenericMutationButton< | ||
| 376 | Props, | ||
| 377 | Args extends unknown[], | ||
| 378 | Result, | ||
| 379 | >( | ||
| 380 | Component: ResolveMutationButtonFc<Props, Args, Result>, | ||
| 381 | props: MutationButtonProps<Args, Result> & Props, | ||
| 382 | ) { | ||
| 383 | const { | ||
| 384 | mutation, | ||
| 385 | args, | ||
| 386 | onClick, | ||
| 387 | onError, | ||
| 388 | onSuccess, | ||
| 389 | onSettled, | ||
| 390 | ...forwarded | ||
| 391 | } = props; | ||
| 392 | forwarded satisfies Omit<Props, keyof MutationButtonProps<Args, Result>>; | ||
| 393 | |||
| 394 | const localHook = useMutate("subscribe" in mutation ? mutation : null); | ||
| 395 | const state = "subscribe" in mutation ? localHook : mutation; | ||
| 396 | |||
| 397 | if (onError) void state.isError; // subscribe to the events | ||
| 398 | if (onSuccess) void state.isSuccess; // subscribe to the events | ||
| 399 | |||
| 400 | // NOTE: the JSR has trouble with JSX syntax for some reason. | ||
| 401 | return jsx( | ||
| 402 | Component, | ||
| 403 | { | ||
| 404 | ...forwarded, | ||
| 405 | onClick: useCallback((e: MouseEvent) => { | ||
| 406 | onClick?.(e); | ||
| 407 | if (e.defaultPrevented) return; | ||
| 408 | const computedArgs = typeof args === "function" ? args(e) : args; | ||
| 409 | if (!computedArgs || e.defaultPrevented) return; | ||
| 410 | state.runWithResult(...computedArgs) | ||
| 411 | .then((result) => { | ||
| 412 | onSuccess?.(result); | ||
| 413 | onSettled?.({ status: "success", result }); | ||
| 414 | }) | ||
| 415 | .catch((error) => { | ||
| 416 | onError?.(error); | ||
| 417 | onSettled?.({ status: "error", error }); | ||
| 418 | }); | ||
| 419 | }, [state]), | ||
| 420 | isPending: state.isPending, | ||
| 421 | } satisfies Parameters<typeof Component>[0], | ||
| 422 | ); | ||
| 423 | } | ||
src/react.tsx deleted-370| ... | @@ -1,370 +0,0 @@ | ||
| 1 | import { | ||
| 2 | type FC, | ||
| 3 | type MouseEvent, | ||
| 4 | type MouseEventHandler, | ||
| 5 | type ReactNode, | ||
| 6 | useCallback, | ||
| 7 | useEffect, | ||
| 8 | useState, | ||
| 9 | } from "react"; | ||
| 10 | import { message as errMessage } from "@clo/lib/error.ts"; | ||
| 11 | import type { Mutation } from "./types.ts"; | ||
| 12 | |||
| 13 | /** | ||
| 14 | * Subscribe to a mutation's status, as well as accessing a local `run` method. | ||
| 15 | * The resulting mutation can be used directly or passed to a {@link createMutationButton|mutation button}. | ||
| 16 | */ | ||
| 17 | export function useMutate< | ||
| 18 | Args extends unknown[], | ||
| 19 | Result, | ||
| 20 | >( | ||
| 21 | mutation: Mutation<Args, Result> | null, | ||
| 22 | ): UseMutateResult<Args, Result> { | ||
| 23 | const [_, setRerender] = useState(0); | ||
| 24 | const [observer] = useState(() => new Observer<Args, Result>(setRerender)); | ||
| 25 | useEffect(() => () => void observer.reset(), []); | ||
| 26 | if (mutation !== observer.mutation) { | ||
| 27 | observer.mutation = mutation; | ||
| 28 | observer.reset(); | ||
| 29 | } | ||
| 30 | return observer.binding; | ||
| 31 | } | ||
| 32 | |||
| 33 | export type UseMutateResult<Args extends unknown[], Result> = | ||
| 34 | & UseMutateResultBase<Args> | ||
| 35 | & ( | ||
| 36 | | UseMutateSuccess<Result> | ||
| 37 | | UseMutateError | ||
| 38 | | UseMutateIdle | ||
| 39 | ); | ||
| 40 | |||
| 41 | export interface UseMutateResultBase<Args extends unknown[]> { | ||
| 42 | run: (...args: Args) => void; | ||
| 43 | clear: () => void; | ||
| 44 | } | ||
| 45 | |||
| 46 | export interface UseMutateSuccess<Result> { | ||
| 47 | status: "success"; | ||
| 48 | result: Result; | ||
| 49 | error: undefined; | ||
| 50 | errorMessage: undefined; | ||
| 51 | /** `true` when a `mutate` function is currently running. */ | ||
| 52 | isMutating: false; | ||
| 53 | /** `true` when a loading indicator should be shown. */ | ||
| 54 | isPending: false; | ||
| 55 | /** `true` when a mutation has completed and has a result. */ | ||
| 56 | isSuccess: true; | ||
| 57 | /** `true` when a mutation has failed. */ | ||
| 58 | isError: false; | ||
| 59 | /** `true` when there is optimistic state applied. */ | ||
| 60 | isOptimisticData: boolean; | ||
| 61 | } | ||
| 62 | export interface UseMutateError { | ||
| 63 | status: "error"; | ||
| 64 | result: undefined; | ||
| 65 | error: unknown; | ||
| 66 | /** User-friendly in this format: `Failed to {action}: {details}` */ | ||
| 67 | errorMessage: string; | ||
| 68 | /** `true` when a `mutate` function is currently running. */ | ||
| 69 | isMutating: false; | ||
| 70 | /** `true` when a loading indicator should be shown. */ | ||
| 71 | isPending: false; | ||
| 72 | /** `true` when a mutation has completed and has a result. */ | ||
| 73 | isSuccess: false; | ||
| 74 | /** `true` when a mutation has failed. */ | ||
| 75 | isError: true; | ||
| 76 | /** `true` when there is optimistic state applied. */ | ||
| 77 | isOptimisticData: boolean; | ||
| 78 | } | ||
| 79 | export interface UseMutateIdle { | ||
| 80 | status: "idle" | "mutating"; | ||
| 81 | result: undefined; | ||
| 82 | error: undefined; | ||
| 83 | errorMessage: undefined; | ||
| 84 | /** `true` when a `mutate` function is currently running. */ | ||
| 85 | isMutating: boolean; | ||
| 86 | /** `true` when a loading indicator should be shown. */ | ||
| 87 | isPending: boolean; | ||
| 88 | /** `true` when a mutation has completed and has a result. */ | ||
| 89 | isSuccess: false; | ||
| 90 | /** `true` when a mutation has failed. */ | ||
| 91 | isError: false; | ||
| 92 | /** `true` when there is optimistic state applied. */ | ||
| 93 | isOptimisticData: boolean; | ||
| 94 | } | ||
| 95 | |||
| 96 | type AnyMutationState<Result> = | ||
| 97 | & Omit< | ||
| 98 | UseMutateIdle, | ||
| 99 | "status" | "result" | "error" | "isSuccess" | "isError" | "errorMessage" | ||
| 100 | > | ||
| 101 | & { | ||
| 102 | status: "idle" | "mutating" | "error" | "success"; | ||
| 103 | result: undefined | Result; | ||
| 104 | error: undefined | unknown; | ||
| 105 | errorMessage: undefined | string; | ||
| 106 | isSuccess: boolean; | ||
| 107 | isError: boolean; | ||
| 108 | }; | ||
| 109 | |||
| 110 | function initialState() { | ||
| 111 | return { | ||
| 112 | status: "idle", | ||
| 113 | result: undefined, | ||
| 114 | error: undefined, | ||
| 115 | errorMessage: undefined, | ||
| 116 | isMutating: false, | ||
| 117 | isPending: false, | ||
| 118 | isSuccess: false, | ||
| 119 | isError: false, | ||
| 120 | isOptimisticData: false, | ||
| 121 | } as const; | ||
| 122 | } | ||
| 123 | |||
| 124 | class Observer<Args extends unknown[], Result> { | ||
| 125 | setRerender: (fn: number) => void; | ||
| 126 | mutation: Mutation<Args, Result> | null = null; | ||
| 127 | unsubscribe: (() => void) | null = null; | ||
| 128 | currentKey: string | null = null; | ||
| 129 | currentArgs: Args | null = null; | ||
| 130 | |||
| 131 | constructor(setRerender: (fn: number) => void) { | ||
| 132 | this.setRerender = setRerender; | ||
| 133 | } | ||
| 134 | |||
| 135 | watched: Set<string> = new Set(); | ||
| 136 | state: AnyMutationState<Result> = initialState(); | ||
| 137 | setState(newState: Partial<AnyMutationState<Result>>) { | ||
| 138 | let updateUi = false; | ||
| 139 | const current: Record<string, unknown> = this.state; | ||
| 140 | for (const [key, value] of Object.entries(newState)) { | ||
| 141 | if (value !== current[key]) { | ||
| 142 | current[key] = value; | ||
| 143 | updateUi ||= this.watched.has(key); | ||
| 144 | } | ||
| 145 | } | ||
| 146 | if (updateUi) { | ||
| 147 | this.setRerender(Math.random()); | ||
| 148 | } | ||
| 149 | } | ||
| 150 | |||
| 151 | reset() { | ||
| 152 | this.unsubscribe?.(); | ||
| 153 | this.unsubscribe = null; | ||
| 154 | this.currentKey = null; | ||
| 155 | this.state = initialState(); | ||
| 156 | } | ||
| 157 | |||
| 158 | computeErrorMessage(error: unknown): string | undefined { | ||
| 159 | if (!error) return undefined; | ||
| 160 | const mutation = this.mutation; | ||
| 161 | if (!mutation || !this.currentArgs) return errMessage(error); | ||
| 162 | return `Failed to ${mutation.describe(...this.currentArgs)}: ${ | ||
| 163 | errMessage(error) | ||
| 164 | }`; | ||
| 165 | } | ||
| 166 | |||
| 167 | binding: UseMutateResult<Args, Result> = ((self: this) => ({ | ||
| 168 | run(...args: Args) { | ||
| 169 | const mutation = self.mutation; | ||
| 170 | if (!mutation) return; | ||
| 171 | self.currentArgs = args; | ||
| 172 | const key = mutation.key(args); | ||
| 173 | if (key !== self.currentKey) { | ||
| 174 | self.currentKey = key; | ||
| 175 | self.unsubscribe?.(); | ||
| 176 | self.unsubscribe = mutation.subscribe( | ||
| 177 | mutation.key(args), | ||
| 178 | ({ status, error, result }) => { | ||
| 179 | if (status === "idle") { | ||
| 180 | self.setState({ | ||
| 181 | isMutating: false, | ||
| 182 | isPending: false, | ||
| 183 | isOptimisticData: false, | ||
| 184 | }); | ||
| 185 | return; | ||
| 186 | } | ||
| 187 | const hasError = error != null; | ||
| 188 | const hasResult = result != null; | ||
| 189 | |||
| 190 | self.setState({ | ||
| 191 | status: hasError | ||
| 192 | ? "error" | ||
| 193 | : hasResult | ||
| 194 | ? "success" | ||
| 195 | : status === "mutating" | ||
| 196 | ? "mutating" | ||
| 197 | : "idle", | ||
| 198 | error: error ?? undefined, | ||
| 199 | errorMessage: self.computeErrorMessage(error ?? undefined), | ||
| 200 | result: result ?? undefined, | ||
| 201 | isMutating: status === "mutating", | ||
| 202 | isPending: status === "mutating" || status === "refetching", | ||
| 203 | isSuccess: hasResult && !hasError, | ||
| 204 | isError: hasError, | ||
| 205 | isOptimisticData: status === "waiting" || status === "mutating" || | ||
| 206 | status === "refetching", | ||
| 207 | }); | ||
| 208 | }, | ||
| 209 | ); | ||
| 210 | } | ||
| 211 | // Use global error/success handling if this usage of the hook doesn't check for | ||
| 212 | // errors or success. This makes it act pretty awesome in terms of defaults. | ||
| 213 | // You don't have to worry about result UI, they'll surface exactly once. | ||
| 214 | const watchesError = self.watched.has("isError") || | ||
| 215 | self.watched.has("error") || self.watched.has("errorMessage"); | ||
| 216 | const watchesSuccess = self.watched.has("isSuccess") || | ||
| 217 | self.watched.has("result"); | ||
| 218 | mutation.runAndReturn(...args) | ||
| 219 | .then((result) => { | ||
| 220 | if (!watchesSuccess && mutation.describeResult) { | ||
| 221 | const message = mutation.describeResult(args, result); | ||
| 222 | if (message && mutation.client.reportSuccess) { | ||
| 223 | mutation.client.reportSuccess(message); | ||
| 224 | } | ||
| 225 | } | ||
| 226 | }) | ||
| 227 | .catch((err) => { | ||
| 228 | if (!watchesError) { | ||
| 229 | const message = `Failed to ${mutation.describe(...args)}: ${errMessage(err)}`; | ||
| 230 | mutation.client.reportError(message, err); | ||
| 231 | } | ||
| 232 | }); | ||
| 233 | }, | ||
| 234 | clear() { | ||
| 235 | self.setState({ | ||
| 236 | status: ["error", "success"].includes(self.state.status) | ||
| 237 | ? "idle" | ||
| 238 | : self.state.status, | ||
| 239 | isError: false, | ||
| 240 | isSuccess: false, | ||
| 241 | error: undefined, | ||
| 242 | errorMessage: undefined, | ||
| 243 | result: undefined, | ||
| 244 | }); | ||
| 245 | }, | ||
| 246 | get status() { | ||
| 247 | self.watched.add("status"); | ||
| 248 | return self.state.status; | ||
| 249 | }, | ||
| 250 | get result() { | ||
| 251 | self.watched.add("result"); | ||
| 252 | return self.state.result; | ||
| 253 | }, | ||
| 254 | get error() { | ||
| 255 | self.watched.add("error"); | ||
| 256 | return self.state.error; | ||
| 257 | }, | ||
| 258 | get errorMessage() { | ||
| 259 | self.watched.add("errorMessage"); | ||
| 260 | return self.state.errorMessage; | ||
| 261 | }, | ||
| 262 | get isMutating() { | ||
| 263 | self.watched.add("isMutating"); | ||
| 264 | return self.state.isMutating; | ||
| 265 | }, | ||
| 266 | get isPending() { | ||
| 267 | self.watched.add("isPending"); | ||
| 268 | return self.state.isPending; | ||
| 269 | }, | ||
| 270 | get isSuccess() { | ||
| 271 | self.watched.add("isSuccess"); | ||
| 272 | return self.state.isSuccess; | ||
| 273 | }, | ||
| 274 | get isError() { | ||
| 275 | self.watched.add("isError"); | ||
| 276 | return self.state.isError; | ||
| 277 | }, | ||
| 278 | get isOptimisticData() { | ||
| 279 | self.watched.add("isOptimisticData"); | ||
| 280 | return self.state.isOptimisticData; | ||
| 281 | }, | ||
| 282 | } as UseMutateResult<Args, Result>))(this); | ||
| 283 | } | ||
| 284 | |||
| 285 | interface BaseButtonProps { | ||
| 286 | onClick: MouseEventHandler<HTMLElement> | undefined; | ||
| 287 | isPending: boolean; | ||
| 288 | } | ||
| 289 | |||
| 290 | interface MutationButtonComponent<Props> { | ||
| 291 | <Args extends unknown[], Result>( | ||
| 292 | props: | ||
| 293 | & MutationButtonProps<Args, Result> | ||
| 294 | & Props, | ||
| 295 | ): ReactNode; | ||
| 296 | displayName?: string; | ||
| 297 | } | ||
| 298 | |||
| 299 | export interface MutationButtonProps<Args extends unknown[], Result> { | ||
| 300 | mutation: | ||
| 301 | | Mutation<Args, Result> | ||
| 302 | | Pick<UseMutateResult<Args, Result>, "run" | "status" | "isPending">; | ||
| 303 | /** Preventing default will interrupt the mutation */ | ||
| 304 | args: Args | ((e: MouseEvent) => Args | null); | ||
| 305 | /** Preventing default will interrupt the mutation */ | ||
| 306 | onClick?: (e: MouseEvent) => void; | ||
| 307 | } | ||
| 308 | |||
| 309 | /** | ||
| 310 | * Wraps a custom button component with logic to execute a mutation. The wrapped | ||
| 311 | * component must accept `onClick` and an `isPending` property. When the inner | ||
| 312 | * component emits `onClick`, that will begin the mutation. This is a trival | ||
| 313 | * abstraction on top of `useMutate`, but with type gymnastics to allow safe | ||
| 314 | * types. | ||
| 315 | */ | ||
| 316 | export function createMutationButton<Props>( | ||
| 317 | // Prevent calling this function if missing `onClick` | ||
| 318 | base: Required<Props> extends BaseButtonProps ? FC<Props> | ||
| 319 | : "Base component is missing required props", | ||
| 320 | ): MutationButtonComponent<Flatten<Omit<Props, keyof BaseButtonProps>>> { | ||
| 321 | const Component = base as ResolveMutationButtonFc<Props, unknown[], unknown>; | ||
| 322 | // apply the generics at a type level to allow `.bind` to work | ||
| 323 | type BareProps = Omit<Props, keyof BaseButtonProps>; | ||
| 324 | const bound = (GenericMutationButton<Props, unknown[], unknown>) | ||
| 325 | // the `as` clause here converts the second and third generic parameter | ||
| 326 | // back into unspecified generics. | ||
| 327 | .bind(null, Component) as MutationButtonComponent<BareProps>; | ||
| 328 | // react devtools loves display names | ||
| 329 | bound.displayName = `MutationButton[${ | ||
| 330 | Component.displayName ?? Component.name | ||
| 331 | }]`; | ||
| 332 | |||
| 333 | return bound; | ||
| 334 | } | ||
| 335 | |||
| 336 | type Identity<T> = T; | ||
| 337 | type Flatten<T> = Identity<{ [K in keyof T]: T[K] }>; | ||
| 338 | type ResolveMutationButtonFc<Props, Args extends unknown[], Result> = FC< | ||
| 339 | & Omit<Props, keyof MutationButtonProps<Args, Result>> | ||
| 340 | & BaseButtonProps | ||
| 341 | >; | ||
| 342 | |||
| 343 | function GenericMutationButton< | ||
| 344 | Props, | ||
| 345 | Args extends unknown[], | ||
| 346 | Result, | ||
| 347 | >( | ||
| 348 | Component: ResolveMutationButtonFc<Props, Args, Result>, | ||
| 349 | props: MutationButtonProps<Args, Result> & Props, | ||
| 350 | ) { | ||
| 351 | const { mutation, args, onClick, ...forwarded } = props; | ||
| 352 | forwarded satisfies Omit<Props, keyof MutationButtonProps<Args, Result>>; | ||
| 353 | |||
| 354 | const localHook = useMutate("subscribe" in mutation ? mutation : null); | ||
| 355 | const state = "subscribe" in mutation ? localHook : mutation; | ||
| 356 | |||
| 357 | return ( | ||
| 358 | <Component | ||
| 359 | {...forwarded} | ||
| 360 | onClick={useCallback((e: MouseEvent) => { | ||
| 361 | onClick?.(e); | ||
| 362 | if (e.defaultPrevented) return; | ||
| 363 | const computedArgs = typeof args === "function" ? args(e) : args; | ||
| 364 | if (!computedArgs || e.defaultPrevented) return; | ||
| 365 | state.run(...computedArgs); | ||
| 366 | }, [state])} | ||
| 367 | isPending={state.isPending} | ||
| 368 | /> | ||
| 369 | ); | ||
| 370 | } | ||
test/batch.test.ts deleted-1019| ... | @@ -1,1019 +0,0 @@ | ||
| 1 | import { assertEquals, assertRejects } from "@std/assert"; | ||
| 2 | import { MutationClient } from "../src/client.ts"; | ||
| 3 | import type { MutationEvent } from "../src/types.ts"; | ||
| 4 | import { test } from "vitest"; | ||
| 5 | |||
| 6 | // Shared test store for optimistic updates | ||
| 7 | const testStore = new Map<string, number>(); | ||
| 8 | |||
| 9 | // Helper to create a test mutation client | ||
| 10 | function createTestClient() { | ||
| 11 | const errors: unknown[] = []; | ||
| 12 | const client = new MutationClient({ | ||
| 13 | context: { userId: "test-user" }, | ||
| 14 | getOptimisticHelpers({ onRestore }) { | ||
| 15 | return { | ||
| 16 | increment(key: string, amount: number) { | ||
| 17 | const oldValue = testStore.get(key) ?? 0; | ||
| 18 | testStore.set(key, oldValue + amount); | ||
| 19 | onRestore(() => testStore.set(key, oldValue)); | ||
| 20 | }, | ||
| 21 | setValue(key: string, value: number) { | ||
| 22 | const oldValue = testStore.get(key); | ||
| 23 | testStore.set(key, value); | ||
| 24 | onRestore(() => { | ||
| 25 | if (oldValue === undefined) { | ||
| 26 | testStore.delete(key); | ||
| 27 | } else { | ||
| 28 | testStore.set(key, oldValue); | ||
| 29 | } | ||
| 30 | }); | ||
| 31 | }, | ||
| 32 | }; | ||
| 33 | }, | ||
| 34 | reportError(message, error) { | ||
| 35 | errors.push(error); | ||
| 36 | }, | ||
| 37 | }); | ||
| 38 | |||
| 39 | return { client, errors }; | ||
| 40 | } | ||
| 41 | |||
| 42 | // Helper to track mutation events | ||
| 43 | function createEventTracker<Result>() { | ||
| 44 | const events: Array<MutationEvent<Result>> = []; | ||
| 45 | const callback = (event: MutationEvent<Result>) => { | ||
| 46 | events.push({ ...event }); | ||
| 47 | }; | ||
| 48 | return { events, callback }; | ||
| 49 | } | ||
| 50 | |||
| 51 | // Helper to wait for async operations | ||
| 52 | function delay(ms: number) { | ||
| 53 | return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| 54 | } | ||
| 55 | |||
| 56 | // ============================================================================ | ||
| 57 | // Basic functionality tests | ||
| 58 | // ============================================================================ | ||
| 59 | |||
| 60 | test("BatchMutation - basic mutation success with debounce", async () => { | ||
| 61 | const { client } = createTestClient(); | ||
| 62 | testStore.clear(); | ||
| 63 | testStore.set("counter", 0); | ||
| 64 | |||
| 65 | let commitCallCount = 0; | ||
| 66 | let refetchCallCount = 0; | ||
| 67 | |||
| 68 | const mutation = client.defineDebounced({ | ||
| 69 | optimistic({ helpers }, amount: number) { | ||
| 70 | helpers.increment("counter", amount); | ||
| 71 | }, | ||
| 72 | mode: "debounce", | ||
| 73 | time: 50, | ||
| 74 | key: () => "test-key", | ||
| 75 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 76 | async commit({ initial, current }) { | ||
| 77 | commitCallCount++; | ||
| 78 | await delay(10); | ||
| 79 | return current - initial; | ||
| 80 | }, | ||
| 81 | describe: "increment counter", | ||
| 82 | async refetch() { | ||
| 83 | refetchCallCount++; | ||
| 84 | }, | ||
| 85 | }); | ||
| 86 | |||
| 87 | const result = await mutation.runAndReturn(5); | ||
| 88 | await delay(20); // Wait for refetch | ||
| 89 | |||
| 90 | assertEquals(result, 5); | ||
| 91 | assertEquals(commitCallCount, 1); | ||
| 92 | assertEquals(refetchCallCount, 1); | ||
| 93 | assertEquals(testStore.get("counter"), 5); | ||
| 94 | }); | ||
| 95 | |||
| 96 | test("BatchMutation - run() catches errors", async () => { | ||
| 97 | const { client, errors } = createTestClient(); | ||
| 98 | testStore.clear(); | ||
| 99 | testStore.set("counter", 0); | ||
| 100 | |||
| 101 | const mutation = client.defineDebounced({ | ||
| 102 | optimistic({ helpers }, amount: number) { | ||
| 103 | helpers.increment("counter", amount); | ||
| 104 | }, | ||
| 105 | mode: "debounce", | ||
| 106 | time: 20, | ||
| 107 | key: () => "test-key", | ||
| 108 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 109 | async commit() { | ||
| 110 | throw new Error("commit failed"); | ||
| 111 | }, | ||
| 112 | describe: "failing mutation", | ||
| 113 | async refetch() {}, | ||
| 114 | }); | ||
| 115 | |||
| 116 | mutation.run(5); | ||
| 117 | await delay(100); | ||
| 118 | |||
| 119 | assertEquals(errors.length, 1); | ||
| 120 | assertEquals((errors[0] as Error).message, "commit failed"); | ||
| 121 | }); | ||
| 122 | |||
| 123 | test("BatchMutation - runAndReturn() rejects on error", async () => { | ||
| 124 | const { client } = createTestClient(); | ||
| 125 | testStore.clear(); | ||
| 126 | testStore.set("counter", 0); | ||
| 127 | |||
| 128 | const mutation = client.defineDebounced({ | ||
| 129 | optimistic({ helpers }, amount: number) { | ||
| 130 | helpers.increment("counter", amount); | ||
| 131 | }, | ||
| 132 | mode: "debounce", | ||
| 133 | time: 10, | ||
| 134 | key: () => "test-key", | ||
| 135 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 136 | async commit() { | ||
| 137 | throw new Error("commit failed"); | ||
| 138 | }, | ||
| 139 | describe: "failing mutation", | ||
| 140 | async refetch() {}, | ||
| 141 | }); | ||
| 142 | |||
| 143 | await assertRejects( | ||
| 144 | () => mutation.runAndReturn(5), | ||
| 145 | Error, | ||
| 146 | "commit failed", | ||
| 147 | ); | ||
| 148 | }); | ||
| 149 | |||
| 150 | // ============================================================================ | ||
| 151 | // Debounce mode tests | ||
| 152 | // ============================================================================ | ||
| 153 | |||
| 154 | test("BatchMutation - debounce batches rapid calls", async () => { | ||
| 155 | const { client } = createTestClient(); | ||
| 156 | testStore.clear(); | ||
| 157 | testStore.set("counter", 0); | ||
| 158 | |||
| 159 | let commitCallCount = 0; | ||
| 160 | const commitArgs: Array<{ initial: number; current: number }> = []; | ||
| 161 | |||
| 162 | const mutation = client.defineDebounced({ | ||
| 163 | optimistic({ helpers }, amount: number) { | ||
| 164 | helpers.increment("counter", amount); | ||
| 165 | }, | ||
| 166 | mode: "debounce", | ||
| 167 | time: 50, | ||
| 168 | key: () => "test-key", | ||
| 169 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 170 | async commit({ initial, current }) { | ||
| 171 | commitCallCount++; | ||
| 172 | commitArgs.push({ initial, current }); | ||
| 173 | return current - initial; | ||
| 174 | }, | ||
| 175 | describe: "increment counter", | ||
| 176 | async refetch() {}, | ||
| 177 | }); | ||
| 178 | |||
| 179 | // Rapid calls within debounce window | ||
| 180 | const promise1 = mutation.runAndReturn(1); | ||
| 181 | const promise2 = mutation.runAndReturn(2); | ||
| 182 | const promise3 = mutation.runAndReturn(3); | ||
| 183 | |||
| 184 | // Optimistic updates should be applied immediately | ||
| 185 | assertEquals(testStore.get("counter"), 6); | ||
| 186 | |||
| 187 | const results = await Promise.all([promise1, promise2, promise3]); | ||
| 188 | |||
| 189 | // All should resolve with the same result (total delta) | ||
| 190 | assertEquals(results, [6, 6, 6]); | ||
| 191 | |||
| 192 | // Only one commit should have been made | ||
| 193 | assertEquals(commitCallCount, 1); | ||
| 194 | assertEquals(commitArgs, [{ initial: 0, current: 6 }]); | ||
| 195 | }); | ||
| 196 | |||
| 197 | test("BatchMutation - debounce resets timer on each call", async () => { | ||
| 198 | const { client } = createTestClient(); | ||
| 199 | testStore.clear(); | ||
| 200 | testStore.set("counter", 0); | ||
| 201 | |||
| 202 | let commitCallCount = 0; | ||
| 203 | |||
| 204 | const mutation = client.defineDebounced({ | ||
| 205 | optimistic({ helpers }, amount: number) { | ||
| 206 | helpers.increment("counter", amount); | ||
| 207 | }, | ||
| 208 | mode: "debounce", | ||
| 209 | time: 30, | ||
| 210 | key: () => "test-key", | ||
| 211 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 212 | async commit({ initial, current }) { | ||
| 213 | commitCallCount++; | ||
| 214 | return current - initial; | ||
| 215 | }, | ||
| 216 | describe: "increment counter", | ||
| 217 | async refetch() {}, | ||
| 218 | }); | ||
| 219 | |||
| 220 | // First call | ||
| 221 | const promise1 = mutation.runAndReturn(1); | ||
| 222 | |||
| 223 | // Wait less than debounce time | ||
| 224 | await delay(15); | ||
| 225 | |||
| 226 | // Second call should reset the timer | ||
| 227 | const promise2 = mutation.runAndReturn(2); | ||
| 228 | |||
| 229 | // Wait less than debounce time again | ||
| 230 | await delay(15); | ||
| 231 | |||
| 232 | // Commit should not have happened yet | ||
| 233 | assertEquals(commitCallCount, 0); | ||
| 234 | |||
| 235 | // Third call | ||
| 236 | const promise3 = mutation.runAndReturn(3); | ||
| 237 | |||
| 238 | // Wait for all to complete | ||
| 239 | await Promise.all([promise1, promise2, promise3]); | ||
| 240 | |||
| 241 | // Only one commit | ||
| 242 | assertEquals(commitCallCount, 1); | ||
| 243 | }); | ||
| 244 | |||
| 245 | test("BatchMutation - debounce separates batches after timeout", async () => { | ||
| 246 | const { client } = createTestClient(); | ||
| 247 | testStore.clear(); | ||
| 248 | testStore.set("counter", 0); | ||
| 249 | |||
| 250 | let commitCallCount = 0; | ||
| 251 | const commitArgs: Array<{ initial: number; current: number }> = []; | ||
| 252 | |||
| 253 | const mutation = client.defineDebounced({ | ||
| 254 | optimistic({ helpers }, amount: number) { | ||
| 255 | helpers.increment("counter", amount); | ||
| 256 | }, | ||
| 257 | mode: "debounce", | ||
| 258 | time: 30, | ||
| 259 | key: () => "test-key", | ||
| 260 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 261 | async commit({ initial, current }) { | ||
| 262 | commitCallCount++; | ||
| 263 | commitArgs.push({ initial, current }); | ||
| 264 | return current - initial; | ||
| 265 | }, | ||
| 266 | describe: "increment counter", | ||
| 267 | async refetch() {}, | ||
| 268 | }); | ||
| 269 | |||
| 270 | // First batch | ||
| 271 | await mutation.runAndReturn(1); | ||
| 272 | await delay(50); // Wait for first batch to complete | ||
| 273 | |||
| 274 | // Second batch (after timeout) | ||
| 275 | await mutation.runAndReturn(2); | ||
| 276 | await delay(50); | ||
| 277 | |||
| 278 | // Two separate commits | ||
| 279 | assertEquals(commitCallCount, 2); | ||
| 280 | assertEquals(commitArgs, [ | ||
| 281 | { initial: 0, current: 1 }, | ||
| 282 | { initial: 1, current: 3 }, | ||
| 283 | ]); | ||
| 284 | }); | ||
| 285 | |||
| 286 | // ============================================================================ | ||
| 287 | // Throttle mode tests | ||
| 288 | // ============================================================================ | ||
| 289 | |||
| 290 | test("BatchMutation - throttle commits immediately on first call", async () => { | ||
| 291 | const { client } = createTestClient(); | ||
| 292 | testStore.clear(); | ||
| 293 | testStore.set("counter", 0); | ||
| 294 | |||
| 295 | let commitTime = 0; | ||
| 296 | const startTime = Date.now(); | ||
| 297 | |||
| 298 | const mutation = client.defineDebounced({ | ||
| 299 | optimistic({ helpers }, amount: number) { | ||
| 300 | helpers.increment("counter", amount); | ||
| 301 | }, | ||
| 302 | mode: "throttle", | ||
| 303 | time: 100, | ||
| 304 | key: () => "test-key", | ||
| 305 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 306 | async commit({ initial, current }) { | ||
| 307 | commitTime = Date.now() - startTime; | ||
| 308 | return current - initial; | ||
| 309 | }, | ||
| 310 | describe: "increment counter", | ||
| 311 | async refetch() {}, | ||
| 312 | }); | ||
| 313 | |||
| 314 | await mutation.runAndReturn(5); | ||
| 315 | |||
| 316 | // First call should commit immediately (within a small tolerance) | ||
| 317 | assertEquals(commitTime < 20, true); | ||
| 318 | }); | ||
| 319 | |||
| 320 | test("BatchMutation - throttle batches calls within time window", async () => { | ||
| 321 | const { client } = createTestClient(); | ||
| 322 | testStore.clear(); | ||
| 323 | testStore.set("counter", 0); | ||
| 324 | |||
| 325 | let commitCallCount = 0; | ||
| 326 | const commitArgs: Array<{ initial: number; current: number }> = []; | ||
| 327 | |||
| 328 | const mutation = client.defineDebounced({ | ||
| 329 | optimistic({ helpers }, amount: number) { | ||
| 330 | helpers.increment("counter", amount); | ||
| 331 | }, | ||
| 332 | mode: "throttle", | ||
| 333 | time: 100, | ||
| 334 | key: () => "test-key", | ||
| 335 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 336 | async commit({ initial, current }) { | ||
| 337 | commitCallCount++; | ||
| 338 | commitArgs.push({ initial, current }); | ||
| 339 | await delay(10); | ||
| 340 | return current - initial; | ||
| 341 | }, | ||
| 342 | describe: "increment counter", | ||
| 343 | async refetch() {}, | ||
| 344 | }); | ||
| 345 | |||
| 346 | // First call commits immediately | ||
| 347 | const promise1 = mutation.runAndReturn(1); | ||
| 348 | await delay(5); | ||
| 349 | |||
| 350 | // Second call within throttle window - should batch | ||
| 351 | const promise2 = mutation.runAndReturn(2); | ||
| 352 | await delay(5); | ||
| 353 | |||
| 354 | // Third call within throttle window - should batch with second | ||
| 355 | const promise3 = mutation.runAndReturn(3); | ||
| 356 | |||
| 357 | // Wait for first to complete | ||
| 358 | await promise1; | ||
| 359 | |||
| 360 | // First commit happened immediately | ||
| 361 | assertEquals(commitCallCount, 1); | ||
| 362 | assertEquals(commitArgs[0], { initial: 0, current: 1 }); | ||
| 363 | |||
| 364 | // Wait for throttle window to pass and second batch to commit | ||
| 365 | await Promise.all([promise2, promise3]); | ||
| 366 | await delay(50); | ||
| 367 | |||
| 368 | // Second batch committed | ||
| 369 | assertEquals(commitCallCount, 2); | ||
| 370 | assertEquals(commitArgs[1], { initial: 1, current: 6 }); | ||
| 371 | }); | ||
| 372 | |||
| 373 | test("BatchMutation - throttle allows new batch after time window", async () => { | ||
| 374 | const { client } = createTestClient(); | ||
| 375 | testStore.clear(); | ||
| 376 | testStore.set("counter", 0); | ||
| 377 | |||
| 378 | let commitCallCount = 0; | ||
| 379 | |||
| 380 | const mutation = client.defineDebounced({ | ||
| 381 | optimistic({ helpers }, amount: number) { | ||
| 382 | helpers.increment("counter", amount); | ||
| 383 | }, | ||
| 384 | mode: "throttle", | ||
| 385 | time: 50, | ||
| 386 | key: () => "test-key", | ||
| 387 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 388 | async commit({ initial, current }) { | ||
| 389 | commitCallCount++; | ||
| 390 | return current - initial; | ||
| 391 | }, | ||
| 392 | describe: "increment counter", | ||
| 393 | async refetch() {}, | ||
| 394 | }); | ||
| 395 | |||
| 396 | // First call | ||
| 397 | await mutation.runAndReturn(1); | ||
| 398 | await delay(10); | ||
| 399 | |||
| 400 | assertEquals(commitCallCount, 1); | ||
| 401 | |||
| 402 | // Wait for throttle window to pass | ||
| 403 | await delay(60); | ||
| 404 | |||
| 405 | // Second call should commit immediately | ||
| 406 | await mutation.runAndReturn(2); | ||
| 407 | await delay(10); | ||
| 408 | |||
| 409 | assertEquals(commitCallCount, 2); | ||
| 410 | }); | ||
| 411 | |||
| 412 | // ============================================================================ | ||
| 413 | // No-op detection tests | ||
| 414 | // ============================================================================ | ||
| 415 | |||
| 416 | test("BatchMutation - skips commit when value unchanged", async () => { | ||
| 417 | const { client } = createTestClient(); | ||
| 418 | testStore.clear(); | ||
| 419 | testStore.set("counter", 5); | ||
| 420 | |||
| 421 | let commitCallCount = 0; | ||
| 422 | |||
| 423 | const mutation = client.defineDebounced({ | ||
| 424 | optimistic({ helpers }, amount: number) { | ||
| 425 | helpers.increment("counter", amount); | ||
| 426 | }, | ||
| 427 | mode: "debounce", | ||
| 428 | time: 20, | ||
| 429 | key: () => "test-key", | ||
| 430 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 431 | async commit({ initial, current }) { | ||
| 432 | commitCallCount++; | ||
| 433 | return current - initial; | ||
| 434 | }, | ||
| 435 | describe: "increment counter", | ||
| 436 | async refetch() {}, | ||
| 437 | }); | ||
| 438 | |||
| 439 | // +5 and -5 cancel out | ||
| 440 | const promise1 = mutation.runAndReturn(5); | ||
| 441 | const promise2 = mutation.runAndReturn(-5); | ||
| 442 | |||
| 443 | const [result1, result2] = await Promise.all([promise1, promise2]); | ||
| 444 | |||
| 445 | // No commit should have been made | ||
| 446 | assertEquals(commitCallCount, 0); | ||
| 447 | |||
| 448 | // Results should be null (no actual change) | ||
| 449 | assertEquals(result1, null); | ||
| 450 | assertEquals(result2, null); | ||
| 451 | |||
| 452 | // Store should be unchanged | ||
| 453 | assertEquals(testStore.get("counter"), 5); | ||
| 454 | }); | ||
| 455 | |||
| 456 | test("BatchMutation - uses deepEquals for comparison", async () => { | ||
| 457 | const errors: unknown[] = []; | ||
| 458 | const objectStore: { value: { count: number } | null } = { | ||
| 459 | value: { count: 0 }, | ||
| 460 | }; | ||
| 461 | |||
| 462 | const client = new MutationClient({ | ||
| 463 | context: {}, | ||
| 464 | getOptimisticHelpers({ onRestore }) { | ||
| 465 | return { | ||
| 466 | setCount(count: number) { | ||
| 467 | const old = objectStore.value; | ||
| 468 | objectStore.value = { count }; | ||
| 469 | onRestore(() => { | ||
| 470 | objectStore.value = old; | ||
| 471 | }); | ||
| 472 | }, | ||
| 473 | }; | ||
| 474 | }, | ||
| 475 | reportError(message, error) { | ||
| 476 | errors.push(error); | ||
| 477 | }, | ||
| 478 | }); | ||
| 479 | |||
| 480 | let commitCallCount = 0; | ||
| 481 | |||
| 482 | const mutation = client.defineDebounced({ | ||
| 483 | optimistic({ helpers }, count: number) { | ||
| 484 | helpers.setCount(count); | ||
| 485 | }, | ||
| 486 | mode: "debounce", | ||
| 487 | time: 20, | ||
| 488 | key: () => "test-key", | ||
| 489 | getValue: (_) => objectStore.value, | ||
| 490 | async commit() { | ||
| 491 | commitCallCount++; | ||
| 492 | return null; | ||
| 493 | }, | ||
| 494 | describe: "set count", | ||
| 495 | async refetch() {}, | ||
| 496 | }); | ||
| 497 | |||
| 498 | // Set to same value (different object reference but same content) | ||
| 499 | await mutation.runAndReturn(0); | ||
| 500 | await delay(30); | ||
| 501 | |||
| 502 | // Should skip commit because value is deeply equal | ||
| 503 | assertEquals(commitCallCount, 0); | ||
| 504 | }); | ||
| 505 | |||
| 506 | test("BatchMutation - custom deepEquals function", async () => { | ||
| 507 | const errors: unknown[] = []; | ||
| 508 | let compareCallCount = 0; | ||
| 509 | |||
| 510 | const client = new MutationClient({ | ||
| 511 | context: {}, | ||
| 512 | getOptimisticHelpers({ onRestore }) { | ||
| 513 | return { | ||
| 514 | increment(key: string, amount: number) { | ||
| 515 | const old = testStore.get(key) ?? 0; | ||
| 516 | testStore.set(key, old + amount); | ||
| 517 | onRestore(() => testStore.set(key, old)); | ||
| 518 | }, | ||
| 519 | }; | ||
| 520 | }, | ||
| 521 | reportError(message, error) { | ||
| 522 | errors.push(error); | ||
| 523 | }, | ||
| 524 | deepEquals(a, b) { | ||
| 525 | compareCallCount++; | ||
| 526 | // Custom comparison | ||
| 527 | return a === b; | ||
| 528 | }, | ||
| 529 | }); | ||
| 530 | |||
| 531 | testStore.clear(); | ||
| 532 | testStore.set("counter", 0); | ||
| 533 | |||
| 534 | const mutation = client.defineDebounced({ | ||
| 535 | optimistic({ helpers }, amount: number) { | ||
| 536 | helpers.increment("counter", amount); | ||
| 537 | }, | ||
| 538 | mode: "debounce", | ||
| 539 | time: 10, | ||
| 540 | key: () => "test-key", | ||
| 541 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 542 | async commit() { | ||
| 543 | throw new Error("commit failed"); | ||
| 544 | }, | ||
| 545 | describe: "failing mutation", | ||
| 546 | async refetch() {}, | ||
| 547 | }); | ||
| 548 | |||
| 549 | await mutation.runAndReturn(5).catch(() => { | ||
| 550 | // Expected to fail due to commit error | ||
| 551 | }); | ||
| 552 | await delay(30); | ||
| 553 | |||
| 554 | // Custom deepEquals should have been called | ||
| 555 | assertEquals(compareCallCount > 0, true); | ||
| 556 | }); | ||
| 557 | |||
| 558 | // ============================================================================ | ||
| 559 | // Rollback tests | ||
| 560 | // ============================================================================ | ||
| 561 | |||
| 562 | test("BatchMutation - rollback on commit error", async () => { | ||
| 563 | const { client } = createTestClient(); | ||
| 564 | testStore.clear(); | ||
| 565 | testStore.set("counter", 10); | ||
| 566 | |||
| 567 | const mutation = client.defineDebounced({ | ||
| 568 | optimistic({ helpers }, amount: number) { | ||
| 569 | helpers.increment("counter", amount); | ||
| 570 | }, | ||
| 571 | mode: "debounce", | ||
| 572 | time: 20, | ||
| 573 | key: () => "test-key", | ||
| 574 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 575 | async commit() { | ||
| 576 | throw new Error("commit failed"); | ||
| 577 | }, | ||
| 578 | describe: "failing mutation", | ||
| 579 | async refetch() {}, | ||
| 580 | }); | ||
| 581 | |||
| 582 | // Optimistic update applied | ||
| 583 | const promise = mutation.runAndReturn(5); | ||
| 584 | assertEquals(testStore.get("counter"), 15); | ||
| 585 | |||
| 586 | await assertRejects(() => promise, Error, "commit failed"); | ||
| 587 | |||
| 588 | // Should be rolled back | ||
| 589 | assertEquals(testStore.get("counter"), 10); | ||
| 590 | }); | ||
| 591 | |||
| 592 | test("BatchMutation - error event includes error details", async () => { | ||
| 593 | const { client } = createTestClient(); | ||
| 594 | testStore.clear(); | ||
| 595 | testStore.set("counter", 0); | ||
| 596 | |||
| 597 | const tracker = createEventTracker<number>(); | ||
| 598 | |||
| 599 | const mutation = client.defineDebounced({ | ||
| 600 | optimistic({ helpers }, amount: number) { | ||
| 601 | helpers.increment("counter", amount); | ||
| 602 | }, | ||
| 603 | mode: "debounce", | ||
| 604 | time: 20, | ||
| 605 | key: () => "test-key", | ||
| 606 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 607 | async commit() { | ||
| 608 | throw new Error("commit failed"); | ||
| 609 | }, | ||
| 610 | describe: "failing mutation", | ||
| 611 | async refetch() {}, | ||
| 612 | }); | ||
| 613 | |||
| 614 | const key = mutation.key([5]); | ||
| 615 | mutation.subscribe(key, tracker.callback); | ||
| 616 | |||
| 617 | await assertRejects(() => mutation.runAndReturn(5)); | ||
| 618 | await delay(30); | ||
| 619 | |||
| 620 | // Should have error in events | ||
| 621 | const errorEvents = tracker.events.filter((e) => e.error !== null); | ||
| 622 | assertEquals(errorEvents.length > 0, true); | ||
| 623 | assertEquals((errorEvents[0]?.error as Error).message, "commit failed"); | ||
| 624 | }); | ||
| 625 | |||
| 626 | // ============================================================================ | ||
| 627 | // Key handling tests | ||
| 628 | // ============================================================================ | ||
| 629 | |||
| 630 | test("BatchMutation - key() returns JSON stringified key", () => { | ||
| 631 | const { client } = createTestClient(); | ||
| 632 | testStore.clear(); | ||
| 633 | |||
| 634 | const mutation = client.defineDebounced({ | ||
| 635 | optimistic(_ctx, _id: string) {}, | ||
| 636 | mode: "debounce", | ||
| 637 | time: 20, | ||
| 638 | key: ({ args }) => args[0], | ||
| 639 | getValue: (_) => 0, | ||
| 640 | async commit() { | ||
| 641 | return null; | ||
| 642 | }, | ||
| 643 | describe: "test mutation", | ||
| 644 | async refetch() {}, | ||
| 645 | }); | ||
| 646 | |||
| 647 | assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id")); | ||
| 648 | }); | ||
| 649 | |||
| 650 | test("BatchMutation - key() can return array", () => { | ||
| 651 | const { client } = createTestClient(); | ||
| 652 | testStore.clear(); | ||
| 653 | |||
| 654 | const mutation = client.defineDebounced({ | ||
| 655 | optimistic(_ctx, _id: string) {}, | ||
| 656 | mode: "debounce", | ||
| 657 | time: 20, | ||
| 658 | key: ({ args }) => ["user", args[0]], | ||
| 659 | getValue: (_) => 0, | ||
| 660 | async commit() { | ||
| 661 | return null; | ||
| 662 | }, | ||
| 663 | describe: "test mutation", | ||
| 664 | async refetch() {}, | ||
| 665 | }); | ||
| 666 | |||
| 667 | assertEquals( | ||
| 668 | mutation.key(["123"]), | ||
| 669 | JSON.stringify(["user", "123"]), | ||
| 670 | ); | ||
| 671 | }); | ||
| 672 | |||
| 673 | test("BatchMutation - different keys create separate batches", async () => { | ||
| 674 | const { client } = createTestClient(); | ||
| 675 | testStore.clear(); | ||
| 676 | testStore.set("counter-a", 0); | ||
| 677 | testStore.set("counter-b", 0); | ||
| 678 | |||
| 679 | let commitCallCount = 0; | ||
| 680 | |||
| 681 | const mutation = client.defineDebounced({ | ||
| 682 | optimistic({ helpers }, key: string, amount: number) { | ||
| 683 | helpers.increment(`counter-${key}`, amount); | ||
| 684 | }, | ||
| 685 | mode: "debounce", | ||
| 686 | time: 20, | ||
| 687 | key: ({ args }) => args[0], | ||
| 688 | getValue: (_, key) => testStore.get(`counter-${key}`) ?? 0, | ||
| 689 | async commit({ current }) { | ||
| 690 | commitCallCount++; | ||
| 691 | return current; | ||
| 692 | }, | ||
| 693 | describe: "test mutation", | ||
| 694 | async refetch() {}, | ||
| 695 | }); | ||
| 696 | |||
| 697 | // Two different keys | ||
| 698 | const promise1 = mutation.runAndReturn("a", 5); | ||
| 699 | const promise2 = mutation.runAndReturn("b", 10); | ||
| 700 | |||
| 701 | await Promise.all([promise1, promise2]); | ||
| 702 | await delay(30); | ||
| 703 | |||
| 704 | // Should have two separate commits | ||
| 705 | assertEquals(commitCallCount, 2); | ||
| 706 | assertEquals(testStore.get("counter-a"), 5); | ||
| 707 | assertEquals(testStore.get("counter-b"), 10); | ||
| 708 | }); | ||
| 709 | |||
| 710 | // ============================================================================ | ||
| 711 | // Describe tests | ||
| 712 | // ============================================================================ | ||
| 713 | |||
| 714 | test("BatchMutation - describe() with string", () => { | ||
| 715 | const { client } = createTestClient(); | ||
| 716 | testStore.clear(); | ||
| 717 | |||
| 718 | const mutation = client.defineDebounced({ | ||
| 719 | optimistic(_ctx, _amount: number) {}, | ||
| 720 | mode: "debounce", | ||
| 721 | time: 20, | ||
| 722 | key: () => "test-key", | ||
| 723 | getValue: (_) => 0, | ||
| 724 | async commit() { | ||
| 725 | return null; | ||
| 726 | }, | ||
| 727 | describe: "update counter", | ||
| 728 | async refetch() {}, | ||
| 729 | }); | ||
| 730 | |||
| 731 | assertEquals(mutation.describe(5), "update counter"); | ||
| 732 | }); | ||
| 733 | |||
| 734 | test("BatchMutation - describe() with function", () => { | ||
| 735 | const { client } = createTestClient(); | ||
| 736 | testStore.clear(); | ||
| 737 | |||
| 738 | const mutation = client.defineDebounced({ | ||
| 739 | optimistic(_ctx, _amount: number) {}, | ||
| 740 | mode: "debounce", | ||
| 741 | time: 20, | ||
| 742 | key: () => "test-key", | ||
| 743 | getValue: (_) => 0, | ||
| 744 | async commit() { | ||
| 745 | return null; | ||
| 746 | }, | ||
| 747 | describe: ({ args }) => `increment by ${args[0]}`, | ||
| 748 | async refetch() {}, | ||
| 749 | }); | ||
| 750 | |||
| 751 | assertEquals(mutation.describe(5), "increment by 5"); | ||
| 752 | }); | ||
| 753 | |||
| 754 | // ============================================================================ | ||
| 755 | // Promise resolution tests | ||
| 756 | // ============================================================================ | ||
| 757 | |||
| 758 | test("BatchMutation - all pending promises resolve with same result", async () => { | ||
| 759 | const { client } = createTestClient(); | ||
| 760 | testStore.clear(); | ||
| 761 | testStore.set("counter", 0); | ||
| 762 | |||
| 763 | const mutation = client.defineDebounced({ | ||
| 764 | optimistic({ helpers }, amount: number) { | ||
| 765 | helpers.increment("counter", amount); | ||
| 766 | }, | ||
| 767 | mode: "debounce", | ||
| 768 | time: 30, | ||
| 769 | key: () => "test-key", | ||
| 770 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 771 | async commit({ initial, current }) { | ||
| 772 | return { delta: current - initial, timestamp: Date.now() }; | ||
| 773 | }, | ||
| 774 | describe: "increment counter", | ||
| 775 | async refetch() {}, | ||
| 776 | }); | ||
| 777 | |||
| 778 | const promise1 = mutation.runAndReturn(1); | ||
| 779 | const promise2 = mutation.runAndReturn(2); | ||
| 780 | const promise3 = mutation.runAndReturn(3); | ||
| 781 | |||
| 782 | const [result1, result2, result3] = await Promise.all([ | ||
| 783 | promise1, | ||
| 784 | promise2, | ||
| 785 | promise3, | ||
| 786 | ]); | ||
| 787 | |||
| 788 | // All should get the same result object | ||
| 789 | assertEquals(result1, result2); | ||
| 790 | assertEquals(result2, result3); | ||
| 791 | assertEquals(result1.delta, 6); | ||
| 792 | }); | ||
| 793 | |||
| 794 | test("BatchMutation - all pending promises reject with same error", async () => { | ||
| 795 | const { client } = createTestClient(); | ||
| 796 | testStore.clear(); | ||
| 797 | testStore.set("counter", 0); | ||
| 798 | |||
| 799 | const mutation = client.defineDebounced({ | ||
| 800 | optimistic({ helpers }, amount: number) { | ||
| 801 | helpers.increment("counter", amount); | ||
| 802 | }, | ||
| 803 | mode: "debounce", | ||
| 804 | time: 30, | ||
| 805 | key: () => "test-key", | ||
| 806 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 807 | async commit() { | ||
| 808 | throw new Error("batch commit failed"); | ||
| 809 | }, | ||
| 810 | describe: "increment counter", | ||
| 811 | async refetch() {}, | ||
| 812 | }); | ||
| 813 | |||
| 814 | const promise1 = mutation.runAndReturn(1); | ||
| 815 | const promise2 = mutation.runAndReturn(2); | ||
| 816 | const promise3 = mutation.runAndReturn(3); | ||
| 817 | |||
| 818 | const errors: Error[] = []; | ||
| 819 | await Promise.all([ | ||
| 820 | promise1.catch((e) => errors.push(e)), | ||
| 821 | promise2.catch((e) => errors.push(e)), | ||
| 822 | promise3.catch((e) => errors.push(e)), | ||
| 823 | ]); | ||
| 824 | |||
| 825 | // All should get the same error | ||
| 826 | assertEquals(errors.length, 3); | ||
| 827 | assertEquals(errors[0].message, "batch commit failed"); | ||
| 828 | assertEquals(errors[1].message, "batch commit failed"); | ||
| 829 | assertEquals(errors[2].message, "batch commit failed"); | ||
| 830 | }); | ||
| 831 | |||
| 832 | // ============================================================================ | ||
| 833 | // Edge case tests | ||
| 834 | // ============================================================================ | ||
| 835 | |||
| 836 | test("BatchMutation - handles empty getValue result", async () => { | ||
| 837 | const { client } = createTestClient(); | ||
| 838 | testStore.clear(); | ||
| 839 | |||
| 840 | let commitCallCount = 0; | ||
| 841 | |||
| 842 | const mutation = client.defineDebounced({ | ||
| 843 | optimistic({ helpers }, amount: number) { | ||
| 844 | helpers.setValue("nonexistent", amount); | ||
| 845 | }, | ||
| 846 | mode: "debounce", | ||
| 847 | time: 20, | ||
| 848 | key: () => "test-key", | ||
| 849 | getValue: (_) => testStore.get("nonexistent"), | ||
| 850 | async commit({ initial, current }) { | ||
| 851 | commitCallCount++; | ||
| 852 | return { initial, current }; | ||
| 853 | }, | ||
| 854 | describe: "test mutation", | ||
| 855 | }); | ||
| 856 | |||
| 857 | const result = await mutation.runAndReturn(5); | ||
| 858 | await delay(30); | ||
| 859 | |||
| 860 | assertEquals(commitCallCount, 1); | ||
| 861 | assertEquals(result.initial, undefined); | ||
| 862 | assertEquals(result.current, 5); | ||
| 863 | }); | ||
| 864 | |||
| 865 | test("BatchMutation - channel cleanup after idle with no listeners", async () => { | ||
| 866 | const { client } = createTestClient(); | ||
| 867 | testStore.clear(); | ||
| 868 | testStore.set("counter", 0); | ||
| 869 | |||
| 870 | const mutation = client.defineDebounced({ | ||
| 871 | optimistic({ helpers }, amount: number) { | ||
| 872 | helpers.increment("counter", amount); | ||
| 873 | }, | ||
| 874 | mode: "debounce", | ||
| 875 | time: 20, | ||
| 876 | key: () => "test-key", | ||
| 877 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 878 | async commit({ initial, current }) { | ||
| 879 | return current - initial; | ||
| 880 | }, | ||
| 881 | describe: "test mutation", | ||
| 882 | async refetch() {}, | ||
| 883 | }); | ||
| 884 | |||
| 885 | // Run mutation without subscribing | ||
| 886 | await mutation.runAndReturn(5); | ||
| 887 | await delay(30); | ||
| 888 | |||
| 889 | // Run another mutation - should work fine (channel recreated if needed) | ||
| 890 | const result = await mutation.runAndReturn(3); | ||
| 891 | await delay(30); | ||
| 892 | |||
| 893 | assertEquals(result, 3); | ||
| 894 | assertEquals(testStore.get("counter"), 8); | ||
| 895 | }); | ||
| 896 | |||
| 897 | test("BatchMutation - default time is 200ms", async () => { | ||
| 898 | const { client } = createTestClient(); | ||
| 899 | testStore.clear(); | ||
| 900 | testStore.set("counter", 0); | ||
| 901 | |||
| 902 | let commitTime: number | null = null; | ||
| 903 | const startTime = Date.now(); | ||
| 904 | |||
| 905 | const mutation = client.defineDebounced({ | ||
| 906 | optimistic({ helpers }, amount: number) { | ||
| 907 | helpers.increment("counter", amount); | ||
| 908 | }, | ||
| 909 | mode: "debounce", | ||
| 910 | // time not specified, should default to 200 | ||
| 911 | key: () => "test-key", | ||
| 912 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 913 | async commit({ initial, current }) { | ||
| 914 | commitTime = Date.now() - startTime; | ||
| 915 | return current - initial; | ||
| 916 | }, | ||
| 917 | describe: "test mutation", | ||
| 918 | async refetch() {}, | ||
| 919 | }); | ||
| 920 | |||
| 921 | await mutation.runAndReturn(5); | ||
| 922 | |||
| 923 | // Should commit after ~200ms (with some tolerance) | ||
| 924 | assertEquals(commitTime !== null, true); | ||
| 925 | assertEquals(commitTime! >= 180, true); | ||
| 926 | assertEquals(commitTime! <= 250, true); | ||
| 927 | }); | ||
| 928 | |||
| 929 | test("BatchMutation - context is passed to getValue", async () => { | ||
| 930 | const { client } = createTestClient(); | ||
| 931 | testStore.clear(); | ||
| 932 | testStore.set("counter", 0); | ||
| 933 | |||
| 934 | let receivedUserId: string | undefined; | ||
| 935 | |||
| 936 | const mutation = client.defineDebounced({ | ||
| 937 | optimistic({ helpers }, amount: number) { | ||
| 938 | helpers.increment("counter", amount); | ||
| 939 | }, | ||
| 940 | mode: "debounce", | ||
| 941 | time: 20, | ||
| 942 | key: () => "test-key", | ||
| 943 | getValue: ({ userId }, _) => { | ||
| 944 | receivedUserId = userId; | ||
| 945 | return testStore.get("counter") ?? 0; | ||
| 946 | }, | ||
| 947 | async commit({ initial, current }) { | ||
| 948 | return current - initial; | ||
| 949 | }, | ||
| 950 | describe: "test mutation", | ||
| 951 | async refetch() {}, | ||
| 952 | }); | ||
| 953 | |||
| 954 | await mutation.runAndReturn(5); | ||
| 955 | await delay(30); | ||
| 956 | |||
| 957 | assertEquals(receivedUserId, "test-user"); | ||
| 958 | }); | ||
| 959 | |||
| 960 | test("BatchMutation - context is passed to commit", async () => { | ||
| 961 | const { client } = createTestClient(); | ||
| 962 | testStore.clear(); | ||
| 963 | testStore.set("counter", 0); | ||
| 964 | |||
| 965 | let receivedUserId: string | undefined; | ||
| 966 | |||
| 967 | const mutation = client.defineDebounced({ | ||
| 968 | optimistic({ helpers }, amount: number) { | ||
| 969 | helpers.increment("counter", amount); | ||
| 970 | }, | ||
| 971 | mode: "debounce", | ||
| 972 | time: 20, | ||
| 973 | key: () => "test-key", | ||
| 974 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 975 | async commit({ userId, initial, current }) { | ||
| 976 | receivedUserId = userId; | ||
| 977 | return current - initial; | ||
| 978 | }, | ||
| 979 | describe: "test mutation", | ||
| 980 | async refetch() {}, | ||
| 981 | }); | ||
| 982 | |||
| 983 | await mutation.runAndReturn(5); | ||
| 984 | await delay(30); | ||
| 985 | |||
| 986 | assertEquals(receivedUserId, "test-user"); | ||
| 987 | }); | ||
| 988 | |||
| 989 | test("BatchMutation - first args are used for commit", async () => { | ||
| 990 | const { client } = createTestClient(); | ||
| 991 | testStore.clear(); | ||
| 992 | testStore.set("counter", 0); | ||
| 993 | |||
| 994 | let receivedArgs: [string, number] | undefined; | ||
| 995 | |||
| 996 | const mutation = client.defineDebounced({ | ||
| 997 | optimistic({ helpers }, _label: string, amount: number) { | ||
| 998 | helpers.increment("counter", amount); | ||
| 999 | }, | ||
| 1000 | mode: "debounce", | ||
| 1001 | time: 30, | ||
| 1002 | key: () => "test-key", | ||
| 1003 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 1004 | async commit({ args, initial, current }) { | ||
| 1005 | receivedArgs = args; | ||
| 1006 | return current - initial; | ||
| 1007 | }, | ||
| 1008 | describe: "test mutation", | ||
| 1009 | async refetch() {}, | ||
| 1010 | }); | ||
| 1011 | |||
| 1012 | mutation.runAndReturn("first", 1); | ||
| 1013 | mutation.runAndReturn("second", 2); | ||
| 1014 | await mutation.runAndReturn("third", 3); | ||
| 1015 | await delay(10); | ||
| 1016 | |||
| 1017 | // Should use first args | ||
| 1018 | assertEquals(receivedArgs, ["first", 1]); | ||
| 1019 | }); | ||
test/blocking.test.ts created+1006| ... | @@ -0,0 +1,1006 @@ | ||
| 1 | import { assertEquals, assertRejects } from "@std/assert"; | ||
| 2 | import { MutationClient } from "../src/client.ts"; | ||
| 3 | import type { MutationEvent } from "../src/types.ts"; | ||
| 4 | import { test } from "vitest"; | ||
| 5 | |||
| 6 | // Helper to create a test mutation client | ||
| 7 | function createTestClient() { | ||
| 8 | const errors: Array<{ message: string; error: unknown }> = []; | ||
| 9 | const successes: string[] = []; | ||
| 10 | const client = new MutationClient({ | ||
| 11 | context: { userId: "test-user" }, | ||
| 12 | getOptimisticHelpers({ onRestore }) { | ||
| 13 | return { | ||
| 14 | setValue(key: string, value: string) { | ||
| 15 | testStore.set(key, value); | ||
| 16 | onRestore(() => testStore.delete(key)); | ||
| 17 | }, | ||
| 18 | }; | ||
| 19 | }, | ||
| 20 | reportError(message, error) { | ||
| 21 | errors.push({ message, error }); | ||
| 22 | }, | ||
| 23 | reportSuccess(message) { | ||
| 24 | successes.push(message); | ||
| 25 | }, | ||
| 26 | }); | ||
| 27 | |||
| 28 | return { client, errors, successes }; | ||
| 29 | } | ||
| 30 | |||
| 31 | const testStore = new Map<string, string>(); | ||
| 32 | |||
| 33 | // Helper to track mutation events | ||
| 34 | function createEventTracker<Result>() { | ||
| 35 | const events: Array<MutationEvent<Result>> = []; | ||
| 36 | const callback = (event: MutationEvent<Result>) => { | ||
| 37 | events.push(event); | ||
| 38 | }; | ||
| 39 | return { events, callback }; | ||
| 40 | } | ||
| 41 | |||
| 42 | // Helper to wait for async operations | ||
| 43 | function delay(ms: number) { | ||
| 44 | return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| 45 | } | ||
| 46 | |||
| 47 | test("BlockingMutation - basic mutation success", async () => { | ||
| 48 | const { client } = createTestClient(); | ||
| 49 | let mutateCallCount = 0; | ||
| 50 | let refetchCallCount = 0; | ||
| 51 | |||
| 52 | const mutation = client.defineBlocking({ | ||
| 53 | async mutate(value: string) { | ||
| 54 | mutateCallCount++; | ||
| 55 | await delay(10); | ||
| 56 | return `result-${value}`; | ||
| 57 | }, | ||
| 58 | describe: "test mutation", | ||
| 59 | describeResult: "Success", | ||
| 60 | optimistic() { | ||
| 61 | // Empty optimistic update | ||
| 62 | }, | ||
| 63 | async refetch() { | ||
| 64 | refetchCallCount++; | ||
| 65 | await delay(5); | ||
| 66 | }, | ||
| 67 | }); | ||
| 68 | |||
| 69 | const result = await mutation.runAndReturn("test"); | ||
| 70 | // Wait for refetch to complete | ||
| 71 | await delay(20); | ||
| 72 | |||
| 73 | assertEquals(result, "result-test"); | ||
| 74 | assertEquals(mutateCallCount, 1); | ||
| 75 | assertEquals(refetchCallCount, 1); | ||
| 76 | }); | ||
| 77 | |||
| 78 | test("BlockingMutation - run() catches errors", async () => { | ||
| 79 | const { client, errors } = createTestClient(); | ||
| 80 | |||
| 81 | const mutation = client.defineBlocking({ | ||
| 82 | async mutate(_value: string) { | ||
| 83 | throw new Error("mutation failed"); | ||
| 84 | }, | ||
| 85 | describe: "failing mutation", | ||
| 86 | describeResult: "Success", | ||
| 87 | optimistic() {}, | ||
| 88 | async refetch() {}, | ||
| 89 | }); | ||
| 90 | |||
| 91 | mutation.run("test"); | ||
| 92 | await delay(50); | ||
| 93 | |||
| 94 | assertEquals(errors.length, 1); | ||
| 95 | assertEquals((errors[0].error as Error).message, "mutation failed"); | ||
| 96 | }); | ||
| 97 | |||
| 98 | test("BlockingMutation - runAndReturn() rejects on error", async () => { | ||
| 99 | const { client } = createTestClient(); | ||
| 100 | |||
| 101 | const mutation = client.defineBlocking({ | ||
| 102 | async mutate(_value: string) { | ||
| 103 | throw new Error("mutation failed"); | ||
| 104 | }, | ||
| 105 | describe: "failing mutation", | ||
| 106 | describeResult: "Success", | ||
| 107 | optimistic() {}, | ||
| 108 | async refetch() {}, | ||
| 109 | }); | ||
| 110 | |||
| 111 | await assertRejects( | ||
| 112 | () => mutation.runAndReturn("test"), | ||
| 113 | Error, | ||
| 114 | "mutation failed", | ||
| 115 | ); | ||
| 116 | }); | ||
| 117 | |||
| 118 | test("BlockingMutation - optimistic updates are applied immediately", async () => { | ||
| 119 | const { client } = createTestClient(); | ||
| 120 | testStore.clear(); | ||
| 121 | |||
| 122 | const mutation = client.defineBlocking({ | ||
| 123 | async mutate(_key: string, value: string) { | ||
| 124 | await delay(50); | ||
| 125 | return value; | ||
| 126 | }, | ||
| 127 | describe: "set value", | ||
| 128 | describeResult: "Success", | ||
| 129 | optimistic({ args, helpers }) { | ||
| 130 | const [key, value] = args; | ||
| 131 | helpers.setValue(key, value); | ||
| 132 | }, | ||
| 133 | async refetch() {}, | ||
| 134 | }); | ||
| 135 | |||
| 136 | const promise = mutation.runAndReturn("key1", "value1"); | ||
| 137 | |||
| 138 | // Optimistic update should be applied synchronously | ||
| 139 | assertEquals(testStore.get("key1"), "value1"); | ||
| 140 | |||
| 141 | // Wait for mutation to complete | ||
| 142 | await promise; | ||
| 143 | await delay(10); | ||
| 144 | }); | ||
| 145 | |||
| 146 | test("BlockingMutation - rollback on error", async () => { | ||
| 147 | const { client } = createTestClient(); | ||
| 148 | testStore.clear(); | ||
| 149 | |||
| 150 | const mutation = client.defineBlocking({ | ||
| 151 | async mutate(_key: string, _value: string) { | ||
| 152 | await delay(10); | ||
| 153 | throw new Error("mutation failed"); | ||
| 154 | }, | ||
| 155 | describe: "failing mutation", | ||
| 156 | describeResult: "Success", | ||
| 157 | optimistic({ args, helpers }) { | ||
| 158 | const [key, value] = args; | ||
| 159 | helpers.setValue(key, value); | ||
| 160 | }, | ||
| 161 | async refetch() {}, | ||
| 162 | }); | ||
| 163 | |||
| 164 | await assertRejects(() => mutation.runAndReturn("key1", "value1")); | ||
| 165 | |||
| 166 | // Optimistic update should be rolled back | ||
| 167 | assertEquals(testStore.has("key1"), false); | ||
| 168 | }); | ||
| 169 | |||
| 170 | test("BlockingMutation - onSuccess callback is called", async () => { | ||
| 171 | const { client } = createTestClient(); | ||
| 172 | const successResults: string[] = []; | ||
| 173 | |||
| 174 | const mutation = client.defineBlocking({ | ||
| 175 | async mutate(value: string) { | ||
| 176 | return `result-${value}`; | ||
| 177 | }, | ||
| 178 | describe: "test mutation", | ||
| 179 | describeResult: "Success", | ||
| 180 | optimistic({ onSuccess }) { | ||
| 181 | onSuccess((result) => { | ||
| 182 | successResults.push(result); | ||
| 183 | }); | ||
| 184 | }, | ||
| 185 | async refetch() {}, | ||
| 186 | }); | ||
| 187 | |||
| 188 | await mutation.runAndReturn("test"); | ||
| 189 | |||
| 190 | assertEquals(successResults, ["result-test"]); | ||
| 191 | }); | ||
| 192 | |||
| 193 | test("BlockingMutation - mutations with same key execute serially", async () => { | ||
| 194 | const { client } = createTestClient(); | ||
| 195 | const executionOrder: string[] = []; | ||
| 196 | |||
| 197 | const mutation = client.defineBlocking({ | ||
| 198 | async mutate(id: string) { | ||
| 199 | executionOrder.push(`start-${id}`); | ||
| 200 | await delay(20); | ||
| 201 | executionOrder.push(`end-${id}`); | ||
| 202 | return id; | ||
| 203 | }, | ||
| 204 | describe: "test mutation", | ||
| 205 | describeResult: "Success", | ||
| 206 | optimistic() {}, | ||
| 207 | async refetch() {}, | ||
| 208 | refetchOnSuccess: false, | ||
| 209 | key() { | ||
| 210 | return "same-key"; | ||
| 211 | }, | ||
| 212 | }); | ||
| 213 | |||
| 214 | // Start two mutations with the same key | ||
| 215 | const promise1 = mutation.runAndReturn("1"); | ||
| 216 | const promise2 = mutation.runAndReturn("2"); | ||
| 217 | |||
| 218 | await Promise.all([promise1, promise2]); | ||
| 219 | await delay(10); | ||
| 220 | |||
| 221 | // They should execute serially, not in parallel | ||
| 222 | assertEquals(executionOrder, ["start-1", "end-1", "start-2", "end-2"]); | ||
| 223 | }); | ||
| 224 | |||
| 225 | test("BlockingMutation - mutations with different keys execute in parallel", async () => { | ||
| 226 | const { client } = createTestClient(); | ||
| 227 | const executionOrder: string[] = []; | ||
| 228 | |||
| 229 | const mutation = client.defineBlocking({ | ||
| 230 | async mutate(id: string) { | ||
| 231 | executionOrder.push(`start-${id}`); | ||
| 232 | await delay(20); | ||
| 233 | executionOrder.push(`end-${id}`); | ||
| 234 | return id; | ||
| 235 | }, | ||
| 236 | describe: "test mutation", | ||
| 237 | describeResult: "Success", | ||
| 238 | optimistic() {}, | ||
| 239 | async refetch() {}, | ||
| 240 | key({ args }) { | ||
| 241 | const [id] = args; | ||
| 242 | return id; | ||
| 243 | }, | ||
| 244 | }); | ||
| 245 | |||
| 246 | // Start two mutations with different keys | ||
| 247 | const promise1 = mutation.runAndReturn("key1"); | ||
| 248 | const promise2 = mutation.runAndReturn("key2"); | ||
| 249 | |||
| 250 | await Promise.all([promise1, promise2]); | ||
| 251 | |||
| 252 | // They should start in parallel | ||
| 253 | assertEquals(executionOrder.slice(0, 2).sort(), ["start-key1", "start-key2"]); | ||
| 254 | }); | ||
| 255 | |||
| 256 | test("BlockingMutation - key() returns JSON stringified key", () => { | ||
| 257 | const { client } = createTestClient(); | ||
| 258 | |||
| 259 | const mutation = client.defineBlocking({ | ||
| 260 | async mutate(id: string) { | ||
| 261 | return id; | ||
| 262 | }, | ||
| 263 | describe: "test mutation", | ||
| 264 | describeResult: "Success", | ||
| 265 | optimistic() {}, | ||
| 266 | async refetch() {}, | ||
| 267 | key({ args }) { | ||
| 268 | const [id] = args; | ||
| 269 | return id; | ||
| 270 | }, | ||
| 271 | }); | ||
| 272 | |||
| 273 | assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id")); | ||
| 274 | }); | ||
| 275 | |||
| 276 | test("BlockingMutation - key() defaults to 'shared' when no key function", () => { | ||
| 277 | const { client } = createTestClient(); | ||
| 278 | |||
| 279 | const mutation = client.defineBlocking({ | ||
| 280 | async mutate(id: string) { | ||
| 281 | return id; | ||
| 282 | }, | ||
| 283 | describe: "test mutation", | ||
| 284 | describeResult: "Success", | ||
| 285 | optimistic() {}, | ||
| 286 | async refetch() {}, | ||
| 287 | }); | ||
| 288 | |||
| 289 | assertEquals(mutation.key(["test-id"]), JSON.stringify("shared")); | ||
| 290 | }); | ||
| 291 | |||
| 292 | test("BlockingMutation - key() can return array", () => { | ||
| 293 | const { client } = createTestClient(); | ||
| 294 | |||
| 295 | const mutation = client.defineBlocking({ | ||
| 296 | async mutate(_userId: string, _itemId: string) { | ||
| 297 | return "result"; | ||
| 298 | }, | ||
| 299 | describe: "test mutation", | ||
| 300 | describeResult: "Success", | ||
| 301 | optimistic() {}, | ||
| 302 | async refetch() {}, | ||
| 303 | key({ args }) { | ||
| 304 | const [userId, itemId] = args; | ||
| 305 | return [userId, itemId]; | ||
| 306 | }, | ||
| 307 | }); | ||
| 308 | |||
| 309 | assertEquals( | ||
| 310 | mutation.key(["user1", "item1"]), | ||
| 311 | JSON.stringify(["user1", "item1"]), | ||
| 312 | ); | ||
| 313 | }); | ||
| 314 | |||
| 315 | test("BlockingMutation - describe() with string", () => { | ||
| 316 | const { client } = createTestClient(); | ||
| 317 | |||
| 318 | const mutation = client.defineBlocking({ | ||
| 319 | async mutate(value: string) { | ||
| 320 | return value; | ||
| 321 | }, | ||
| 322 | describe: "create item", | ||
| 323 | describeResult: "Success", | ||
| 324 | optimistic() {}, | ||
| 325 | async refetch() {}, | ||
| 326 | }); | ||
| 327 | |||
| 328 | assertEquals(mutation.describe("test"), "create item"); | ||
| 329 | }); | ||
| 330 | |||
| 331 | test("BlockingMutation - describe() with function", () => { | ||
| 332 | const { client } = createTestClient(); | ||
| 333 | |||
| 334 | const mutation = client.defineBlocking({ | ||
| 335 | async mutate(id: string) { | ||
| 336 | return id; | ||
| 337 | }, | ||
| 338 | describe({ args }) { | ||
| 339 | const [id] = args; | ||
| 340 | return `delete item ${id}`; | ||
| 341 | }, | ||
| 342 | optimistic() {}, | ||
| 343 | async refetch() {}, | ||
| 344 | }); | ||
| 345 | |||
| 346 | assertEquals(mutation.describe("123"), "delete item 123"); | ||
| 347 | }); | ||
| 348 | |||
| 349 | test("BlockingMutation - describe() receives context", () => { | ||
| 350 | const { client } = createTestClient(); | ||
| 351 | |||
| 352 | const mutation = client.defineBlocking({ | ||
| 353 | async mutate(id: string) { | ||
| 354 | return id; | ||
| 355 | }, | ||
| 356 | describe({ userId, args }) { | ||
| 357 | const [id] = args; | ||
| 358 | return `user ${userId} editing item ${id}`; | ||
| 359 | }, | ||
| 360 | optimistic() {}, | ||
| 361 | async refetch() {}, | ||
| 362 | }); | ||
| 363 | |||
| 364 | assertEquals( | ||
| 365 | mutation.describe("123"), | ||
| 366 | "user test-user editing item 123", | ||
| 367 | ); | ||
| 368 | }); | ||
| 369 | |||
| 370 | test("BlockingMutation - subscribe() tracks mutation events", async () => { | ||
| 371 | const { client } = createTestClient(); | ||
| 372 | const tracker = createEventTracker<string>(); | ||
| 373 | |||
| 374 | const mutation = client.defineBlocking({ | ||
| 375 | async mutate(value: string) { | ||
| 376 | await delay(10); | ||
| 377 | return `result-${value}`; | ||
| 378 | }, | ||
| 379 | describe: "test mutation", | ||
| 380 | describeResult: "Success", | ||
| 381 | optimistic() {}, | ||
| 382 | async refetch() { | ||
| 383 | await delay(5); | ||
| 384 | }, | ||
| 385 | }); | ||
| 386 | |||
| 387 | const key = mutation.key(["test"]); | ||
| 388 | mutation.subscribe(key, tracker.callback); | ||
| 389 | |||
| 390 | await mutation.runAndReturn("test"); | ||
| 391 | // Wait for refetch to complete | ||
| 392 | await delay(20); | ||
| 393 | |||
| 394 | // Should have received status updates | ||
| 395 | assertEquals(tracker.events.length >= 2, true); | ||
| 396 | assertEquals(tracker.events.some((e) => e.status === "mutating"), true); | ||
| 397 | assertEquals(tracker.events.some((e) => e.status === "refetching"), true); | ||
| 398 | }); | ||
| 399 | |||
| 400 | test("BlockingMutation - unsubscribe stops receiving events", async () => { | ||
| 401 | const { client } = createTestClient(); | ||
| 402 | const tracker = createEventTracker<string>(); | ||
| 403 | |||
| 404 | const mutation = client.defineBlocking({ | ||
| 405 | async mutate(value: string) { | ||
| 406 | await delay(10); | ||
| 407 | return value; | ||
| 408 | }, | ||
| 409 | describe: "test mutation", | ||
| 410 | describeResult: "Success", | ||
| 411 | optimistic() {}, | ||
| 412 | async refetch() {}, | ||
| 413 | refetchOnSuccess: false, | ||
| 414 | }); | ||
| 415 | |||
| 416 | const key = mutation.key(["test"]); | ||
| 417 | const unsubscribe = mutation.subscribe(key, tracker.callback); | ||
| 418 | |||
| 419 | unsubscribe(); | ||
| 420 | |||
| 421 | await mutation.runAndReturn("test"); | ||
| 422 | await delay(10); | ||
| 423 | |||
| 424 | // Should not have received any events | ||
| 425 | assertEquals(tracker.events.length, 0); | ||
| 426 | }); | ||
| 427 | |||
| 428 | test("BlockingMutation - refetchOnSuccess can be disabled", async () => { | ||
| 429 | const { client } = createTestClient(); | ||
| 430 | let refetchCallCount = 0; | ||
| 431 | |||
| 432 | const mutation = client.defineBlocking({ | ||
| 433 | async mutate(_value: string) { | ||
| 434 | return _value; | ||
| 435 | }, | ||
| 436 | describe: "test mutation", | ||
| 437 | describeResult: "Success", | ||
| 438 | optimistic() {}, | ||
| 439 | async refetch() { | ||
| 440 | refetchCallCount++; | ||
| 441 | }, | ||
| 442 | refetchOnSuccess: false, | ||
| 443 | }); | ||
| 444 | |||
| 445 | await mutation.runAndReturn("test"); | ||
| 446 | |||
| 447 | assertEquals(refetchCallCount, 0); | ||
| 448 | }); | ||
| 449 | |||
| 450 | test("BlockingMutation - refetch is called on error", async () => { | ||
| 451 | const { client } = createTestClient(); | ||
| 452 | let refetchCallCount = 0; | ||
| 453 | |||
| 454 | const mutation = client.defineBlocking({ | ||
| 455 | async mutate(_value: string) { | ||
| 456 | throw new Error("mutation failed"); | ||
| 457 | }, | ||
| 458 | describe: "failing mutation", | ||
| 459 | describeResult: "Success", | ||
| 460 | optimistic() {}, | ||
| 461 | async refetch() { | ||
| 462 | refetchCallCount++; | ||
| 463 | }, | ||
| 464 | }); | ||
| 465 | |||
| 466 | await assertRejects(() => mutation.runAndReturn("test")); | ||
| 467 | |||
| 468 | assertEquals(refetchCallCount, 1); | ||
| 469 | }); | ||
| 470 | |||
| 471 | test("BlockingMutation - queued mutations are cancelled on error", async () => { | ||
| 472 | const { client } = createTestClient(); | ||
| 473 | const executionOrder: string[] = []; | ||
| 474 | |||
| 475 | const mutation = client.defineBlocking({ | ||
| 476 | async mutate(id: string) { | ||
| 477 | executionOrder.push(`start-${id}`); | ||
| 478 | await delay(10); | ||
| 479 | if (id === "1") { | ||
| 480 | throw new Error("first mutation failed"); | ||
| 481 | } | ||
| 482 | executionOrder.push(`end-${id}`); | ||
| 483 | return id; | ||
| 484 | }, | ||
| 485 | describe: "test mutation", | ||
| 486 | describeResult: "Success", | ||
| 487 | optimistic() {}, | ||
| 488 | async refetch() {}, | ||
| 489 | key() { | ||
| 490 | return "same-key"; | ||
| 491 | }, | ||
| 492 | }); | ||
| 493 | |||
| 494 | const promise1 = mutation.runAndReturn("1"); | ||
| 495 | const promise2 = mutation.runAndReturn("2"); | ||
| 496 | const promise3 = mutation.runAndReturn("3"); | ||
| 497 | |||
| 498 | await assertRejects(() => promise1, Error, "first mutation failed"); | ||
| 499 | await assertRejects(() => promise2, Error, "first mutation failed"); | ||
| 500 | await assertRejects(() => promise3, Error, "first mutation failed"); | ||
| 501 | |||
| 502 | // Only the first mutation should start | ||
| 503 | assertEquals(executionOrder, ["start-1"]); | ||
| 504 | }); | ||
| 505 | |||
| 506 | test("BlockingMutation - rollbacks are called in reverse order on error", async () => { | ||
| 507 | const { client } = createTestClient(); | ||
| 508 | const rollbackOrder: number[] = []; | ||
| 509 | |||
| 510 | const mutation = client.defineBlocking({ | ||
| 511 | async mutate(_value: string) { | ||
| 512 | throw new Error("mutation failed"); | ||
| 513 | }, | ||
| 514 | describe: "failing mutation", | ||
| 515 | describeResult: "Success", | ||
| 516 | optimistic({ onRestore }) { | ||
| 517 | onRestore(() => rollbackOrder.push(1)); | ||
| 518 | onRestore(() => rollbackOrder.push(2)); | ||
| 519 | onRestore(() => rollbackOrder.push(3)); | ||
| 520 | }, | ||
| 521 | async refetch() {}, | ||
| 522 | }); | ||
| 523 | |||
| 524 | await assertRejects(() => mutation.runAndReturn("test")); | ||
| 525 | |||
| 526 | // Rollbacks should be called in reverse order | ||
| 527 | assertEquals(rollbackOrder, [3, 2, 1]); | ||
| 528 | }); | ||
| 529 | |||
| 530 | test("BlockingMutation - multiple mutations: rollbacks only affect failed mutation", async () => { | ||
| 531 | const { client } = createTestClient(); | ||
| 532 | const rollbackOrder: string[] = []; | ||
| 533 | |||
| 534 | const mutation = client.defineBlocking({ | ||
| 535 | async mutate(id: string) { | ||
| 536 | await delay(10); | ||
| 537 | if (id === "fail") { | ||
| 538 | throw new Error("mutation failed"); | ||
| 539 | } | ||
| 540 | return id; | ||
| 541 | }, | ||
| 542 | describe: "test mutation", | ||
| 543 | describeResult: "Success", | ||
| 544 | optimistic({ args: [id], onRestore }) { | ||
| 545 | onRestore(() => rollbackOrder.push(`rollback-${id}`)); | ||
| 546 | }, | ||
| 547 | async refetch() {}, | ||
| 548 | key() { | ||
| 549 | return "same-key"; | ||
| 550 | }, | ||
| 551 | }); | ||
| 552 | |||
| 553 | // First mutation succeeds | ||
| 554 | await mutation.runAndReturn("success"); | ||
| 555 | |||
| 556 | // Second mutation fails | ||
| 557 | await assertRejects(() => mutation.runAndReturn("fail")); | ||
| 558 | |||
| 559 | // Only the failed mutation's rollback should be called | ||
| 560 | // And all rollbacks from queued items | ||
| 561 | assertEquals(rollbackOrder, ["rollback-fail"]); | ||
| 562 | }); | ||
| 563 | |||
| 564 | test("BlockingMutation - onRestore throws error if called after optimistic phase", async () => { | ||
| 565 | const { client } = createTestClient(); | ||
| 566 | let capturedOnRestore: ((cb: () => void) => void) | null = null; | ||
| 567 | |||
| 568 | const mutation = client.defineBlocking({ | ||
| 569 | async mutate(_value: string) { | ||
| 570 | return "result"; | ||
| 571 | }, | ||
| 572 | describe: "test mutation", | ||
| 573 | describeResult: "Success", | ||
| 574 | optimistic({ onRestore }) { | ||
| 575 | capturedOnRestore = onRestore; | ||
| 576 | }, | ||
| 577 | async refetch() {}, | ||
| 578 | }); | ||
| 579 | |||
| 580 | await mutation.runAndReturn("test"); | ||
| 581 | |||
| 582 | // Calling onRestore after the optimistic phase should throw | ||
| 583 | let error: Error | null = null; | ||
| 584 | try { | ||
| 585 | capturedOnRestore!(() => {}); | ||
| 586 | } catch (e) { | ||
| 587 | error = e as Error; | ||
| 588 | } | ||
| 589 | |||
| 590 | assertEquals( | ||
| 591 | error?.message, | ||
| 592 | "Can only call onRestore from within the optimistic update function.", | ||
| 593 | ); | ||
| 594 | }); | ||
| 595 | |||
| 596 | test("BlockingMutation - onSuccess throws error if called after optimistic phase", async () => { | ||
| 597 | const { client } = createTestClient(); | ||
| 598 | let capturedOnSuccess: ((cb: (result: string) => void) => void) | null = null; | ||
| 599 | |||
| 600 | const mutation = client.defineBlocking({ | ||
| 601 | async mutate(_value: string) { | ||
| 602 | return "result"; | ||
| 603 | }, | ||
| 604 | describe: "test mutation", | ||
| 605 | describeResult: "Success", | ||
| 606 | optimistic({ onSuccess }) { | ||
| 607 | capturedOnSuccess = onSuccess; | ||
| 608 | }, | ||
| 609 | async refetch() {}, | ||
| 610 | }); | ||
| 611 | |||
| 612 | await mutation.runAndReturn("test"); | ||
| 613 | |||
| 614 | // Calling onSuccess after the optimistic phase should throw | ||
| 615 | let error: Error | null = null; | ||
| 616 | try { | ||
| 617 | capturedOnSuccess!(() => {}); | ||
| 618 | } catch (e) { | ||
| 619 | error = e as Error; | ||
| 620 | } | ||
| 621 | |||
| 622 | assertEquals( | ||
| 623 | error?.message, | ||
| 624 | "Can only call onSuccess from within the optimistic update function.", | ||
| 625 | ); | ||
| 626 | }); | ||
| 627 | |||
| 628 | test("BlockingMutation - error during optimistic update is rejected immediately", async () => { | ||
| 629 | const { client } = createTestClient(); | ||
| 630 | |||
| 631 | const mutation = client.defineBlocking({ | ||
| 632 | async mutate(_value: string) { | ||
| 633 | return "result"; | ||
| 634 | }, | ||
| 635 | describe: "test mutation", | ||
| 636 | describeResult: "Success", | ||
| 637 | optimistic() { | ||
| 638 | throw new Error("optimistic update failed"); | ||
| 639 | }, | ||
| 640 | async refetch() {}, | ||
| 641 | }); | ||
| 642 | |||
| 643 | await assertRejects( | ||
| 644 | () => mutation.runAndReturn("test"), | ||
| 645 | Error, | ||
| 646 | "optimistic update failed", | ||
| 647 | ); | ||
| 648 | }); | ||
| 649 | |||
| 650 | test("BlockingMutation - error during optimistic update rolls back registered callbacks", async () => { | ||
| 651 | const { client } = createTestClient(); | ||
| 652 | const rollbackOrder: number[] = []; | ||
| 653 | |||
| 654 | const mutation = client.defineBlocking({ | ||
| 655 | async mutate(_value: string) { | ||
| 656 | return "result"; | ||
| 657 | }, | ||
| 658 | describe: "test mutation", | ||
| 659 | describeResult: "Success", | ||
| 660 | optimistic({ onRestore }) { | ||
| 661 | onRestore(() => rollbackOrder.push(1)); | ||
| 662 | onRestore(() => rollbackOrder.push(2)); | ||
| 663 | throw new Error("optimistic update failed"); | ||
| 664 | }, | ||
| 665 | async refetch() {}, | ||
| 666 | }); | ||
| 667 | |||
| 668 | await assertRejects(() => mutation.runAndReturn("test")); | ||
| 669 | |||
| 670 | // Rollbacks should be called even though optimistic update failed | ||
| 671 | // Note: during optimistic error, rollbacks are executed in the order they were added | ||
| 672 | assertEquals(rollbackOrder, [1, 2]); | ||
| 673 | }); | ||
| 674 | |||
| 675 | test("BlockingMutation - refetch errors are reported but don't fail mutation", async () => { | ||
| 676 | const { client, errors } = createTestClient(); | ||
| 677 | |||
| 678 | const mutation = client.defineBlocking({ | ||
| 679 | async mutate(value: string) { | ||
| 680 | return value; | ||
| 681 | }, | ||
| 682 | describe: "test mutation", | ||
| 683 | describeResult: "Success", | ||
| 684 | optimistic() {}, | ||
| 685 | async refetch() { | ||
| 686 | throw new Error("refetch failed"); | ||
| 687 | }, | ||
| 688 | }); | ||
| 689 | |||
| 690 | // Mutation should still succeed | ||
| 691 | const result = await mutation.runAndReturn("test"); | ||
| 692 | assertEquals(result, "test"); | ||
| 693 | |||
| 694 | // But refetch error should be reported | ||
| 695 | await delay(20); | ||
| 696 | assertEquals(errors.length, 1); | ||
| 697 | assertEquals((errors[0].error as Error).message, "refetch failed"); | ||
| 698 | }); | ||
| 699 | |||
| 700 | test("BlockingMutation - optimistic function receives args and helpers", async () => { | ||
| 701 | const { client } = createTestClient(); | ||
| 702 | let receivedArgs: unknown[] | undefined; | ||
| 703 | let receivedHelpers: unknown | undefined; | ||
| 704 | |||
| 705 | const mutation = client.defineBlocking({ | ||
| 706 | async mutate(_value: string) { | ||
| 707 | return "result"; | ||
| 708 | }, | ||
| 709 | describe: "test mutation", | ||
| 710 | describeResult: "Success", | ||
| 711 | optimistic({ args, helpers }) { | ||
| 712 | receivedArgs = args; | ||
| 713 | receivedHelpers = helpers; | ||
| 714 | }, | ||
| 715 | async refetch() {}, | ||
| 716 | }); | ||
| 717 | |||
| 718 | await mutation.runAndReturn("test"); | ||
| 719 | |||
| 720 | assertEquals(receivedArgs, ["test"]); | ||
| 721 | assertEquals(typeof receivedHelpers, "object"); | ||
| 722 | }); | ||
| 723 | |||
| 724 | test("BlockingMutation - refetch receives context and args", async () => { | ||
| 725 | const { client } = createTestClient(); | ||
| 726 | let receivedUserId: string | undefined; | ||
| 727 | let receivedArgs: unknown[] | undefined; | ||
| 728 | |||
| 729 | const mutation = client.defineBlocking({ | ||
| 730 | async mutate(_id: string, value: string) { | ||
| 731 | return value; | ||
| 732 | }, | ||
| 733 | describe: "test mutation", | ||
| 734 | describeResult: "Success", | ||
| 735 | optimistic() {}, | ||
| 736 | async refetch({ userId, args }) { | ||
| 737 | receivedUserId = userId; | ||
| 738 | receivedArgs = args; | ||
| 739 | }, | ||
| 740 | }); | ||
| 741 | |||
| 742 | await mutation.runAndReturn("test-id", "test-value"); | ||
| 743 | |||
| 744 | assertEquals(receivedUserId, "test-user"); | ||
| 745 | assertEquals(receivedArgs, ["test-id", "test-value"]); | ||
| 746 | }); | ||
| 747 | |||
| 748 | test("BlockingMutation - notifies error on mutation failure", async () => { | ||
| 749 | const { client } = createTestClient(); | ||
| 750 | const tracker = createEventTracker<string>(); | ||
| 751 | |||
| 752 | const mutation = client.defineBlocking({ | ||
| 753 | async mutate(_value: string) { | ||
| 754 | await delay(10); | ||
| 755 | throw new Error("mutation failed"); | ||
| 756 | }, | ||
| 757 | describe: "failing mutation", | ||
| 758 | describeResult: "Success", | ||
| 759 | optimistic() {}, | ||
| 760 | async refetch() {}, | ||
| 761 | }); | ||
| 762 | |||
| 763 | const key = mutation.key(["test"]); | ||
| 764 | mutation.subscribe(key, tracker.callback); | ||
| 765 | |||
| 766 | await assertRejects(() => mutation.runAndReturn("test")); | ||
| 767 | |||
| 768 | // Should have error event | ||
| 769 | const errorEvents = tracker.events.filter((e) => | ||
| 770 | e.status === "mutating" && e.error | ||
| 771 | ); | ||
| 772 | assertEquals(errorEvents.length > 0, true); | ||
| 773 | assertEquals((errorEvents[0]?.error as Error).message, "mutation failed"); | ||
| 774 | }); | ||
| 775 | |||
| 776 | test("BlockingMutation - multiple subscribers receive events", async () => { | ||
| 777 | const { client } = createTestClient(); | ||
| 778 | const tracker1 = createEventTracker<string>(); | ||
| 779 | const tracker2 = createEventTracker<string>(); | ||
| 780 | |||
| 781 | const mutation = client.defineBlocking({ | ||
| 782 | async mutate(value: string) { | ||
| 783 | await delay(5); | ||
| 784 | return value; | ||
| 785 | }, | ||
| 786 | describe: "test mutation", | ||
| 787 | describeResult: "Success", | ||
| 788 | optimistic() {}, | ||
| 789 | async refetch() {}, | ||
| 790 | refetchOnSuccess: false, | ||
| 791 | }); | ||
| 792 | |||
| 793 | const key = mutation.key(["test"]); | ||
| 794 | mutation.subscribe(key, tracker1.callback); | ||
| 795 | mutation.subscribe(key, tracker2.callback); | ||
| 796 | |||
| 797 | await mutation.runAndReturn("test"); | ||
| 798 | await delay(10); | ||
| 799 | |||
| 800 | // Both subscribers should receive events | ||
| 801 | assertEquals(tracker1.events.length, tracker2.events.length); | ||
| 802 | assertEquals(tracker1.events.length > 0, true); | ||
| 803 | }); | ||
| 804 | |||
| 805 | test("BlockingMutation - onSuccess is called before mutation resolves", async () => { | ||
| 806 | const { client } = createTestClient(); | ||
| 807 | const callOrder: string[] = []; | ||
| 808 | |||
| 809 | const mutation = client.defineBlocking({ | ||
| 810 | async mutate(value: string) { | ||
| 811 | return value; | ||
| 812 | }, | ||
| 813 | describe: "test mutation", | ||
| 814 | describeResult: "Success", | ||
| 815 | optimistic({ onSuccess }) { | ||
| 816 | onSuccess(() => { | ||
| 817 | callOrder.push("onSuccess"); | ||
| 818 | }); | ||
| 819 | }, | ||
| 820 | async refetch() {}, | ||
| 821 | refetchOnSuccess: false, | ||
| 822 | }); | ||
| 823 | |||
| 824 | const promise = mutation.runAndReturn("test"); | ||
| 825 | promise.then(() => { | ||
| 826 | callOrder.push("then"); | ||
| 827 | }); | ||
| 828 | |||
| 829 | await promise; | ||
| 830 | await delay(5); | ||
| 831 | |||
| 832 | // onSuccess should be called before the promise resolves | ||
| 833 | assertEquals(callOrder, ["onSuccess", "then"]); | ||
| 834 | }); | ||
| 835 | |||
| 836 | test("BlockingMutation - result is passed to notification on success", async () => { | ||
| 837 | const { client } = createTestClient(); | ||
| 838 | const tracker = createEventTracker<string>(); | ||
| 839 | |||
| 840 | const mutation = client.defineBlocking({ | ||
| 841 | async mutate(value: string) { | ||
| 842 | await delay(5); | ||
| 843 | return `result-${value}`; | ||
| 844 | }, | ||
| 845 | describe: "test mutation", | ||
| 846 | describeResult: "Success", | ||
| 847 | optimistic() {}, | ||
| 848 | async refetch() { | ||
| 849 | await delay(5); | ||
| 850 | }, | ||
| 851 | }); | ||
| 852 | |||
| 853 | const key = mutation.key(["test"]); | ||
| 854 | mutation.subscribe(key, tracker.callback); | ||
| 855 | |||
| 856 | await mutation.runAndReturn("test"); | ||
| 857 | await delay(20); | ||
| 858 | |||
| 859 | // Should have refetching event with result | ||
| 860 | const refetchingEvents = tracker.events.filter((e) => | ||
| 861 | e.status === "refetching" | ||
| 862 | ); | ||
| 863 | assertEquals(refetchingEvents.length > 0, true); | ||
| 864 | assertEquals(refetchingEvents[0]?.result, "result-test"); | ||
| 865 | }); | ||
| 866 | |||
| 867 | test("BlockingMutation - channel is reused for same key", async () => { | ||
| 868 | const { client } = createTestClient(); | ||
| 869 | const events: string[] = []; | ||
| 870 | |||
| 871 | const mutation = client.defineBlocking({ | ||
| 872 | async mutate(value: string) { | ||
| 873 | events.push(`mutate-${value}`); | ||
| 874 | return value; | ||
| 875 | }, | ||
| 876 | describe: "test mutation", | ||
| 877 | describeResult: "Success", | ||
| 878 | optimistic() {}, | ||
| 879 | async refetch() {}, | ||
| 880 | refetchOnSuccess: false, | ||
| 881 | }); | ||
| 882 | |||
| 883 | // First mutation | ||
| 884 | await mutation.runAndReturn("first"); | ||
| 885 | await delay(5); | ||
| 886 | |||
| 887 | // Second mutation with same key | ||
| 888 | await mutation.runAndReturn("second"); | ||
| 889 | await delay(5); | ||
| 890 | |||
| 891 | assertEquals(events, ["mutate-first", "mutate-second"]); | ||
| 892 | }); | ||
| 893 | |||
| 894 | test("BlockingMutation - empty queue after all mutations complete", async () => { | ||
| 895 | const { client } = createTestClient(); | ||
| 896 | |||
| 897 | const mutation = client.defineBlocking({ | ||
| 898 | async mutate(value: string) { | ||
| 899 | await delay(5); | ||
| 900 | return value; | ||
| 901 | }, | ||
| 902 | describe: "test mutation", | ||
| 903 | describeResult: "Success", | ||
| 904 | optimistic() {}, | ||
| 905 | async refetch() {}, | ||
| 906 | refetchOnSuccess: false, | ||
| 907 | key() { | ||
| 908 | return "test-key"; | ||
| 909 | }, | ||
| 910 | }); | ||
| 911 | |||
| 912 | // Run multiple mutations | ||
| 913 | await mutation.runAndReturn("1"); | ||
| 914 | await mutation.runAndReturn("2"); | ||
| 915 | await mutation.runAndReturn("3"); | ||
| 916 | await delay(10); | ||
| 917 | |||
| 918 | // All mutations should have completed | ||
| 919 | // (We can't directly check the queue, but we can verify by running another mutation) | ||
| 920 | const start = Date.now(); | ||
| 921 | await mutation.runAndReturn("4"); | ||
| 922 | const duration = Date.now() - start; | ||
| 923 | |||
| 924 | // Should execute immediately, not be queued (< 10ms if not queued) | ||
| 925 | assertEquals(duration < 15, true); | ||
| 926 | }); | ||
| 927 | |||
| 928 | test("BlockingMutation - multiple onSuccess callbacks are all called", async () => { | ||
| 929 | const { client } = createTestClient(); | ||
| 930 | const results: string[] = []; | ||
| 931 | |||
| 932 | const mutation = client.defineBlocking({ | ||
| 933 | async mutate(value: string) { | ||
| 934 | return value; | ||
| 935 | }, | ||
| 936 | describe: "test mutation", | ||
| 937 | describeResult: "Success", | ||
| 938 | optimistic({ onSuccess }) { | ||
| 939 | onSuccess((result) => results.push(`first-${result}`)); | ||
| 940 | onSuccess((result) => results.push(`second-${result}`)); | ||
| 941 | onSuccess((result) => results.push(`third-${result}`)); | ||
| 942 | }, | ||
| 943 | async refetch() {}, | ||
| 944 | refetchOnSuccess: false, | ||
| 945 | }); | ||
| 946 | |||
| 947 | await mutation.runAndReturn("test"); | ||
| 948 | |||
| 949 | assertEquals(results, ["first-test", "second-test", "third-test"]); | ||
| 950 | }); | ||
| 951 | |||
| 952 | test("BlockingMutation - refetchOnSuccess false skips refetch", async () => { | ||
| 953 | const { client } = createTestClient(); | ||
| 954 | let refetchCalled = false; | ||
| 955 | |||
| 956 | const mutation = client.defineBlocking({ | ||
| 957 | async mutate(value: string) { | ||
| 958 | return value; | ||
| 959 | }, | ||
| 960 | describe: "test mutation", | ||
| 961 | describeResult: "Success", | ||
| 962 | optimistic() {}, | ||
| 963 | async refetch() { | ||
| 964 | refetchCalled = true; | ||
| 965 | }, | ||
| 966 | refetchOnSuccess: false, | ||
| 967 | }); | ||
| 968 | |||
| 969 | await mutation.runAndReturn("test"); | ||
| 970 | await delay(10); | ||
| 971 | |||
| 972 | // Refetch should not have been called | ||
| 973 | assertEquals(refetchCalled, false); | ||
| 974 | }); | ||
| 975 | |||
| 976 | test("BlockingMutation - refetch error after mutation failure is reported", async () => { | ||
| 977 | const { client, errors } = createTestClient(); | ||
| 978 | |||
| 979 | const mutation = client.defineBlocking({ | ||
| 980 | async mutate(_value: string) { | ||
| 981 | throw new Error("mutation failed"); | ||
| 982 | }, | ||
| 983 | describe: "failing mutation", | ||
| 984 | describeResult: "Success", | ||
| 985 | optimistic() {}, | ||
| 986 | async refetch() { | ||
| 987 | throw new Error("refetch also failed"); | ||
| 988 | }, | ||
| 989 | }); | ||
| 990 | |||
| 991 | await assertRejects( | ||
| 992 | () => mutation.runAndReturn("test"), | ||
| 993 | Error, | ||
| 994 | "mutation failed", | ||
| 995 | ); | ||
| 996 | |||
| 997 | // Wait for refetch to complete and error to be reported | ||
| 998 | await delay(20); | ||
| 999 | |||
| 1000 | // Should have both the mutation error and refetch error reported | ||
| 1001 | assertEquals(errors.length >= 1, true); | ||
| 1002 | assertEquals( | ||
| 1003 | (errors[errors.length - 1].error as Error).message, | ||
| 1004 | "refetch also failed", | ||
| 1005 | ); | ||
| 1006 | }); | ||
test/debounced.test.ts created+1050| ... | @@ -0,0 +1,1050 @@ | ||
| 1 | import { assertEquals, assertRejects } from "@std/assert"; | ||
| 2 | import { MutationClient } from "../src/client.ts"; | ||
| 3 | import type { MutationEvent } from "../src/types.ts"; | ||
| 4 | import { test } from "vitest"; | ||
| 5 | |||
| 6 | // Shared test store for optimistic updates | ||
| 7 | const testStore = new Map<string, number>(); | ||
| 8 | |||
| 9 | // Helper to create a test mutation client | ||
| 10 | function createTestClient() { | ||
| 11 | const errors: Array<{ message: string; error: unknown }> = []; | ||
| 12 | const successes: string[] = []; | ||
| 13 | const client = new MutationClient({ | ||
| 14 | context: { userId: "test-user" }, | ||
| 15 | getOptimisticHelpers({ onRestore }) { | ||
| 16 | return { | ||
| 17 | increment(key: string, amount: number) { | ||
| 18 | const oldValue = testStore.get(key) ?? 0; | ||
| 19 | testStore.set(key, oldValue + amount); | ||
| 20 | onRestore(() => testStore.set(key, oldValue)); | ||
| 21 | }, | ||
| 22 | setValue(key: string, value: number) { | ||
| 23 | const oldValue = testStore.get(key); | ||
| 24 | testStore.set(key, value); | ||
| 25 | onRestore(() => { | ||
| 26 | if (oldValue === undefined) { | ||
| 27 | testStore.delete(key); | ||
| 28 | } else { | ||
| 29 | testStore.set(key, oldValue); | ||
| 30 | } | ||
| 31 | }); | ||
| 32 | }, | ||
| 33 | }; | ||
| 34 | }, | ||
| 35 | reportError(message, error) { | ||
| 36 | errors.push({ message, error }); | ||
| 37 | }, | ||
| 38 | reportSuccess(message) { | ||
| 39 | successes.push(message); | ||
| 40 | }, | ||
| 41 | }); | ||
| 42 | |||
| 43 | return { client, errors, successes }; | ||
| 44 | } | ||
| 45 | |||
| 46 | // Helper to track mutation events | ||
| 47 | function createEventTracker<Result>() { | ||
| 48 | const events: Array<MutationEvent<Result>> = []; | ||
| 49 | const callback = (event: MutationEvent<Result>) => { | ||
| 50 | events.push({ ...event }); | ||
| 51 | }; | ||
| 52 | return { events, callback }; | ||
| 53 | } | ||
| 54 | |||
| 55 | // Helper to wait for async operations | ||
| 56 | function delay(ms: number) { | ||
| 57 | return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| 58 | } | ||
| 59 | |||
| 60 | // ============================================================================ | ||
| 61 | // Basic functionality tests | ||
| 62 | // ============================================================================ | ||
| 63 | |||
| 64 | test("DebouncedMutation - basic mutation success with debounce", async () => { | ||
| 65 | const { client } = createTestClient(); | ||
| 66 | testStore.clear(); | ||
| 67 | testStore.set("counter", 0); | ||
| 68 | |||
| 69 | let commitCallCount = 0; | ||
| 70 | let refetchCallCount = 0; | ||
| 71 | |||
| 72 | const mutation = client.defineDebounced({ | ||
| 73 | optimistic({ helpers }, amount: number) { | ||
| 74 | helpers.increment("counter", amount); | ||
| 75 | }, | ||
| 76 | mode: "debounce", | ||
| 77 | time: 50, | ||
| 78 | key: () => "test-key", | ||
| 79 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 80 | async commit({ initial, current }) { | ||
| 81 | commitCallCount++; | ||
| 82 | await delay(10); | ||
| 83 | return current - initial; | ||
| 84 | }, | ||
| 85 | describe: "increment counter", | ||
| 86 | describeResult: "Success", | ||
| 87 | async refetch() { | ||
| 88 | refetchCallCount++; | ||
| 89 | }, | ||
| 90 | }); | ||
| 91 | |||
| 92 | const result = await mutation.runAndReturn(5); | ||
| 93 | await delay(20); // Wait for refetch | ||
| 94 | |||
| 95 | assertEquals(result, 5); | ||
| 96 | assertEquals(commitCallCount, 1); | ||
| 97 | assertEquals(refetchCallCount, 1); | ||
| 98 | assertEquals(testStore.get("counter"), 5); | ||
| 99 | }); | ||
| 100 | |||
| 101 | test("DebouncedMutation - run() catches errors", async () => { | ||
| 102 | const { client, errors } = createTestClient(); | ||
| 103 | testStore.clear(); | ||
| 104 | testStore.set("counter", 0); | ||
| 105 | |||
| 106 | const mutation = client.defineDebounced({ | ||
| 107 | optimistic({ helpers }, amount: number) { | ||
| 108 | helpers.increment("counter", amount); | ||
| 109 | }, | ||
| 110 | mode: "debounce", | ||
| 111 | time: 20, | ||
| 112 | key: () => "test-key", | ||
| 113 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 114 | async commit() { | ||
| 115 | throw new Error("commit failed"); | ||
| 116 | }, | ||
| 117 | describe: "failing mutation", | ||
| 118 | describeResult: "Success", | ||
| 119 | async refetch() {}, | ||
| 120 | }); | ||
| 121 | |||
| 122 | mutation.run(5); | ||
| 123 | await delay(100); | ||
| 124 | |||
| 125 | assertEquals(errors.length, 1); | ||
| 126 | assertEquals((errors[0].error as Error).message, "commit failed"); | ||
| 127 | }); | ||
| 128 | |||
| 129 | test("DebouncedMutation - runAndReturn() rejects on error", async () => { | ||
| 130 | const { client } = createTestClient(); | ||
| 131 | testStore.clear(); | ||
| 132 | testStore.set("counter", 0); | ||
| 133 | |||
| 134 | const mutation = client.defineDebounced({ | ||
| 135 | optimistic({ helpers }, amount: number) { | ||
| 136 | helpers.increment("counter", amount); | ||
| 137 | }, | ||
| 138 | mode: "debounce", | ||
| 139 | time: 10, | ||
| 140 | key: () => "test-key", | ||
| 141 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 142 | async commit() { | ||
| 143 | throw new Error("commit failed"); | ||
| 144 | }, | ||
| 145 | describe: "failing mutation", | ||
| 146 | describeResult: "Success", | ||
| 147 | async refetch() {}, | ||
| 148 | }); | ||
| 149 | |||
| 150 | await assertRejects( | ||
| 151 | () => mutation.runAndReturn(5), | ||
| 152 | Error, | ||
| 153 | "commit failed", | ||
| 154 | ); | ||
| 155 | }); | ||
| 156 | |||
| 157 | // ============================================================================ | ||
| 158 | // Debounce mode tests | ||
| 159 | // ============================================================================ | ||
| 160 | |||
| 161 | test("DebouncedMutation - debounce batches rapid calls", async () => { | ||
| 162 | const { client } = createTestClient(); | ||
| 163 | testStore.clear(); | ||
| 164 | testStore.set("counter", 0); | ||
| 165 | |||
| 166 | let commitCallCount = 0; | ||
| 167 | const commitArgs: Array<{ initial: number; current: number }> = []; | ||
| 168 | |||
| 169 | const mutation = client.defineDebounced({ | ||
| 170 | optimistic({ helpers }, amount: number) { | ||
| 171 | helpers.increment("counter", amount); | ||
| 172 | }, | ||
| 173 | mode: "debounce", | ||
| 174 | time: 50, | ||
| 175 | key: () => "test-key", | ||
| 176 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 177 | async commit({ initial, current }) { | ||
| 178 | commitCallCount++; | ||
| 179 | commitArgs.push({ initial, current }); | ||
| 180 | return current - initial; | ||
| 181 | }, | ||
| 182 | describe: "increment counter", | ||
| 183 | describeResult: "Success", | ||
| 184 | async refetch() {}, | ||
| 185 | }); | ||
| 186 | |||
| 187 | // Rapid calls within debounce window | ||
| 188 | const promise1 = mutation.runAndReturn(1); | ||
| 189 | const promise2 = mutation.runAndReturn(2); | ||
| 190 | const promise3 = mutation.runAndReturn(3); | ||
| 191 | |||
| 192 | // Optimistic updates should be applied immediately | ||
| 193 | assertEquals(testStore.get("counter"), 6); | ||
| 194 | |||
| 195 | const results = await Promise.all([promise1, promise2, promise3]); | ||
| 196 | |||
| 197 | // All should resolve with the same result (total delta) | ||
| 198 | assertEquals(results, [6, 6, 6]); | ||
| 199 | |||
| 200 | // Only one commit should have been made | ||
| 201 | assertEquals(commitCallCount, 1); | ||
| 202 | assertEquals(commitArgs, [{ initial: 0, current: 6 }]); | ||
| 203 | }); | ||
| 204 | |||
| 205 | test("DebouncedMutation - debounce resets timer on each call", async () => { | ||
| 206 | const { client } = createTestClient(); | ||
| 207 | testStore.clear(); | ||
| 208 | testStore.set("counter", 0); | ||
| 209 | |||
| 210 | let commitCallCount = 0; | ||
| 211 | |||
| 212 | const mutation = client.defineDebounced({ | ||
| 213 | optimistic({ helpers }, amount: number) { | ||
| 214 | helpers.increment("counter", amount); | ||
| 215 | }, | ||
| 216 | mode: "debounce", | ||
| 217 | time: 30, | ||
| 218 | key: () => "test-key", | ||
| 219 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 220 | async commit({ initial, current }) { | ||
| 221 | commitCallCount++; | ||
| 222 | return current - initial; | ||
| 223 | }, | ||
| 224 | describe: "increment counter", | ||
| 225 | describeResult: "Success", | ||
| 226 | async refetch() {}, | ||
| 227 | }); | ||
| 228 | |||
| 229 | // First call | ||
| 230 | const promise1 = mutation.runAndReturn(1); | ||
| 231 | |||
| 232 | // Wait less than debounce time | ||
| 233 | await delay(15); | ||
| 234 | |||
| 235 | // Second call should reset the timer | ||
| 236 | const promise2 = mutation.runAndReturn(2); | ||
| 237 | |||
| 238 | // Wait less than debounce time again | ||
| 239 | await delay(15); | ||
| 240 | |||
| 241 | // Commit should not have happened yet | ||
| 242 | assertEquals(commitCallCount, 0); | ||
| 243 | |||
| 244 | // Third call | ||
| 245 | const promise3 = mutation.runAndReturn(3); | ||
| 246 | |||
| 247 | // Wait for all to complete | ||
| 248 | await Promise.all([promise1, promise2, promise3]); | ||
| 249 | |||
| 250 | // Only one commit | ||
| 251 | assertEquals(commitCallCount, 1); | ||
| 252 | }); | ||
| 253 | |||
| 254 | test("DebouncedMutation - debounce separates batches after timeout", async () => { | ||
| 255 | const { client } = createTestClient(); | ||
| 256 | testStore.clear(); | ||
| 257 | testStore.set("counter", 0); | ||
| 258 | |||
| 259 | let commitCallCount = 0; | ||
| 260 | const commitArgs: Array<{ initial: number; current: number }> = []; | ||
| 261 | |||
| 262 | const mutation = client.defineDebounced({ | ||
| 263 | optimistic({ helpers }, amount: number) { | ||
| 264 | helpers.increment("counter", amount); | ||
| 265 | }, | ||
| 266 | mode: "debounce", | ||
| 267 | time: 30, | ||
| 268 | key: () => "test-key", | ||
| 269 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 270 | async commit({ initial, current }) { | ||
| 271 | commitCallCount++; | ||
| 272 | commitArgs.push({ initial, current }); | ||
| 273 | return current - initial; | ||
| 274 | }, | ||
| 275 | describe: "increment counter", | ||
| 276 | describeResult: "Success", | ||
| 277 | async refetch() {}, | ||
| 278 | }); | ||
| 279 | |||
| 280 | // First batch | ||
| 281 | await mutation.runAndReturn(1); | ||
| 282 | await delay(50); // Wait for first batch to complete | ||
| 283 | |||
| 284 | // Second batch (after timeout) | ||
| 285 | await mutation.runAndReturn(2); | ||
| 286 | await delay(50); | ||
| 287 | |||
| 288 | // Two separate commits | ||
| 289 | assertEquals(commitCallCount, 2); | ||
| 290 | assertEquals(commitArgs, [ | ||
| 291 | { initial: 0, current: 1 }, | ||
| 292 | { initial: 1, current: 3 }, | ||
| 293 | ]); | ||
| 294 | }); | ||
| 295 | |||
| 296 | // ============================================================================ | ||
| 297 | // Throttle mode tests | ||
| 298 | // ============================================================================ | ||
| 299 | |||
| 300 | test("DebouncedMutation - throttle commits immediately on first call", async () => { | ||
| 301 | const { client } = createTestClient(); | ||
| 302 | testStore.clear(); | ||
| 303 | testStore.set("counter", 0); | ||
| 304 | |||
| 305 | let commitTime = 0; | ||
| 306 | const startTime = Date.now(); | ||
| 307 | |||
| 308 | const mutation = client.defineDebounced({ | ||
| 309 | optimistic({ helpers }, amount: number) { | ||
| 310 | helpers.increment("counter", amount); | ||
| 311 | }, | ||
| 312 | mode: "throttle", | ||
| 313 | time: 100, | ||
| 314 | key: () => "test-key", | ||
| 315 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 316 | async commit({ initial, current }) { | ||
| 317 | commitTime = Date.now() - startTime; | ||
| 318 | return current - initial; | ||
| 319 | }, | ||
| 320 | describe: "increment counter", | ||
| 321 | describeResult: "Success", | ||
| 322 | async refetch() {}, | ||
| 323 | }); | ||
| 324 | |||
| 325 | await mutation.runAndReturn(5); | ||
| 326 | |||
| 327 | // First call should commit immediately (within a small tolerance) | ||
| 328 | assertEquals(commitTime < 20, true); | ||
| 329 | }); | ||
| 330 | |||
| 331 | test("DebouncedMutation - throttle batches calls within time window", async () => { | ||
| 332 | const { client } = createTestClient(); | ||
| 333 | testStore.clear(); | ||
| 334 | testStore.set("counter", 0); | ||
| 335 | |||
| 336 | let commitCallCount = 0; | ||
| 337 | const commitArgs: Array<{ initial: number; current: number }> = []; | ||
| 338 | |||
| 339 | const mutation = client.defineDebounced({ | ||
| 340 | optimistic({ helpers }, amount: number) { | ||
| 341 | helpers.increment("counter", amount); | ||
| 342 | }, | ||
| 343 | mode: "throttle", | ||
| 344 | time: 100, | ||
| 345 | key: () => "test-key", | ||
| 346 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 347 | async commit({ initial, current }) { | ||
| 348 | commitCallCount++; | ||
| 349 | commitArgs.push({ initial, current }); | ||
| 350 | await delay(10); | ||
| 351 | return current - initial; | ||
| 352 | }, | ||
| 353 | describe: "increment counter", | ||
| 354 | describeResult: "Success", | ||
| 355 | async refetch() {}, | ||
| 356 | }); | ||
| 357 | |||
| 358 | // First call commits immediately | ||
| 359 | const promise1 = mutation.runAndReturn(1); | ||
| 360 | await delay(5); | ||
| 361 | |||
| 362 | // Second call within throttle window - should batch | ||
| 363 | const promise2 = mutation.runAndReturn(2); | ||
| 364 | await delay(5); | ||
| 365 | |||
| 366 | // Third call within throttle window - should batch with second | ||
| 367 | const promise3 = mutation.runAndReturn(3); | ||
| 368 | |||
| 369 | // Wait for first to complete | ||
| 370 | await promise1; | ||
| 371 | |||
| 372 | // First commit happened immediately | ||
| 373 | assertEquals(commitCallCount, 1); | ||
| 374 | assertEquals(commitArgs[0], { initial: 0, current: 1 }); | ||
| 375 | |||
| 376 | // Wait for throttle window to pass and second batch to commit | ||
| 377 | await Promise.all([promise2, promise3]); | ||
| 378 | await delay(50); | ||
| 379 | |||
| 380 | // Second batch committed | ||
| 381 | assertEquals(commitCallCount, 2); | ||
| 382 | assertEquals(commitArgs[1], { initial: 1, current: 6 }); | ||
| 383 | }); | ||
| 384 | |||
| 385 | test("DebouncedMutation - throttle allows new batch after time window", async () => { | ||
| 386 | const { client } = createTestClient(); | ||
| 387 | testStore.clear(); | ||
| 388 | testStore.set("counter", 0); | ||
| 389 | |||
| 390 | let commitCallCount = 0; | ||
| 391 | |||
| 392 | const mutation = client.defineDebounced({ | ||
| 393 | optimistic({ helpers }, amount: number) { | ||
| 394 | helpers.increment("counter", amount); | ||
| 395 | }, | ||
| 396 | mode: "throttle", | ||
| 397 | time: 50, | ||
| 398 | key: () => "test-key", | ||
| 399 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 400 | async commit({ initial, current }) { | ||
| 401 | commitCallCount++; | ||
| 402 | return current - initial; | ||
| 403 | }, | ||
| 404 | describe: "increment counter", | ||
| 405 | describeResult: "Success", | ||
| 406 | async refetch() {}, | ||
| 407 | }); | ||
| 408 | |||
| 409 | // First call | ||
| 410 | await mutation.runAndReturn(1); | ||
| 411 | await delay(10); | ||
| 412 | |||
| 413 | assertEquals(commitCallCount, 1); | ||
| 414 | |||
| 415 | // Wait for throttle window to pass | ||
| 416 | await delay(60); | ||
| 417 | |||
| 418 | // Second call should commit immediately | ||
| 419 | await mutation.runAndReturn(2); | ||
| 420 | await delay(10); | ||
| 421 | |||
| 422 | assertEquals(commitCallCount, 2); | ||
| 423 | }); | ||
| 424 | |||
| 425 | // ============================================================================ | ||
| 426 | // No-op detection tests | ||
| 427 | // ============================================================================ | ||
| 428 | |||
| 429 | test("DebouncedMutation - skips commit when value unchanged", async () => { | ||
| 430 | const { client } = createTestClient(); | ||
| 431 | testStore.clear(); | ||
| 432 | testStore.set("counter", 5); | ||
| 433 | |||
| 434 | let commitCallCount = 0; | ||
| 435 | |||
| 436 | const mutation = client.defineDebounced({ | ||
| 437 | optimistic({ helpers }, amount: number) { | ||
| 438 | helpers.increment("counter", amount); | ||
| 439 | }, | ||
| 440 | mode: "debounce", | ||
| 441 | time: 20, | ||
| 442 | key: () => "test-key", | ||
| 443 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 444 | async commit({ initial, current }) { | ||
| 445 | commitCallCount++; | ||
| 446 | return current - initial; | ||
| 447 | }, | ||
| 448 | describe: "increment counter", | ||
| 449 | describeResult: "Success", | ||
| 450 | async refetch() {}, | ||
| 451 | }); | ||
| 452 | |||
| 453 | // +5 and -5 cancel out | ||
| 454 | const promise1 = mutation.runAndReturn(5); | ||
| 455 | const promise2 = mutation.runAndReturn(-5); | ||
| 456 | |||
| 457 | const [result1, result2] = await Promise.all([promise1, promise2]); | ||
| 458 | |||
| 459 | // No commit should have been made | ||
| 460 | assertEquals(commitCallCount, 0); | ||
| 461 | |||
| 462 | // Results should be null (no actual change) | ||
| 463 | assertEquals(result1, null); | ||
| 464 | assertEquals(result2, null); | ||
| 465 | |||
| 466 | // Store should be unchanged | ||
| 467 | assertEquals(testStore.get("counter"), 5); | ||
| 468 | }); | ||
| 469 | |||
| 470 | test("DebouncedMutation - uses deepEquals for comparison", async () => { | ||
| 471 | const errors: unknown[] = []; | ||
| 472 | const objectStore: { value: { count: number } | null } = { | ||
| 473 | value: { count: 0 }, | ||
| 474 | }; | ||
| 475 | |||
| 476 | const client = new MutationClient({ | ||
| 477 | context: {}, | ||
| 478 | getOptimisticHelpers({ onRestore }) { | ||
| 479 | return { | ||
| 480 | setCount(count: number) { | ||
| 481 | const old = objectStore.value; | ||
| 482 | objectStore.value = { count }; | ||
| 483 | onRestore(() => { | ||
| 484 | objectStore.value = old; | ||
| 485 | }); | ||
| 486 | }, | ||
| 487 | }; | ||
| 488 | }, | ||
| 489 | reportError(message, error) { | ||
| 490 | errors.push(error); | ||
| 491 | }, | ||
| 492 | }); | ||
| 493 | |||
| 494 | let commitCallCount = 0; | ||
| 495 | |||
| 496 | const mutation = client.defineDebounced({ | ||
| 497 | optimistic({ helpers }, count: number) { | ||
| 498 | helpers.setCount(count); | ||
| 499 | }, | ||
| 500 | mode: "debounce", | ||
| 501 | time: 20, | ||
| 502 | key: () => "test-key", | ||
| 503 | getValue: (_) => objectStore.value, | ||
| 504 | async commit() { | ||
| 505 | commitCallCount++; | ||
| 506 | return null; | ||
| 507 | }, | ||
| 508 | describe: "set count", | ||
| 509 | describeResult: "Success", | ||
| 510 | async refetch() {}, | ||
| 511 | }); | ||
| 512 | |||
| 513 | // Set to same value (different object reference but same content) | ||
| 514 | await mutation.runAndReturn(0); | ||
| 515 | await delay(30); | ||
| 516 | |||
| 517 | // Should skip commit because value is deeply equal | ||
| 518 | assertEquals(commitCallCount, 0); | ||
| 519 | }); | ||
| 520 | |||
| 521 | test("DebouncedMutation - custom deepEquals function", async () => { | ||
| 522 | const errors: unknown[] = []; | ||
| 523 | let compareCallCount = 0; | ||
| 524 | |||
| 525 | const client = new MutationClient({ | ||
| 526 | context: {}, | ||
| 527 | getOptimisticHelpers({ onRestore }) { | ||
| 528 | return { | ||
| 529 | increment(key: string, amount: number) { | ||
| 530 | const old = testStore.get(key) ?? 0; | ||
| 531 | testStore.set(key, old + amount); | ||
| 532 | onRestore(() => testStore.set(key, old)); | ||
| 533 | }, | ||
| 534 | }; | ||
| 535 | }, | ||
| 536 | reportError(message, error) { | ||
| 537 | errors.push(error); | ||
| 538 | }, | ||
| 539 | deepEquals(a, b) { | ||
| 540 | compareCallCount++; | ||
| 541 | // Custom comparison | ||
| 542 | return a === b; | ||
| 543 | }, | ||
| 544 | }); | ||
| 545 | |||
| 546 | testStore.clear(); | ||
| 547 | testStore.set("counter", 0); | ||
| 548 | |||
| 549 | const mutation = client.defineDebounced({ | ||
| 550 | optimistic({ helpers }, amount: number) { | ||
| 551 | helpers.increment("counter", amount); | ||
| 552 | }, | ||
| 553 | mode: "debounce", | ||
| 554 | time: 10, | ||
| 555 | key: () => "test-key", | ||
| 556 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 557 | async commit() { | ||
| 558 | throw new Error("commit failed"); | ||
| 559 | }, | ||
| 560 | describe: "failing mutation", | ||
| 561 | describeResult: "Success", | ||
| 562 | async refetch() {}, | ||
| 563 | }); | ||
| 564 | |||
| 565 | await mutation.runAndReturn(5).catch(() => { | ||
| 566 | // Expected to fail due to commit error | ||
| 567 | }); | ||
| 568 | await delay(30); | ||
| 569 | |||
| 570 | // Custom deepEquals should have been called | ||
| 571 | assertEquals(compareCallCount > 0, true); | ||
| 572 | }); | ||
| 573 | |||
| 574 | // ============================================================================ | ||
| 575 | // Rollback tests | ||
| 576 | // ============================================================================ | ||
| 577 | |||
| 578 | test("DebouncedMutation - rollback on commit error", async () => { | ||
| 579 | const { client } = createTestClient(); | ||
| 580 | testStore.clear(); | ||
| 581 | testStore.set("counter", 10); | ||
| 582 | |||
| 583 | const mutation = client.defineDebounced({ | ||
| 584 | optimistic({ helpers }, amount: number) { | ||
| 585 | helpers.increment("counter", amount); | ||
| 586 | }, | ||
| 587 | mode: "debounce", | ||
| 588 | time: 20, | ||
| 589 | key: () => "test-key", | ||
| 590 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 591 | async commit() { | ||
| 592 | throw new Error("commit failed"); | ||
| 593 | }, | ||
| 594 | describe: "failing mutation", | ||
| 595 | describeResult: "Success", | ||
| 596 | async refetch() {}, | ||
| 597 | }); | ||
| 598 | |||
| 599 | // Optimistic update applied | ||
| 600 | const promise = mutation.runAndReturn(5); | ||
| 601 | assertEquals(testStore.get("counter"), 15); | ||
| 602 | |||
| 603 | await assertRejects(() => promise, Error, "commit failed"); | ||
| 604 | |||
| 605 | // Should be rolled back | ||
| 606 | assertEquals(testStore.get("counter"), 10); | ||
| 607 | }); | ||
| 608 | |||
| 609 | test("DebouncedMutation - error event includes error details", async () => { | ||
| 610 | const { client } = createTestClient(); | ||
| 611 | testStore.clear(); | ||
| 612 | testStore.set("counter", 0); | ||
| 613 | |||
| 614 | const tracker = createEventTracker<number>(); | ||
| 615 | |||
| 616 | const mutation = client.defineDebounced({ | ||
| 617 | optimistic({ helpers }, amount: number) { | ||
| 618 | helpers.increment("counter", amount); | ||
| 619 | }, | ||
| 620 | mode: "debounce", | ||
| 621 | time: 20, | ||
| 622 | key: () => "test-key", | ||
| 623 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 624 | async commit() { | ||
| 625 | throw new Error("commit failed"); | ||
| 626 | }, | ||
| 627 | describe: "failing mutation", | ||
| 628 | describeResult: "Success", | ||
| 629 | async refetch() {}, | ||
| 630 | }); | ||
| 631 | |||
| 632 | const key = mutation.key([5]); | ||
| 633 | mutation.subscribe(key, tracker.callback); | ||
| 634 | |||
| 635 | await assertRejects(() => mutation.runAndReturn(5)); | ||
| 636 | await delay(30); | ||
| 637 | |||
| 638 | // Should have error in events | ||
| 639 | const errorEvents = tracker.events.filter((e) => e.error !== null); | ||
| 640 | assertEquals(errorEvents.length > 0, true); | ||
| 641 | assertEquals((errorEvents[0]?.error as Error).message, "commit failed"); | ||
| 642 | }); | ||
| 643 | |||
| 644 | // ============================================================================ | ||
| 645 | // Key handling tests | ||
| 646 | // ============================================================================ | ||
| 647 | |||
| 648 | test("DebouncedMutation - key() returns JSON stringified key", () => { | ||
| 649 | const { client } = createTestClient(); | ||
| 650 | testStore.clear(); | ||
| 651 | |||
| 652 | const mutation = client.defineDebounced({ | ||
| 653 | optimistic(_ctx, _id: string) {}, | ||
| 654 | mode: "debounce", | ||
| 655 | time: 20, | ||
| 656 | key: ({ args }) => args[0], | ||
| 657 | getValue: (_) => 0, | ||
| 658 | async commit() { | ||
| 659 | return null; | ||
| 660 | }, | ||
| 661 | describe: "test mutation", | ||
| 662 | describeResult: "Success", | ||
| 663 | async refetch() {}, | ||
| 664 | }); | ||
| 665 | |||
| 666 | assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id")); | ||
| 667 | }); | ||
| 668 | |||
| 669 | test("DebouncedMutation - key() can return array", () => { | ||
| 670 | const { client } = createTestClient(); | ||
| 671 | testStore.clear(); | ||
| 672 | |||
| 673 | const mutation = client.defineDebounced({ | ||
| 674 | optimistic(_ctx, _id: string) {}, | ||
| 675 | mode: "debounce", | ||
| 676 | time: 20, | ||
| 677 | key: ({ args }) => ["user", args[0]], | ||
| 678 | getValue: (_) => 0, | ||
| 679 | async commit() { | ||
| 680 | return null; | ||
| 681 | }, | ||
| 682 | describe: "test mutation", | ||
| 683 | describeResult: "Success", | ||
| 684 | async refetch() {}, | ||
| 685 | }); | ||
| 686 | |||
| 687 | assertEquals( | ||
| 688 | mutation.key(["123"]), | ||
| 689 | JSON.stringify(["user", "123"]), | ||
| 690 | ); | ||
| 691 | }); | ||
| 692 | |||
| 693 | test("DebouncedMutation - different keys create separate batches", async () => { | ||
| 694 | const { client } = createTestClient(); | ||
| 695 | testStore.clear(); | ||
| 696 | testStore.set("counter-a", 0); | ||
| 697 | testStore.set("counter-b", 0); | ||
| 698 | |||
| 699 | let commitCallCount = 0; | ||
| 700 | |||
| 701 | const mutation = client.defineDebounced({ | ||
| 702 | optimistic({ helpers }, key: string, amount: number) { | ||
| 703 | helpers.increment(`counter-${key}`, amount); | ||
| 704 | }, | ||
| 705 | mode: "debounce", | ||
| 706 | time: 20, | ||
| 707 | key: ({ args }) => args[0], | ||
| 708 | getValue: ({ args: [key] }) => testStore.get(`counter-${key}`) ?? 0, | ||
| 709 | async commit({ current }) { | ||
| 710 | commitCallCount++; | ||
| 711 | return current; | ||
| 712 | }, | ||
| 713 | describe: "test mutation", | ||
| 714 | describeResult: "Success", | ||
| 715 | async refetch() {}, | ||
| 716 | }); | ||
| 717 | |||
| 718 | // Two different keys | ||
| 719 | const promise1 = mutation.runAndReturn("a", 5); | ||
| 720 | const promise2 = mutation.runAndReturn("b", 10); | ||
| 721 | |||
| 722 | await Promise.all([promise1, promise2]); | ||
| 723 | await delay(30); | ||
| 724 | |||
| 725 | // Should have two separate commits | ||
| 726 | assertEquals(commitCallCount, 2); | ||
| 727 | assertEquals(testStore.get("counter-a"), 5); | ||
| 728 | assertEquals(testStore.get("counter-b"), 10); | ||
| 729 | }); | ||
| 730 | |||
| 731 | // ============================================================================ | ||
| 732 | // Describe tests | ||
| 733 | // ============================================================================ | ||
| 734 | |||
| 735 | test("DebouncedMutation - describe() with string", () => { | ||
| 736 | const { client } = createTestClient(); | ||
| 737 | testStore.clear(); | ||
| 738 | |||
| 739 | const mutation = client.defineDebounced({ | ||
| 740 | optimistic(_ctx, _amount: number) {}, | ||
| 741 | mode: "debounce", | ||
| 742 | time: 20, | ||
| 743 | key: () => "test-key", | ||
| 744 | getValue: (_) => 0, | ||
| 745 | async commit() { | ||
| 746 | return null; | ||
| 747 | }, | ||
| 748 | describe: "update counter", | ||
| 749 | describeResult: "Success", | ||
| 750 | async refetch() {}, | ||
| 751 | }); | ||
| 752 | |||
| 753 | assertEquals(mutation.describe(5), "update counter"); | ||
| 754 | }); | ||
| 755 | |||
| 756 | test("DebouncedMutation - describe() with function", () => { | ||
| 757 | const { client } = createTestClient(); | ||
| 758 | testStore.clear(); | ||
| 759 | |||
| 760 | const mutation = client.defineDebounced({ | ||
| 761 | optimistic(_ctx, _amount: number) {}, | ||
| 762 | mode: "debounce", | ||
| 763 | time: 20, | ||
| 764 | key: () => "test-key", | ||
| 765 | getValue: (_) => 0, | ||
| 766 | async commit() { | ||
| 767 | return null; | ||
| 768 | }, | ||
| 769 | describe: ({ args }) => `increment by ${args[0]}`, | ||
| 770 | describeResult: "Success", | ||
| 771 | async refetch() {}, | ||
| 772 | }); | ||
| 773 | |||
| 774 | assertEquals(mutation.describe(5), "increment by 5"); | ||
| 775 | }); | ||
| 776 | |||
| 777 | // ============================================================================ | ||
| 778 | // Promise resolution tests | ||
| 779 | // ============================================================================ | ||
| 780 | |||
| 781 | test("DebouncedMutation - all pending promises resolve with same result", async () => { | ||
| 782 | const { client } = createTestClient(); | ||
| 783 | testStore.clear(); | ||
| 784 | testStore.set("counter", 0); | ||
| 785 | |||
| 786 | const mutation = client.defineDebounced({ | ||
| 787 | optimistic({ helpers }, amount: number) { | ||
| 788 | helpers.increment("counter", amount); | ||
| 789 | }, | ||
| 790 | mode: "debounce", | ||
| 791 | time: 30, | ||
| 792 | key: () => "test-key", | ||
| 793 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 794 | async commit({ initial, current }) { | ||
| 795 | return { delta: current - initial, timestamp: Date.now() }; | ||
| 796 | }, | ||
| 797 | describe: "increment counter", | ||
| 798 | describeResult: "Success", | ||
| 799 | async refetch() {}, | ||
| 800 | }); | ||
| 801 | |||
| 802 | const promise1 = mutation.runAndReturn(1); | ||
| 803 | const promise2 = mutation.runAndReturn(2); | ||
| 804 | const promise3 = mutation.runAndReturn(3); | ||
| 805 | |||
| 806 | const [result1, result2, result3] = await Promise.all([ | ||
| 807 | promise1, | ||
| 808 | promise2, | ||
| 809 | promise3, | ||
| 810 | ]); | ||
| 811 | |||
| 812 | // All should get the same result object | ||
| 813 | assertEquals(result1, result2); | ||
| 814 | assertEquals(result2, result3); | ||
| 815 | assertEquals(result1.delta, 6); | ||
| 816 | }); | ||
| 817 | |||
| 818 | test("DebouncedMutation - all pending promises reject with same error", async () => { | ||
| 819 | const { client } = createTestClient(); | ||
| 820 | testStore.clear(); | ||
| 821 | testStore.set("counter", 0); | ||
| 822 | |||
| 823 | const mutation = client.defineDebounced({ | ||
| 824 | optimistic({ helpers }, amount: number) { | ||
| 825 | helpers.increment("counter", amount); | ||
| 826 | }, | ||
| 827 | mode: "debounce", | ||
| 828 | time: 30, | ||
| 829 | key: () => "test-key", | ||
| 830 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 831 | async commit() { | ||
| 832 | throw new Error("batch commit failed"); | ||
| 833 | }, | ||
| 834 | describe: "increment counter", | ||
| 835 | describeResult: "Success", | ||
| 836 | async refetch() {}, | ||
| 837 | }); | ||
| 838 | |||
| 839 | const promise1 = mutation.runAndReturn(1); | ||
| 840 | const promise2 = mutation.runAndReturn(2); | ||
| 841 | const promise3 = mutation.runAndReturn(3); | ||
| 842 | |||
| 843 | const errors: Error[] = []; | ||
| 844 | await Promise.all([ | ||
| 845 | promise1.catch((e) => errors.push(e)), | ||
| 846 | promise2.catch((e) => errors.push(e)), | ||
| 847 | promise3.catch((e) => errors.push(e)), | ||
| 848 | ]); | ||
| 849 | |||
| 850 | // All should get the same error | ||
| 851 | assertEquals(errors.length, 3); | ||
| 852 | assertEquals(errors[0].message, "batch commit failed"); | ||
| 853 | assertEquals(errors[1].message, "batch commit failed"); | ||
| 854 | assertEquals(errors[2].message, "batch commit failed"); | ||
| 855 | }); | ||
| 856 | |||
| 857 | // ============================================================================ | ||
| 858 | // Edge case tests | ||
| 859 | // ============================================================================ | ||
| 860 | |||
| 861 | test("DebouncedMutation - handles empty getValue result", async () => { | ||
| 862 | const { client } = createTestClient(); | ||
| 863 | testStore.clear(); | ||
| 864 | |||
| 865 | let commitCallCount = 0; | ||
| 866 | |||
| 867 | const mutation = client.defineDebounced({ | ||
| 868 | optimistic({ helpers }, amount: number) { | ||
| 869 | helpers.setValue("nonexistent", amount); | ||
| 870 | }, | ||
| 871 | mode: "debounce", | ||
| 872 | time: 20, | ||
| 873 | key: () => "test-key", | ||
| 874 | getValue: (_) => testStore.get("nonexistent"), | ||
| 875 | async commit({ initial, current }) { | ||
| 876 | commitCallCount++; | ||
| 877 | return { initial, current }; | ||
| 878 | }, | ||
| 879 | describe: "test mutation", | ||
| 880 | describeResult: "Success", | ||
| 881 | }); | ||
| 882 | |||
| 883 | const result = await mutation.runAndReturn(5); | ||
| 884 | await delay(30); | ||
| 885 | |||
| 886 | assertEquals(commitCallCount, 1); | ||
| 887 | assertEquals(result.initial, undefined); | ||
| 888 | assertEquals(result.current, 5); | ||
| 889 | }); | ||
| 890 | |||
| 891 | test("DebouncedMutation - channel cleanup after idle with no listeners", async () => { | ||
| 892 | const { client } = createTestClient(); | ||
| 893 | testStore.clear(); | ||
| 894 | testStore.set("counter", 0); | ||
| 895 | |||
| 896 | const mutation = client.defineDebounced({ | ||
| 897 | optimistic({ helpers }, amount: number) { | ||
| 898 | helpers.increment("counter", amount); | ||
| 899 | }, | ||
| 900 | mode: "debounce", | ||
| 901 | time: 20, | ||
| 902 | key: () => "test-key", | ||
| 903 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 904 | async commit({ initial, current }) { | ||
| 905 | return current - initial; | ||
| 906 | }, | ||
| 907 | describe: "test mutation", | ||
| 908 | describeResult: "Success", | ||
| 909 | async refetch() {}, | ||
| 910 | }); | ||
| 911 | |||
| 912 | // Run mutation without subscribing | ||
| 913 | await mutation.runAndReturn(5); | ||
| 914 | await delay(30); | ||
| 915 | |||
| 916 | // Run another mutation - should work fine (channel recreated if needed) | ||
| 917 | const result = await mutation.runAndReturn(3); | ||
| 918 | await delay(30); | ||
| 919 | |||
| 920 | assertEquals(result, 3); | ||
| 921 | assertEquals(testStore.get("counter"), 8); | ||
| 922 | }); | ||
| 923 | |||
| 924 | test("DebouncedMutation - default time is 200ms", async () => { | ||
| 925 | const { client } = createTestClient(); | ||
| 926 | testStore.clear(); | ||
| 927 | testStore.set("counter", 0); | ||
| 928 | |||
| 929 | let commitTime: number | null = null; | ||
| 930 | const startTime = Date.now(); | ||
| 931 | |||
| 932 | const mutation = client.defineDebounced({ | ||
| 933 | optimistic({ helpers }, amount: number) { | ||
| 934 | helpers.increment("counter", amount); | ||
| 935 | }, | ||
| 936 | mode: "debounce", | ||
| 937 | // time not specified, should default to 200 | ||
| 938 | key: () => "test-key", | ||
| 939 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 940 | async commit({ initial, current }) { | ||
| 941 | commitTime = Date.now() - startTime; | ||
| 942 | return current - initial; | ||
| 943 | }, | ||
| 944 | describe: "test mutation", | ||
| 945 | describeResult: "Success", | ||
| 946 | async refetch() {}, | ||
| 947 | }); | ||
| 948 | |||
| 949 | await mutation.runAndReturn(5); | ||
| 950 | |||
| 951 | // Should commit after ~200ms (with some tolerance) | ||
| 952 | assertEquals(commitTime !== null, true); | ||
| 953 | assertEquals(commitTime! >= 180, true); | ||
| 954 | assertEquals(commitTime! <= 250, true); | ||
| 955 | }); | ||
| 956 | |||
| 957 | test("DebouncedMutation - context is passed to getValue", async () => { | ||
| 958 | const { client } = createTestClient(); | ||
| 959 | testStore.clear(); | ||
| 960 | testStore.set("counter", 0); | ||
| 961 | |||
| 962 | let receivedUserId: string | undefined; | ||
| 963 | |||
| 964 | const mutation = client.defineDebounced({ | ||
| 965 | optimistic({ helpers }, amount: number) { | ||
| 966 | helpers.increment("counter", amount); | ||
| 967 | }, | ||
| 968 | mode: "debounce", | ||
| 969 | time: 20, | ||
| 970 | key: () => "test-key", | ||
| 971 | getValue: ({ userId }) => { | ||
| 972 | receivedUserId = userId; | ||
| 973 | return testStore.get("counter") ?? 0; | ||
| 974 | }, | ||
| 975 | async commit({ initial, current }) { | ||
| 976 | return current - initial; | ||
| 977 | }, | ||
| 978 | describe: "test mutation", | ||
| 979 | describeResult: "Success", | ||
| 980 | async refetch() {}, | ||
| 981 | }); | ||
| 982 | |||
| 983 | await mutation.runAndReturn(5); | ||
| 984 | await delay(30); | ||
| 985 | |||
| 986 | assertEquals(receivedUserId, "test-user"); | ||
| 987 | }); | ||
| 988 | |||
| 989 | test("DebouncedMutation - context is passed to commit", async () => { | ||
| 990 | const { client } = createTestClient(); | ||
| 991 | testStore.clear(); | ||
| 992 | testStore.set("counter", 0); | ||
| 993 | |||
| 994 | let receivedUserId: string | undefined; | ||
| 995 | |||
| 996 | const mutation = client.defineDebounced({ | ||
| 997 | optimistic({ helpers }, amount: number) { | ||
| 998 | helpers.increment("counter", amount); | ||
| 999 | }, | ||
| 1000 | mode: "debounce", | ||
| 1001 | time: 20, | ||
| 1002 | key: () => "test-key", | ||
| 1003 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 1004 | async commit({ userId, initial, current }) { | ||
| 1005 | receivedUserId = userId; | ||
| 1006 | return current - initial; | ||
| 1007 | }, | ||
| 1008 | describe: "test mutation", | ||
| 1009 | describeResult: "Success", | ||
| 1010 | async refetch() {}, | ||
| 1011 | }); | ||
| 1012 | |||
| 1013 | await mutation.runAndReturn(5); | ||
| 1014 | await delay(30); | ||
| 1015 | |||
| 1016 | assertEquals(receivedUserId, "test-user"); | ||
| 1017 | }); | ||
| 1018 | |||
| 1019 | test("DebouncedMutation - first args are used for commit", async () => { | ||
| 1020 | const { client } = createTestClient(); | ||
| 1021 | testStore.clear(); | ||
| 1022 | testStore.set("counter", 0); | ||
| 1023 | |||
| 1024 | let receivedArgs: [string, number] | undefined; | ||
| 1025 | |||
| 1026 | const mutation = client.defineDebounced({ | ||
| 1027 | optimistic({ helpers }, _label: string, amount: number) { | ||
| 1028 | helpers.increment("counter", amount); | ||
| 1029 | }, | ||
| 1030 | mode: "debounce", | ||
| 1031 | time: 30, | ||
| 1032 | key: () => "test-key", | ||
| 1033 | getValue: (_) => testStore.get("counter") ?? 0, | ||
| 1034 | async commit({ args, initial, current }) { | ||
| 1035 | receivedArgs = args; | ||
| 1036 | return current - initial; | ||
| 1037 | }, | ||
| 1038 | describe: "test mutation", | ||
| 1039 | describeResult: "Success", | ||
| 1040 | async refetch() {}, | ||
| 1041 | }); | ||
| 1042 | |||
| 1043 | mutation.runAndReturn("first", 1); | ||
| 1044 | mutation.runAndReturn("second", 2); | ||
| 1045 | await mutation.runAndReturn("third", 3); | ||
| 1046 | await delay(10); | ||
| 1047 | |||
| 1048 | // Should use first args | ||
| 1049 | assertEquals(receivedArgs, ["first", 1]); | ||
| 1050 | }); | ||
test/object-path-types.test.ts deleted-407| ... | @@ -1,407 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * Type-level tests for object-path system | ||
| 3 | * These tests verify that TypeScript types work correctly at compile time | ||
| 4 | */ | ||
| 5 | |||
| 6 | import type { AllObjectPaths, GetObjectPath } from "../src/object-path.ts"; | ||
| 7 | |||
| 8 | // Type testing utilities | ||
| 9 | type Expect<T extends true> = T; | ||
| 10 | type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends | ||
| 11 | <T>() => T extends Y ? 1 | ||
| 12 | : 2 ? true | ||
| 13 | : false; | ||
| 14 | type NotEqual<X, Y> = Equal<X, Y> extends true ? false : true; | ||
| 15 | type IsAny<T> = 0 extends 1 & T ? true : false; | ||
| 16 | type NotAny<T> = IsAny<T> extends true ? false : true; | ||
| 17 | |||
| 18 | // Test interface | ||
| 19 | interface TestData { | ||
| 20 | name: string; | ||
| 21 | count: number; | ||
| 22 | active: boolean; | ||
| 23 | settings: { | ||
| 24 | theme: string; | ||
| 25 | notifications: boolean; | ||
| 26 | }; | ||
| 27 | items: Array<{ id: number; label: string }>; | ||
| 28 | tags: string[]; | ||
| 29 | nested: { | ||
| 30 | deep: { | ||
| 31 | value: boolean; | ||
| 32 | config: { | ||
| 33 | enabled: true; | ||
| 34 | }; | ||
| 35 | }; | ||
| 36 | }; | ||
| 37 | } | ||
| 38 | |||
| 39 | // ============================================================================ | ||
| 40 | // AllObjectPaths tests | ||
| 41 | // ============================================================================ | ||
| 42 | |||
| 43 | // Should allow top-level paths | ||
| 44 | type TestPath1 = Expect< | ||
| 45 | Equal<["name"], Extract<AllObjectPaths<TestData>, ["name"]>> | ||
| 46 | >; | ||
| 47 | type TestPath2 = Expect< | ||
| 48 | Equal<["count"], Extract<AllObjectPaths<TestData>, ["count"]>> | ||
| 49 | >; | ||
| 50 | |||
| 51 | // Should allow nested paths | ||
| 52 | type TestPath3 = Expect< | ||
| 53 | Equal< | ||
| 54 | ["settings", "theme"], | ||
| 55 | Extract<AllObjectPaths<TestData>, ["settings", "theme"]> | ||
| 56 | > | ||
| 57 | >; | ||
| 58 | |||
| 59 | // Should allow deep nested paths | ||
| 60 | type TestPath4 = Expect< | ||
| 61 | Equal< | ||
| 62 | ["nested", "deep", "value"], | ||
| 63 | Extract<AllObjectPaths<TestData>, ["nested", "deep", "value"]> | ||
| 64 | > | ||
| 65 | >; | ||
| 66 | |||
| 67 | // Should allow array index access | ||
| 68 | type TestPath5 = Expect< | ||
| 69 | Equal<[number], Extract<AllObjectPaths<string[]>, [number]>> | ||
| 70 | >; | ||
| 71 | |||
| 72 | // Should allow array element property access | ||
| 73 | type TestPath6 = Expect< | ||
| 74 | Equal< | ||
| 75 | ["items", number, "id"], | ||
| 76 | Extract<AllObjectPaths<TestData>, ["items", number, "id"]> | ||
| 77 | > | ||
| 78 | >; | ||
| 79 | |||
| 80 | // Should allow empty path for nested objects | ||
| 81 | type TestPath7 = Expect< | ||
| 82 | Equal<[], Extract<AllObjectPaths<TestData>, []>> | ||
| 83 | >; | ||
| 84 | |||
| 85 | // ============================================================================ | ||
| 86 | // GetObjectPath tests | ||
| 87 | // ============================================================================ | ||
| 88 | |||
| 89 | // Top-level property access | ||
| 90 | type GetTest1 = Expect<Equal<GetObjectPath<TestData, ["name"]>, string>>; | ||
| 91 | type GetTest2 = Expect<Equal<GetObjectPath<TestData, ["count"]>, number>>; | ||
| 92 | type GetTest3 = Expect<Equal<GetObjectPath<TestData, ["active"]>, boolean>>; | ||
| 93 | |||
| 94 | // Nested property access | ||
| 95 | type GetTest4 = Expect< | ||
| 96 | Equal< | ||
| 97 | GetObjectPath<TestData, ["settings", "theme"]>, | ||
| 98 | string | ||
| 99 | > | ||
| 100 | >; | ||
| 101 | type GetTest5 = Expect< | ||
| 102 | Equal< | ||
| 103 | GetObjectPath<TestData, ["settings", "notifications"]>, | ||
| 104 | boolean | ||
| 105 | > | ||
| 106 | >; | ||
| 107 | |||
| 108 | // Deep nested access | ||
| 109 | type GetTest6 = Expect< | ||
| 110 | Equal< | ||
| 111 | GetObjectPath<TestData, ["nested", "deep", "value"]>, | ||
| 112 | boolean | ||
| 113 | > | ||
| 114 | >; | ||
| 115 | type GetTest7 = Expect< | ||
| 116 | Equal< | ||
| 117 | GetObjectPath<TestData, ["nested", "deep", "config", "enabled"]>, | ||
| 118 | true | ||
| 119 | > | ||
| 120 | >; | ||
| 121 | |||
| 122 | // Array access | ||
| 123 | type GetTest8 = Expect< | ||
| 124 | Equal< | ||
| 125 | GetObjectPath<TestData, ["tags"]>, | ||
| 126 | string[] | ||
| 127 | > | ||
| 128 | >; | ||
| 129 | type GetTest9 = Expect< | ||
| 130 | Equal< | ||
| 131 | GetObjectPath<TestData, ["tags", number]>, | ||
| 132 | string | ||
| 133 | > | ||
| 134 | >; | ||
| 135 | type GetTest10 = Expect< | ||
| 136 | Equal< | ||
| 137 | GetObjectPath<TestData, ["items"]>, | ||
| 138 | Array<{ id: number; label: string }> | ||
| 139 | > | ||
| 140 | >; | ||
| 141 | type GetTest11 = Expect< | ||
| 142 | Equal< | ||
| 143 | GetObjectPath<TestData, ["items", number]>, | ||
| 144 | { id: number; label: string } | ||
| 145 | > | ||
| 146 | >; | ||
| 147 | type GetTest12 = Expect< | ||
| 148 | Equal< | ||
| 149 | GetObjectPath<TestData, ["items", number, "id"]>, | ||
| 150 | number | ||
| 151 | > | ||
| 152 | >; | ||
| 153 | type GetTest13 = Expect< | ||
| 154 | Equal< | ||
| 155 | GetObjectPath<TestData, ["items", number, "label"]>, | ||
| 156 | string | ||
| 157 | > | ||
| 158 | >; | ||
| 159 | |||
| 160 | // Object access | ||
| 161 | type GetTest14 = Expect< | ||
| 162 | Equal< | ||
| 163 | GetObjectPath<TestData, ["settings"]>, | ||
| 164 | { theme: string; notifications: boolean } | ||
| 165 | > | ||
| 166 | >; | ||
| 167 | |||
| 168 | // Empty path returns the whole object | ||
| 169 | type GetTest15 = Expect<Equal<GetObjectPath<TestData, []>, TestData>>; | ||
| 170 | |||
| 171 | // ============================================================================ | ||
| 172 | // Array element type extraction tests | ||
| 173 | // ============================================================================ | ||
| 174 | |||
| 175 | type ArrayElement<T> = T extends readonly (infer U)[] ? U : never; | ||
| 176 | |||
| 177 | type ArrayTest1 = Expect<Equal<ArrayElement<string[]>, string>>; | ||
| 178 | type ArrayTest2 = Expect<Equal<ArrayElement<number[]>, number>>; | ||
| 179 | type ArrayTest3 = Expect< | ||
| 180 | Equal<ArrayElement<Array<{ id: number }>>, { id: number }> | ||
| 181 | >; | ||
| 182 | type ArrayTest4 = Expect< | ||
| 183 | Equal< | ||
| 184 | ArrayElement<GetObjectPath<TestData, ["items"]>>, | ||
| 185 | { id: number; label: string } | ||
| 186 | > | ||
| 187 | >; | ||
| 188 | type ArrayTest5 = Expect< | ||
| 189 | Equal<ArrayElement<GetObjectPath<TestData, ["tags"]>>, string> | ||
| 190 | >; | ||
| 191 | |||
| 192 | // ============================================================================ | ||
| 193 | // Conditional type tests for helper functions | ||
| 194 | // ============================================================================ | ||
| 195 | |||
| 196 | // Test that we can extract array element types from paths | ||
| 197 | type ExtractArrayElement< | ||
| 198 | Data extends object, | ||
| 199 | Path extends AllObjectPaths<Data>, | ||
| 200 | > = GetObjectPath<Data, Path> extends readonly (infer T)[] ? T : never; | ||
| 201 | |||
| 202 | type ElementTest1 = Expect< | ||
| 203 | Equal< | ||
| 204 | ExtractArrayElement<TestData, ["items"]>, | ||
| 205 | { id: number; label: string } | ||
| 206 | > | ||
| 207 | >; | ||
| 208 | type ElementTest2 = Expect< | ||
| 209 | Equal<ExtractArrayElement<TestData, ["tags"]>, string> | ||
| 210 | >; | ||
| 211 | |||
| 212 | // Test that non-array paths return never | ||
| 213 | type ElementTest3 = Expect< | ||
| 214 | Equal<ExtractArrayElement<TestData, ["name"]>, never> | ||
| 215 | >; | ||
| 216 | |||
| 217 | // Test number extraction | ||
| 218 | type IsNumber< | ||
| 219 | Data extends object, | ||
| 220 | Path extends AllObjectPaths<Data>, | ||
| 221 | > = GetObjectPath<Data, Path> extends number ? true : false; | ||
| 222 | |||
| 223 | type NumberTest1 = Expect<Equal<IsNumber<TestData, ["count"]>, true>>; | ||
| 224 | type NumberTest2 = Expect<Equal<IsNumber<TestData, ["name"]>, false>>; | ||
| 225 | |||
| 226 | // Test boolean extraction | ||
| 227 | type IsBoolean< | ||
| 228 | Data extends object, | ||
| 229 | Path extends AllObjectPaths<Data>, | ||
| 230 | > = GetObjectPath<Data, Path> extends boolean ? true : false; | ||
| 231 | |||
| 232 | type BooleanTest1 = Expect<Equal<IsBoolean<TestData, ["active"]>, true>>; | ||
| 233 | type BooleanTest2 = Expect<Equal<IsBoolean<TestData, ["count"]>, false>>; | ||
| 234 | |||
| 235 | // Test object extraction | ||
| 236 | type IsObject< | ||
| 237 | Data extends object, | ||
| 238 | Path extends AllObjectPaths<Data>, | ||
| 239 | > = GetObjectPath<Data, Path> extends object ? true : false; | ||
| 240 | |||
| 241 | type ObjectTest1 = Expect<Equal<IsObject<TestData, ["settings"]>, true>>; | ||
| 242 | type ObjectTest2 = Expect<Equal<IsObject<TestData, ["items"]>, true>>; | ||
| 243 | type ObjectTest3 = Expect<Equal<IsObject<TestData, ["name"]>, false>>; | ||
| 244 | |||
| 245 | // ============================================================================ | ||
| 246 | // Edge cases | ||
| 247 | // ============================================================================ | ||
| 248 | |||
| 249 | // Readonly arrays should work | ||
| 250 | interface ReadonlyData { | ||
| 251 | readonly items: readonly { id: number }[]; | ||
| 252 | } | ||
| 253 | |||
| 254 | type ReadonlyTest1 = Expect< | ||
| 255 | Equal< | ||
| 256 | GetObjectPath<ReadonlyData, ["items"]>, | ||
| 257 | readonly { id: number }[] | ||
| 258 | > | ||
| 259 | >; | ||
| 260 | type ReadonlyTest2 = Expect< | ||
| 261 | Equal< | ||
| 262 | GetObjectPath<ReadonlyData, ["items", number]>, | ||
| 263 | { id: number } | ||
| 264 | > | ||
| 265 | >; | ||
| 266 | type ReadonlyTest3 = Expect< | ||
| 267 | Equal< | ||
| 268 | GetObjectPath<ReadonlyData, ["items", number, "id"]>, | ||
| 269 | number | ||
| 270 | > | ||
| 271 | >; | ||
| 272 | |||
| 273 | // Optional properties | ||
| 274 | interface OptionalData { | ||
| 275 | required: string; | ||
| 276 | optional?: number; | ||
| 277 | nested?: { | ||
| 278 | value: boolean; | ||
| 279 | }; | ||
| 280 | } | ||
| 281 | |||
| 282 | type OptionalTest1 = Expect< | ||
| 283 | Equal<GetObjectPath<OptionalData, ["required"]>, string> | ||
| 284 | >; | ||
| 285 | type OptionalTest2 = Expect< | ||
| 286 | Equal<GetObjectPath<OptionalData, ["optional"]>, number | undefined> | ||
| 287 | >; | ||
| 288 | |||
| 289 | // Union types | ||
| 290 | interface UnionData { | ||
| 291 | value: string | number; | ||
| 292 | items: Array<{ type: "a"; a: string } | { type: "b"; b: number }>; | ||
| 293 | } | ||
| 294 | |||
| 295 | type UnionTest1 = Expect< | ||
| 296 | Equal<GetObjectPath<UnionData, ["value"]>, string | number> | ||
| 297 | >; | ||
| 298 | |||
| 299 | // ============================================================================ | ||
| 300 | // Real-world usage simulation | ||
| 301 | // ============================================================================ | ||
| 302 | |||
| 303 | // Simulate the actual helper function signatures | ||
| 304 | type ObjSetSignature< | ||
| 305 | Data extends object, | ||
| 306 | Path extends AllObjectPaths<Data>, | ||
| 307 | > = ( | ||
| 308 | path: Path, | ||
| 309 | value: | ||
| 310 | | Exclude<GetObjectPath<Data, Path>, Function> | ||
| 311 | | ((prev: GetObjectPath<Data, Path>) => GetObjectPath<Data, Path>), | ||
| 312 | ) => void; | ||
| 313 | |||
| 314 | // This should accept string or function | ||
| 315 | declare const objSetName: ObjSetSignature<TestData, ["name"]>; | ||
| 316 | objSetName(["name"], "test"); | ||
| 317 | objSetName(["name"], (prev) => prev.toUpperCase()); | ||
| 318 | |||
| 319 | // This should accept number or function | ||
| 320 | declare const objSetCount: ObjSetSignature<TestData, ["count"]>; | ||
| 321 | objSetCount(["count"], 42); | ||
| 322 | objSetCount(["count"], (n) => n + 1); | ||
| 323 | |||
| 324 | // Array push signature | ||
| 325 | type ArrayPushSignature< | ||
| 326 | Data extends object, | ||
| 327 | Path extends AllObjectPaths<Data>, | ||
| 328 | > = GetObjectPath<Data, Path> extends readonly (infer T)[] | ||
| 329 | ? (path: Path, ...items: T[]) => void | ||
| 330 | : never; | ||
| 331 | |||
| 332 | // This should accept individual items, not arrays | ||
| 333 | declare const arrayPushItems: ArrayPushSignature<TestData, ["items"]>; | ||
| 334 | arrayPushItems( | ||
| 335 | ["items"], | ||
| 336 | { id: 1, label: "first" }, | ||
| 337 | { id: 2, label: "second" }, | ||
| 338 | ); | ||
| 339 | |||
| 340 | declare const arrayPushTags: ArrayPushSignature<TestData, ["tags"]>; | ||
| 341 | arrayPushTags(["tags"], "alpha", "beta", "gamma"); | ||
| 342 | |||
| 343 | // Array remove signature | ||
| 344 | type ArrayRemoveSignature< | ||
| 345 | Data extends object, | ||
| 346 | Path extends AllObjectPaths<Data>, | ||
| 347 | > = GetObjectPath<Data, Path> extends readonly (infer T)[] | ||
| 348 | ? (path: Path, filter: (item: T, index: number) => boolean) => void | ||
| 349 | : never; | ||
| 350 | |||
| 351 | declare const arrayRemoveItems: ArrayRemoveSignature<TestData, ["items"]>; | ||
| 352 | arrayRemoveItems(["items"], (item) => item.id === 1); | ||
| 353 | arrayRemoveItems(["items"], (item, index) => index === 0); | ||
| 354 | |||
| 355 | declare const arrayRemoveTags: ArrayRemoveSignature<TestData, ["tags"]>; | ||
| 356 | arrayRemoveTags(["tags"], (tag) => tag === "alpha"); | ||
| 357 | |||
| 358 | // Increment signature | ||
| 359 | type IncrementSignature< | ||
| 360 | Data extends object, | ||
| 361 | Path extends AllObjectPaths<Data>, | ||
| 362 | > = GetObjectPath<Data, Path> extends number | ||
| 363 | ? (path: Path, amount?: number) => void | ||
| 364 | : never; | ||
| 365 | |||
| 366 | declare const increment: IncrementSignature<TestData, ["count"]>; | ||
| 367 | increment(["count"]); | ||
| 368 | increment(["count"], 5); | ||
| 369 | |||
| 370 | // Should not work on non-numbers (type should be never) | ||
| 371 | type IncrementNameTest = Expect< | ||
| 372 | Equal<IncrementSignature<TestData, ["name"]>, never> | ||
| 373 | >; | ||
| 374 | |||
| 375 | // Toggle signature | ||
| 376 | type ToggleSignature< | ||
| 377 | Data extends object, | ||
| 378 | Path extends AllObjectPaths<Data>, | ||
| 379 | > = GetObjectPath<Data, Path> extends boolean ? (path: Path) => void | ||
| 380 | : never; | ||
| 381 | |||
| 382 | declare const toggle: ToggleSignature<TestData, ["active"]>; | ||
| 383 | toggle(["active"]); | ||
| 384 | |||
| 385 | // Should not work on non-booleans (type should be never) | ||
| 386 | type ToggleCountTest = Expect< | ||
| 387 | Equal<ToggleSignature<TestData, ["count"]>, never> | ||
| 388 | >; | ||
| 389 | |||
| 390 | // ============================================================================ | ||
| 391 | // Verify no `any` types leaked through | ||
| 392 | // ============================================================================ | ||
| 393 | |||
| 394 | type NoAnyTest1 = Expect<NotAny<GetObjectPath<TestData, ["name"]>>>; | ||
| 395 | type NoAnyTest2 = Expect<NotAny<GetObjectPath<TestData, ["items"]>>>; | ||
| 396 | type NoAnyTest3 = Expect<NotAny<GetObjectPath<TestData, ["items", number]>>>; | ||
| 397 | type NoAnyTest4 = Expect< | ||
| 398 | NotAny<GetObjectPath<TestData, ["settings", "theme"]>> | ||
| 399 | >; | ||
| 400 | |||
| 401 | export type { | ||
| 402 | ArrayPushSignature, | ||
| 403 | ArrayRemoveSignature, | ||
| 404 | IncrementSignature, | ||
| 405 | ObjSetSignature, | ||
| 406 | ToggleSignature, | ||
| 407 | }; | ||
test/object-path.types.ts created+407| ... | @@ -0,0 +1,407 @@ | ||
| 1 | /** | ||
| 2 | * Type-level tests for object-path system | ||
| 3 | * These tests verify that TypeScript types work correctly at compile time | ||
| 4 | */ | ||
| 5 | |||
| 6 | import type { AllObjectPaths, GetObjectPath } from "../src/object-path.ts"; | ||
| 7 | |||
| 8 | // Type testing utilities | ||
| 9 | type Expect<T extends true> = T; | ||
| 10 | type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends | ||
| 11 | <T>() => T extends Y ? 1 | ||
| 12 | : 2 ? true | ||
| 13 | : false; | ||
| 14 | type NotEqual<X, Y> = Equal<X, Y> extends true ? false : true; | ||
| 15 | type IsAny<T> = 0 extends 1 & T ? true : false; | ||
| 16 | type NotAny<T> = IsAny<T> extends true ? false : true; | ||
| 17 | |||
| 18 | // Test interface | ||
| 19 | interface TestData { | ||
| 20 | name: string; | ||
| 21 | count: number; | ||
| 22 | active: boolean; | ||
| 23 | settings: { | ||
| 24 | theme: string; | ||
| 25 | notifications: boolean; | ||
| 26 | }; | ||
| 27 | items: Array<{ id: number; label: string }>; | ||
| 28 | tags: string[]; | ||
| 29 | nested: { | ||
| 30 | deep: { | ||
| 31 | value: boolean; | ||
| 32 | config: { | ||
| 33 | enabled: true; | ||
| 34 | }; | ||
| 35 | }; | ||
| 36 | }; | ||
| 37 | } | ||
| 38 | |||
| 39 | // ============================================================================ | ||
| 40 | // AllObjectPaths tests | ||
| 41 | // ============================================================================ | ||
| 42 | |||
| 43 | // Should allow top-level paths | ||
| 44 | type TestPath1 = Expect< | ||
| 45 | Equal<["name"], Extract<AllObjectPaths<TestData>, ["name"]>> | ||
| 46 | >; | ||
| 47 | type TestPath2 = Expect< | ||
| 48 | Equal<["count"], Extract<AllObjectPaths<TestData>, ["count"]>> | ||
| 49 | >; | ||
| 50 | |||
| 51 | // Should allow nested paths | ||
| 52 | type TestPath3 = Expect< | ||
| 53 | Equal< | ||
| 54 | ["settings", "theme"], | ||
| 55 | Extract<AllObjectPaths<TestData>, ["settings", "theme"]> | ||
| 56 | > | ||
| 57 | >; | ||
| 58 | |||
| 59 | // Should allow deep nested paths | ||
| 60 | type TestPath4 = Expect< | ||
| 61 | Equal< | ||
| 62 | ["nested", "deep", "value"], | ||
| 63 | Extract<AllObjectPaths<TestData>, ["nested", "deep", "value"]> | ||
| 64 | > | ||
| 65 | >; | ||
| 66 | |||
| 67 | // Should allow array index access | ||
| 68 | type TestPath5 = Expect< | ||
| 69 | Equal<[number], Extract<AllObjectPaths<string[]>, [number]>> | ||
| 70 | >; | ||
| 71 | |||
| 72 | // Should allow array element property access | ||
| 73 | type TestPath6 = Expect< | ||
| 74 | Equal< | ||
| 75 | ["items", number, "id"], | ||
| 76 | Extract<AllObjectPaths<TestData>, ["items", number, "id"]> | ||
| 77 | > | ||
| 78 | >; | ||
| 79 | |||
| 80 | // Should allow empty path for nested objects | ||
| 81 | type TestPath7 = Expect< | ||
| 82 | Equal<[], Extract<AllObjectPaths<TestData>, []>> | ||
| 83 | >; | ||
| 84 | |||
| 85 | // ============================================================================ | ||
| 86 | // GetObjectPath tests | ||
| 87 | // ============================================================================ | ||
| 88 | |||
| 89 | // Top-level property access | ||
| 90 | type GetTest1 = Expect<Equal<GetObjectPath<TestData, ["name"]>, string>>; | ||
| 91 | type GetTest2 = Expect<Equal<GetObjectPath<TestData, ["count"]>, number>>; | ||
| 92 | type GetTest3 = Expect<Equal<GetObjectPath<TestData, ["active"]>, boolean>>; | ||
| 93 | |||
| 94 | // Nested property access | ||
| 95 | type GetTest4 = Expect< | ||
| 96 | Equal< | ||
| 97 | GetObjectPath<TestData, ["settings", "theme"]>, | ||
| 98 | string | ||
| 99 | > | ||
| 100 | >; | ||
| 101 | type GetTest5 = Expect< | ||
| 102 | Equal< | ||
| 103 | GetObjectPath<TestData, ["settings", "notifications"]>, | ||
| 104 | boolean | ||
| 105 | > | ||
| 106 | >; | ||
| 107 | |||
| 108 | // Deep nested access | ||
| 109 | type GetTest6 = Expect< | ||
| 110 | Equal< | ||
| 111 | GetObjectPath<TestData, ["nested", "deep", "value"]>, | ||
| 112 | boolean | ||
| 113 | > | ||
| 114 | >; | ||
| 115 | type GetTest7 = Expect< | ||
| 116 | Equal< | ||
| 117 | GetObjectPath<TestData, ["nested", "deep", "config", "enabled"]>, | ||
| 118 | true | ||
| 119 | > | ||
| 120 | >; | ||
| 121 | |||
| 122 | // Array access | ||
| 123 | type GetTest8 = Expect< | ||
| 124 | Equal< | ||
| 125 | GetObjectPath<TestData, ["tags"]>, | ||
| 126 | string[] | ||
| 127 | > | ||
| 128 | >; | ||
| 129 | type GetTest9 = Expect< | ||
| 130 | Equal< | ||
| 131 | GetObjectPath<TestData, ["tags", number]>, | ||
| 132 | string | ||
| 133 | > | ||
| 134 | >; | ||
| 135 | type GetTest10 = Expect< | ||
| 136 | Equal< | ||
| 137 | GetObjectPath<TestData, ["items"]>, | ||
| 138 | Array<{ id: number; label: string }> | ||
| 139 | > | ||
| 140 | >; | ||
| 141 | type GetTest11 = Expect< | ||
| 142 | Equal< | ||
| 143 | GetObjectPath<TestData, ["items", number]>, | ||
| 144 | { id: number; label: string } | ||
| 145 | > | ||
| 146 | >; | ||
| 147 | type GetTest12 = Expect< | ||
| 148 | Equal< | ||
| 149 | GetObjectPath<TestData, ["items", number, "id"]>, | ||
| 150 | number | ||
| 151 | > | ||
| 152 | >; | ||
| 153 | type GetTest13 = Expect< | ||
| 154 | Equal< | ||
| 155 | GetObjectPath<TestData, ["items", number, "label"]>, | ||
| 156 | string | ||
| 157 | > | ||
| 158 | >; | ||
| 159 | |||
| 160 | // Object access | ||
| 161 | type GetTest14 = Expect< | ||
| 162 | Equal< | ||
| 163 | GetObjectPath<TestData, ["settings"]>, | ||
| 164 | { theme: string; notifications: boolean } | ||
| 165 | > | ||
| 166 | >; | ||
| 167 | |||
| 168 | // Empty path returns the whole object | ||
| 169 | type GetTest15 = Expect<Equal<GetObjectPath<TestData, []>, TestData>>; | ||
| 170 | |||
| 171 | // ============================================================================ | ||
| 172 | // Array element type extraction tests | ||
| 173 | // ============================================================================ | ||
| 174 | |||
| 175 | type ArrayElement<T> = T extends readonly (infer U)[] ? U : never; | ||
| 176 | |||
| 177 | type ArrayTest1 = Expect<Equal<ArrayElement<string[]>, string>>; | ||
| 178 | type ArrayTest2 = Expect<Equal<ArrayElement<number[]>, number>>; | ||
| 179 | type ArrayTest3 = Expect< | ||
| 180 | Equal<ArrayElement<Array<{ id: number }>>, { id: number }> | ||
| 181 | >; | ||
| 182 | type ArrayTest4 = Expect< | ||
| 183 | Equal< | ||
| 184 | ArrayElement<GetObjectPath<TestData, ["items"]>>, | ||
| 185 | { id: number; label: string } | ||
| 186 | > | ||
| 187 | >; | ||
| 188 | type ArrayTest5 = Expect< | ||
| 189 | Equal<ArrayElement<GetObjectPath<TestData, ["tags"]>>, string> | ||
| 190 | >; | ||
| 191 | |||
| 192 | // ============================================================================ | ||
| 193 | // Conditional type tests for helper functions | ||
| 194 | // ============================================================================ | ||
| 195 | |||
| 196 | // Test that we can extract array element types from paths | ||
| 197 | type ExtractArrayElement< | ||
| 198 | Data extends object, | ||
| 199 | Path extends AllObjectPaths<Data>, | ||
| 200 | > = GetObjectPath<Data, Path> extends readonly (infer T)[] ? T : never; | ||
| 201 | |||
| 202 | type ElementTest1 = Expect< | ||
| 203 | Equal< | ||
| 204 | ExtractArrayElement<TestData, ["items"]>, | ||
| 205 | { id: number; label: string } | ||
| 206 | > | ||
| 207 | >; | ||
| 208 | type ElementTest2 = Expect< | ||
| 209 | Equal<ExtractArrayElement<TestData, ["tags"]>, string> | ||
| 210 | >; | ||
| 211 | |||
| 212 | // Test that non-array paths return never | ||
| 213 | type ElementTest3 = Expect< | ||
| 214 | Equal<ExtractArrayElement<TestData, ["name"]>, never> | ||
| 215 | >; | ||
| 216 | |||
| 217 | // Test number extraction | ||
| 218 | type IsNumber< | ||
| 219 | Data extends object, | ||
| 220 | Path extends AllObjectPaths<Data>, | ||
| 221 | > = GetObjectPath<Data, Path> extends number ? true : false; | ||
| 222 | |||
| 223 | type NumberTest1 = Expect<Equal<IsNumber<TestData, ["count"]>, true>>; | ||
| 224 | type NumberTest2 = Expect<Equal<IsNumber<TestData, ["name"]>, false>>; | ||
| 225 | |||
| 226 | // Test boolean extraction | ||
| 227 | type IsBoolean< | ||
| 228 | Data extends object, | ||
| 229 | Path extends AllObjectPaths<Data>, | ||
| 230 | > = GetObjectPath<Data, Path> extends boolean ? true : false; | ||
| 231 | |||
| 232 | type BooleanTest1 = Expect<Equal<IsBoolean<TestData, ["active"]>, true>>; | ||
| 233 | type BooleanTest2 = Expect<Equal<IsBoolean<TestData, ["count"]>, false>>; | ||
| 234 | |||
| 235 | // Test object extraction | ||
| 236 | type IsObject< | ||
| 237 | Data extends object, | ||
| 238 | Path extends AllObjectPaths<Data>, | ||
| 239 | > = GetObjectPath<Data, Path> extends object ? true : false; | ||
| 240 | |||
| 241 | type ObjectTest1 = Expect<Equal<IsObject<TestData, ["settings"]>, true>>; | ||
| 242 | type ObjectTest2 = Expect<Equal<IsObject<TestData, ["items"]>, true>>; | ||
| 243 | type ObjectTest3 = Expect<Equal<IsObject<TestData, ["name"]>, false>>; | ||
| 244 | |||
| 245 | // ============================================================================ | ||
| 246 | // Edge cases | ||
| 247 | // ============================================================================ | ||
| 248 | |||
| 249 | // Readonly arrays should work | ||
| 250 | interface ReadonlyData { | ||
| 251 | readonly items: readonly { id: number }[]; | ||
| 252 | } | ||
| 253 | |||
| 254 | type ReadonlyTest1 = Expect< | ||
| 255 | Equal< | ||
| 256 | GetObjectPath<ReadonlyData, ["items"]>, | ||
| 257 | readonly { id: number }[] | ||
| 258 | > | ||
| 259 | >; | ||
| 260 | type ReadonlyTest2 = Expect< | ||
| 261 | Equal< | ||
| 262 | GetObjectPath<ReadonlyData, ["items", number]>, | ||
| 263 | { id: number } | ||
| 264 | > | ||
| 265 | >; | ||
| 266 | type ReadonlyTest3 = Expect< | ||
| 267 | Equal< | ||
| 268 | GetObjectPath<ReadonlyData, ["items", number, "id"]>, | ||
| 269 | number | ||
| 270 | > | ||
| 271 | >; | ||
| 272 | |||
| 273 | // Optional properties | ||
| 274 | interface OptionalData { | ||
| 275 | required: string; | ||
| 276 | optional?: number; | ||
| 277 | nested?: { | ||
| 278 | value: boolean; | ||
| 279 | }; | ||
| 280 | } | ||
| 281 | |||
| 282 | type OptionalTest1 = Expect< | ||
| 283 | Equal<GetObjectPath<OptionalData, ["required"]>, string> | ||
| 284 | >; | ||
| 285 | type OptionalTest2 = Expect< | ||
| 286 | Equal<GetObjectPath<OptionalData, ["optional"]>, number | undefined> | ||
| 287 | >; | ||
| 288 | |||
| 289 | // Union types | ||
| 290 | interface UnionData { | ||
| 291 | value: string | number; | ||
| 292 | items: Array<{ type: "a"; a: string } | { type: "b"; b: number }>; | ||
| 293 | } | ||
| 294 | |||
| 295 | type UnionTest1 = Expect< | ||
| 296 | Equal<GetObjectPath<UnionData, ["value"]>, string | number> | ||
| 297 | >; | ||
| 298 | |||
| 299 | // ============================================================================ | ||
| 300 | // Real-world usage simulation | ||
| 301 | // ============================================================================ | ||
| 302 | |||
| 303 | // Simulate the actual helper function signatures | ||
| 304 | type ObjSetSignature< | ||
| 305 | Data extends object, | ||
| 306 | Path extends AllObjectPaths<Data>, | ||
| 307 | > = ( | ||
| 308 | path: Path, | ||
| 309 | value: | ||
| 310 | | Exclude<GetObjectPath<Data, Path>, Function> | ||
| 311 | | ((prev: GetObjectPath<Data, Path>) => GetObjectPath<Data, Path>), | ||
| 312 | ) => void; | ||
| 313 | |||
| 314 | // This should accept string or function | ||
| 315 | declare const objSetName: ObjSetSignature<TestData, ["name"]>; | ||
| 316 | objSetName(["name"], "test"); | ||
| 317 | objSetName(["name"], (prev) => prev.toUpperCase()); | ||
| 318 | |||
| 319 | // This should accept number or function | ||
| 320 | declare const objSetCount: ObjSetSignature<TestData, ["count"]>; | ||
| 321 | objSetCount(["count"], 42); | ||
| 322 | objSetCount(["count"], (n) => n + 1); | ||
| 323 | |||
| 324 | // Array push signature | ||
| 325 | type ArrayPushSignature< | ||
| 326 | Data extends object, | ||
| 327 | Path extends AllObjectPaths<Data>, | ||
| 328 | > = GetObjectPath<Data, Path> extends readonly (infer T)[] | ||
| 329 | ? (path: Path, ...items: T[]) => void | ||
| 330 | : never; | ||
| 331 | |||
| 332 | // This should accept individual items, not arrays | ||
| 333 | declare const arrayPushItems: ArrayPushSignature<TestData, ["items"]>; | ||
| 334 | arrayPushItems( | ||
| 335 | ["items"], | ||
| 336 | { id: 1, label: "first" }, | ||
| 337 | { id: 2, label: "second" }, | ||
| 338 | ); | ||
| 339 | |||
| 340 | declare const arrayPushTags: ArrayPushSignature<TestData, ["tags"]>; | ||
| 341 | arrayPushTags(["tags"], "alpha", "beta", "gamma"); | ||
| 342 | |||
| 343 | // Array remove signature | ||
| 344 | type ArrayRemoveSignature< | ||
| 345 | Data extends object, | ||
| 346 | Path extends AllObjectPaths<Data>, | ||
| 347 | > = GetObjectPath<Data, Path> extends readonly (infer T)[] | ||
| 348 | ? (path: Path, filter: (item: T, index: number) => boolean) => void | ||
| 349 | : never; | ||
| 350 | |||
| 351 | declare const arrayRemoveItems: ArrayRemoveSignature<TestData, ["items"]>; | ||
| 352 | arrayRemoveItems(["items"], (item) => item.id === 1); | ||
| 353 | arrayRemoveItems(["items"], (item, index) => index === 0); | ||
| 354 | |||
| 355 | declare const arrayRemoveTags: ArrayRemoveSignature<TestData, ["tags"]>; | ||
| 356 | arrayRemoveTags(["tags"], (tag) => tag === "alpha"); | ||
| 357 | |||
| 358 | // Increment signature | ||
| 359 | type IncrementSignature< | ||
| 360 | Data extends object, | ||
| 361 | Path extends AllObjectPaths<Data>, | ||
| 362 | > = GetObjectPath<Data, Path> extends number | ||
| 363 | ? (path: Path, amount?: number) => void | ||
| 364 | : never; | ||
| 365 | |||
| 366 | declare const increment: IncrementSignature<TestData, ["count"]>; | ||
| 367 | increment(["count"]); | ||
| 368 | increment(["count"], 5); | ||
| 369 | |||
| 370 | // Should not work on non-numbers (type should be never) | ||
| 371 | type IncrementNameTest = Expect< | ||
| 372 | Equal<IncrementSignature<TestData, ["name"]>, never> | ||
| 373 | >; | ||
| 374 | |||
| 375 | // Toggle signature | ||
| 376 | type ToggleSignature< | ||
| 377 | Data extends object, | ||
| 378 | Path extends AllObjectPaths<Data>, | ||
| 379 | > = GetObjectPath<Data, Path> extends boolean ? (path: Path) => void | ||
| 380 | : never; | ||
| 381 | |||
| 382 | declare const toggle: ToggleSignature<TestData, ["active"]>; | ||
| 383 | toggle(["active"]); | ||
| 384 | |||
| 385 | // Should not work on non-booleans (type should be never) | ||
| 386 | type ToggleCountTest = Expect< | ||
| 387 | Equal<ToggleSignature<TestData, ["count"]>, never> | ||
| 388 | >; | ||
| 389 | |||
| 390 | // ============================================================================ | ||
| 391 | // Verify no `any` types leaked through | ||
| 392 | // ============================================================================ | ||
| 393 | |||
| 394 | type NoAnyTest1 = Expect<NotAny<GetObjectPath<TestData, ["name"]>>>; | ||
| 395 | type NoAnyTest2 = Expect<NotAny<GetObjectPath<TestData, ["items"]>>>; | ||
| 396 | type NoAnyTest3 = Expect<NotAny<GetObjectPath<TestData, ["items", number]>>>; | ||
| 397 | type NoAnyTest4 = Expect< | ||
| 398 | NotAny<GetObjectPath<TestData, ["settings", "theme"]>> | ||
| 399 | >; | ||
| 400 | |||
| 401 | export type { | ||
| 402 | ArrayPushSignature, | ||
| 403 | ArrayRemoveSignature, | ||
| 404 | IncrementSignature, | ||
| 405 | ObjSetSignature, | ||
| 406 | ToggleSignature, | ||
| 407 | }; | ||
test/queued.test.ts deleted-967| ... | @@ -1,967 +0,0 @@ | ||
| 1 | import { assertEquals, assertRejects } from "@std/assert"; | ||
| 2 | import { MutationClient } from "../src/client.ts"; | ||
| 3 | import type { MutationEvent } from "../src/types.ts"; | ||
| 4 | import { test } from "vitest"; | ||
| 5 | |||
| 6 | // Helper to create a test mutation client | ||
| 7 | function createTestClient() { | ||
| 8 | const errors: unknown[] = []; | ||
| 9 | const client = new MutationClient({ | ||
| 10 | context: { userId: "test-user" }, | ||
| 11 | getOptimisticHelpers({ onRestore }) { | ||
| 12 | return { | ||
| 13 | setValue(key: string, value: string) { | ||
| 14 | testStore.set(key, value); | ||
| 15 | onRestore(() => testStore.delete(key)); | ||
| 16 | }, | ||
| 17 | }; | ||
| 18 | }, | ||
| 19 | reportError(message, error) { | ||
| 20 | errors.push(error); | ||
| 21 | }, | ||
| 22 | }); | ||
| 23 | |||
| 24 | return { client, errors }; | ||
| 25 | } | ||
| 26 | |||
| 27 | const testStore = new Map<string, string>(); | ||
| 28 | |||
| 29 | // Helper to track mutation events | ||
| 30 | function createEventTracker<Result>() { | ||
| 31 | const events: Array<MutationEvent<Result>> = []; | ||
| 32 | const callback = (event: MutationEvent<Result>) => { | ||
| 33 | events.push(event); | ||
| 34 | }; | ||
| 35 | return { events, callback }; | ||
| 36 | } | ||
| 37 | |||
| 38 | // Helper to wait for async operations | ||
| 39 | function delay(ms: number) { | ||
| 40 | return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| 41 | } | ||
| 42 | |||
| 43 | test("QueuedMutation - basic mutation success", async () => { | ||
| 44 | const { client } = createTestClient(); | ||
| 45 | let mutateCallCount = 0; | ||
| 46 | let refetchCallCount = 0; | ||
| 47 | |||
| 48 | const mutation = client.defineBlocking({ | ||
| 49 | async mutate(value: string) { | ||
| 50 | mutateCallCount++; | ||
| 51 | await delay(10); | ||
| 52 | return `result-${value}`; | ||
| 53 | }, | ||
| 54 | describe: "test mutation", | ||
| 55 | optimistic() { | ||
| 56 | // Empty optimistic update | ||
| 57 | }, | ||
| 58 | async refetch() { | ||
| 59 | refetchCallCount++; | ||
| 60 | await delay(5); | ||
| 61 | }, | ||
| 62 | }); | ||
| 63 | |||
| 64 | const result = await mutation.runAndReturn("test"); | ||
| 65 | // Wait for refetch to complete | ||
| 66 | await delay(20); | ||
| 67 | |||
| 68 | assertEquals(result, "result-test"); | ||
| 69 | assertEquals(mutateCallCount, 1); | ||
| 70 | assertEquals(refetchCallCount, 1); | ||
| 71 | }); | ||
| 72 | |||
| 73 | test("QueuedMutation - run() catches errors", async () => { | ||
| 74 | const { client, errors } = createTestClient(); | ||
| 75 | |||
| 76 | const mutation = client.defineBlocking({ | ||
| 77 | async mutate(_value: string) { | ||
| 78 | throw new Error("mutation failed"); | ||
| 79 | }, | ||
| 80 | describe: "failing mutation", | ||
| 81 | optimistic() {}, | ||
| 82 | async refetch() {}, | ||
| 83 | }); | ||
| 84 | |||
| 85 | mutation.run("test"); | ||
| 86 | await delay(50); | ||
| 87 | |||
| 88 | assertEquals(errors.length, 1); | ||
| 89 | assertEquals((errors[0] as Error).message, "mutation failed"); | ||
| 90 | }); | ||
| 91 | |||
| 92 | test("QueuedMutation - runAndReturn() rejects on error", async () => { | ||
| 93 | const { client } = createTestClient(); | ||
| 94 | |||
| 95 | const mutation = client.defineBlocking({ | ||
| 96 | async mutate(_value: string) { | ||
| 97 | throw new Error("mutation failed"); | ||
| 98 | }, | ||
| 99 | describe: "failing mutation", | ||
| 100 | optimistic() {}, | ||
| 101 | async refetch() {}, | ||
| 102 | }); | ||
| 103 | |||
| 104 | await assertRejects( | ||
| 105 | () => mutation.runAndReturn("test"), | ||
| 106 | Error, | ||
| 107 | "mutation failed", | ||
| 108 | ); | ||
| 109 | }); | ||
| 110 | |||
| 111 | test("QueuedMutation - optimistic updates are applied immediately", async () => { | ||
| 112 | const { client } = createTestClient(); | ||
| 113 | testStore.clear(); | ||
| 114 | |||
| 115 | const mutation = client.defineBlocking({ | ||
| 116 | async mutate(_key: string, value: string) { | ||
| 117 | await delay(50); | ||
| 118 | return value; | ||
| 119 | }, | ||
| 120 | describe: "set value", | ||
| 121 | optimistic({ args, helpers }) { | ||
| 122 | const [key, value] = args; | ||
| 123 | helpers.setValue(key, value); | ||
| 124 | }, | ||
| 125 | async refetch() {}, | ||
| 126 | }); | ||
| 127 | |||
| 128 | const promise = mutation.runAndReturn("key1", "value1"); | ||
| 129 | |||
| 130 | // Optimistic update should be applied synchronously | ||
| 131 | assertEquals(testStore.get("key1"), "value1"); | ||
| 132 | |||
| 133 | // Wait for mutation to complete | ||
| 134 | await promise; | ||
| 135 | await delay(10); | ||
| 136 | }); | ||
| 137 | |||
| 138 | test("QueuedMutation - rollback on error", async () => { | ||
| 139 | const { client } = createTestClient(); | ||
| 140 | testStore.clear(); | ||
| 141 | |||
| 142 | const mutation = client.defineBlocking({ | ||
| 143 | async mutate(_key: string, _value: string) { | ||
| 144 | await delay(10); | ||
| 145 | throw new Error("mutation failed"); | ||
| 146 | }, | ||
| 147 | describe: "failing mutation", | ||
| 148 | optimistic({ args, helpers }) { | ||
| 149 | const [key, value] = args; | ||
| 150 | helpers.setValue(key, value); | ||
| 151 | }, | ||
| 152 | async refetch() {}, | ||
| 153 | }); | ||
| 154 | |||
| 155 | await assertRejects(() => mutation.runAndReturn("key1", "value1")); | ||
| 156 | |||
| 157 | // Optimistic update should be rolled back | ||
| 158 | assertEquals(testStore.has("key1"), false); | ||
| 159 | }); | ||
| 160 | |||
| 161 | test("QueuedMutation - onSuccess callback is called", async () => { | ||
| 162 | const { client } = createTestClient(); | ||
| 163 | const successResults: string[] = []; | ||
| 164 | |||
| 165 | const mutation = client.defineBlocking({ | ||
| 166 | async mutate(value: string) { | ||
| 167 | return `result-${value}`; | ||
| 168 | }, | ||
| 169 | describe: "test mutation", | ||
| 170 | optimistic({ onSuccess }) { | ||
| 171 | onSuccess((result) => { | ||
| 172 | successResults.push(result); | ||
| 173 | }); | ||
| 174 | }, | ||
| 175 | async refetch() {}, | ||
| 176 | }); | ||
| 177 | |||
| 178 | await mutation.runAndReturn("test"); | ||
| 179 | |||
| 180 | assertEquals(successResults, ["result-test"]); | ||
| 181 | }); | ||
| 182 | |||
| 183 | test("QueuedMutation - mutations with same key execute serially", async () => { | ||
| 184 | const { client } = createTestClient(); | ||
| 185 | const executionOrder: string[] = []; | ||
| 186 | |||
| 187 | const mutation = client.defineBlocking({ | ||
| 188 | async mutate(id: string) { | ||
| 189 | executionOrder.push(`start-${id}`); | ||
| 190 | await delay(20); | ||
| 191 | executionOrder.push(`end-${id}`); | ||
| 192 | return id; | ||
| 193 | }, | ||
| 194 | describe: "test mutation", | ||
| 195 | optimistic() {}, | ||
| 196 | async refetch() {}, | ||
| 197 | refetchOnSuccess: false, | ||
| 198 | key() { | ||
| 199 | return "same-key"; | ||
| 200 | }, | ||
| 201 | }); | ||
| 202 | |||
| 203 | // Start two mutations with the same key | ||
| 204 | const promise1 = mutation.runAndReturn("1"); | ||
| 205 | const promise2 = mutation.runAndReturn("2"); | ||
| 206 | |||
| 207 | await Promise.all([promise1, promise2]); | ||
| 208 | await delay(10); | ||
| 209 | |||
| 210 | // They should execute serially, not in parallel | ||
| 211 | assertEquals(executionOrder, ["start-1", "end-1", "start-2", "end-2"]); | ||
| 212 | }); | ||
| 213 | |||
| 214 | test("QueuedMutation - mutations with different keys execute in parallel", async () => { | ||
| 215 | const { client } = createTestClient(); | ||
| 216 | const executionOrder: string[] = []; | ||
| 217 | |||
| 218 | const mutation = client.defineBlocking({ | ||
| 219 | async mutate(id: string) { | ||
| 220 | executionOrder.push(`start-${id}`); | ||
| 221 | await delay(20); | ||
| 222 | executionOrder.push(`end-${id}`); | ||
| 223 | return id; | ||
| 224 | }, | ||
| 225 | describe: "test mutation", | ||
| 226 | optimistic() {}, | ||
| 227 | async refetch() {}, | ||
| 228 | key({ args }) { | ||
| 229 | const [id] = args; | ||
| 230 | return id; | ||
| 231 | }, | ||
| 232 | }); | ||
| 233 | |||
| 234 | // Start two mutations with different keys | ||
| 235 | const promise1 = mutation.runAndReturn("key1"); | ||
| 236 | const promise2 = mutation.runAndReturn("key2"); | ||
| 237 | |||
| 238 | await Promise.all([promise1, promise2]); | ||
| 239 | |||
| 240 | // They should start in parallel | ||
| 241 | assertEquals(executionOrder.slice(0, 2).sort(), ["start-key1", "start-key2"]); | ||
| 242 | }); | ||
| 243 | |||
| 244 | test("QueuedMutation - key() returns JSON stringified key", () => { | ||
| 245 | const { client } = createTestClient(); | ||
| 246 | |||
| 247 | const mutation = client.defineBlocking({ | ||
| 248 | async mutate(id: string) { | ||
| 249 | return id; | ||
| 250 | }, | ||
| 251 | describe: "test mutation", | ||
| 252 | optimistic() {}, | ||
| 253 | async refetch() {}, | ||
| 254 | key({ args }) { | ||
| 255 | const [id] = args; | ||
| 256 | return id; | ||
| 257 | }, | ||
| 258 | }); | ||
| 259 | |||
| 260 | assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id")); | ||
| 261 | }); | ||
| 262 | |||
| 263 | test("QueuedMutation - key() defaults to 'shared' when no key function", () => { | ||
| 264 | const { client } = createTestClient(); | ||
| 265 | |||
| 266 | const mutation = client.defineBlocking({ | ||
| 267 | async mutate(id: string) { | ||
| 268 | return id; | ||
| 269 | }, | ||
| 270 | describe: "test mutation", | ||
| 271 | optimistic() {}, | ||
| 272 | async refetch() {}, | ||
| 273 | }); | ||
| 274 | |||
| 275 | assertEquals(mutation.key(["test-id"]), JSON.stringify("shared")); | ||
| 276 | }); | ||
| 277 | |||
| 278 | test("QueuedMutation - key() can return array", () => { | ||
| 279 | const { client } = createTestClient(); | ||
| 280 | |||
| 281 | const mutation = client.defineBlocking({ | ||
| 282 | async mutate(_userId: string, _itemId: string) { | ||
| 283 | return "result"; | ||
| 284 | }, | ||
| 285 | describe: "test mutation", | ||
| 286 | optimistic() {}, | ||
| 287 | async refetch() {}, | ||
| 288 | key({ args }) { | ||
| 289 | const [userId, itemId] = args; | ||
| 290 | return [userId, itemId]; | ||
| 291 | }, | ||
| 292 | }); | ||
| 293 | |||
| 294 | assertEquals( | ||
| 295 | mutation.key(["user1", "item1"]), | ||
| 296 | JSON.stringify(["user1", "item1"]), | ||
| 297 | ); | ||
| 298 | }); | ||
| 299 | |||
| 300 | test("QueuedMutation - describe() with string", () => { | ||
| 301 | const { client } = createTestClient(); | ||
| 302 | |||
| 303 | const mutation = client.defineBlocking({ | ||
| 304 | async mutate(value: string) { | ||
| 305 | return value; | ||
| 306 | }, | ||
| 307 | describe: "create item", | ||
| 308 | optimistic() {}, | ||
| 309 | async refetch() {}, | ||
| 310 | }); | ||
| 311 | |||
| 312 | assertEquals(mutation.describe("test"), "create item"); | ||
| 313 | }); | ||
| 314 | |||
| 315 | test("QueuedMutation - describe() with function", () => { | ||
| 316 | const { client } = createTestClient(); | ||
| 317 | |||
| 318 | const mutation = client.defineBlocking({ | ||
| 319 | async mutate(id: string) { | ||
| 320 | return id; | ||
| 321 | }, | ||
| 322 | describe({ args }) { | ||
| 323 | const [id] = args; | ||
| 324 | return `delete item ${id}`; | ||
| 325 | }, | ||
| 326 | optimistic() {}, | ||
| 327 | async refetch() {}, | ||
| 328 | }); | ||
| 329 | |||
| 330 | assertEquals(mutation.describe("123"), "delete item 123"); | ||
| 331 | }); | ||
| 332 | |||
| 333 | test("QueuedMutation - describe() receives context", () => { | ||
| 334 | const { client } = createTestClient(); | ||
| 335 | |||
| 336 | const mutation = client.defineBlocking({ | ||
| 337 | async mutate(id: string) { | ||
| 338 | return id; | ||
| 339 | }, | ||
| 340 | describe({ userId, args }) { | ||
| 341 | const [id] = args; | ||
| 342 | return `user ${userId} editing item ${id}`; | ||
| 343 | }, | ||
| 344 | optimistic() {}, | ||
| 345 | async refetch() {}, | ||
| 346 | }); | ||
| 347 | |||
| 348 | assertEquals( | ||
| 349 | mutation.describe("123"), | ||
| 350 | "user test-user editing item 123", | ||
| 351 | ); | ||
| 352 | }); | ||
| 353 | |||
| 354 | test("QueuedMutation - subscribe() tracks mutation events", async () => { | ||
| 355 | const { client } = createTestClient(); | ||
| 356 | const tracker = createEventTracker<string>(); | ||
| 357 | |||
| 358 | const mutation = client.defineBlocking({ | ||
| 359 | async mutate(value: string) { | ||
| 360 | await delay(10); | ||
| 361 | return `result-${value}`; | ||
| 362 | }, | ||
| 363 | describe: "test mutation", | ||
| 364 | optimistic() {}, | ||
| 365 | async refetch() { | ||
| 366 | await delay(5); | ||
| 367 | }, | ||
| 368 | }); | ||
| 369 | |||
| 370 | const key = mutation.key(["test"]); | ||
| 371 | mutation.subscribe(key, tracker.callback); | ||
| 372 | |||
| 373 | await mutation.runAndReturn("test"); | ||
| 374 | // Wait for refetch to complete | ||
| 375 | await delay(20); | ||
| 376 | |||
| 377 | // Should have received status updates | ||
| 378 | assertEquals(tracker.events.length >= 2, true); | ||
| 379 | assertEquals(tracker.events.some((e) => e.status === "mutating"), true); | ||
| 380 | assertEquals(tracker.events.some((e) => e.status === "refetching"), true); | ||
| 381 | }); | ||
| 382 | |||
| 383 | test("QueuedMutation - unsubscribe stops receiving events", async () => { | ||
| 384 | const { client } = createTestClient(); | ||
| 385 | const tracker = createEventTracker<string>(); | ||
| 386 | |||
| 387 | const mutation = client.defineBlocking({ | ||
| 388 | async mutate(value: string) { | ||
| 389 | await delay(10); | ||
| 390 | return value; | ||
| 391 | }, | ||
| 392 | describe: "test mutation", | ||
| 393 | optimistic() {}, | ||
| 394 | async refetch() {}, | ||
| 395 | refetchOnSuccess: false, | ||
| 396 | }); | ||
| 397 | |||
| 398 | const key = mutation.key(["test"]); | ||
| 399 | const unsubscribe = mutation.subscribe(key, tracker.callback); | ||
| 400 | |||
| 401 | unsubscribe(); | ||
| 402 | |||
| 403 | await mutation.runAndReturn("test"); | ||
| 404 | await delay(10); | ||
| 405 | |||
| 406 | // Should not have received any events | ||
| 407 | assertEquals(tracker.events.length, 0); | ||
| 408 | }); | ||
| 409 | |||
| 410 | test("QueuedMutation - refetchOnSuccess can be disabled", async () => { | ||
| 411 | const { client } = createTestClient(); | ||
| 412 | let refetchCallCount = 0; | ||
| 413 | |||
| 414 | const mutation = client.defineBlocking({ | ||
| 415 | async mutate(_value: string) { | ||
| 416 | return _value; | ||
| 417 | }, | ||
| 418 | describe: "test mutation", | ||
| 419 | optimistic() {}, | ||
| 420 | async refetch() { | ||
| 421 | refetchCallCount++; | ||
| 422 | }, | ||
| 423 | refetchOnSuccess: false, | ||
| 424 | }); | ||
| 425 | |||
| 426 | await mutation.runAndReturn("test"); | ||
| 427 | |||
| 428 | assertEquals(refetchCallCount, 0); | ||
| 429 | }); | ||
| 430 | |||
| 431 | test("QueuedMutation - refetch is called on error", async () => { | ||
| 432 | const { client } = createTestClient(); | ||
| 433 | let refetchCallCount = 0; | ||
| 434 | |||
| 435 | const mutation = client.defineBlocking({ | ||
| 436 | async mutate(_value: string) { | ||
| 437 | throw new Error("mutation failed"); | ||
| 438 | }, | ||
| 439 | describe: "failing mutation", | ||
| 440 | optimistic() {}, | ||
| 441 | async refetch() { | ||
| 442 | refetchCallCount++; | ||
| 443 | }, | ||
| 444 | }); | ||
| 445 | |||
| 446 | await assertRejects(() => mutation.runAndReturn("test")); | ||
| 447 | |||
| 448 | assertEquals(refetchCallCount, 1); | ||
| 449 | }); | ||
| 450 | |||
| 451 | test("QueuedMutation - queued mutations are cancelled on error", async () => { | ||
| 452 | const { client } = createTestClient(); | ||
| 453 | const executionOrder: string[] = []; | ||
| 454 | |||
| 455 | const mutation = client.defineBlocking({ | ||
| 456 | async mutate(id: string) { | ||
| 457 | executionOrder.push(`start-${id}`); | ||
| 458 | await delay(10); | ||
| 459 | if (id === "1") { | ||
| 460 | throw new Error("first mutation failed"); | ||
| 461 | } | ||
| 462 | executionOrder.push(`end-${id}`); | ||
| 463 | return id; | ||
| 464 | }, | ||
| 465 | describe: "test mutation", | ||
| 466 | optimistic() {}, | ||
| 467 | async refetch() {}, | ||
| 468 | key() { | ||
| 469 | return "same-key"; | ||
| 470 | }, | ||
| 471 | }); | ||
| 472 | |||
| 473 | const promise1 = mutation.runAndReturn("1"); | ||
| 474 | const promise2 = mutation.runAndReturn("2"); | ||
| 475 | const promise3 = mutation.runAndReturn("3"); | ||
| 476 | |||
| 477 | await assertRejects(() => promise1, Error, "first mutation failed"); | ||
| 478 | await assertRejects(() => promise2, Error, "first mutation failed"); | ||
| 479 | await assertRejects(() => promise3, Error, "first mutation failed"); | ||
| 480 | |||
| 481 | // Only the first mutation should start | ||
| 482 | assertEquals(executionOrder, ["start-1"]); | ||
| 483 | }); | ||
| 484 | |||
| 485 | test("QueuedMutation - rollbacks are called in reverse order on error", async () => { | ||
| 486 | const { client } = createTestClient(); | ||
| 487 | const rollbackOrder: number[] = []; | ||
| 488 | |||
| 489 | const mutation = client.defineBlocking({ | ||
| 490 | async mutate(_value: string) { | ||
| 491 | throw new Error("mutation failed"); | ||
| 492 | }, | ||
| 493 | describe: "failing mutation", | ||
| 494 | optimistic({ onRestore }) { | ||
| 495 | onRestore(() => rollbackOrder.push(1)); | ||
| 496 | onRestore(() => rollbackOrder.push(2)); | ||
| 497 | onRestore(() => rollbackOrder.push(3)); | ||
| 498 | }, | ||
| 499 | async refetch() {}, | ||
| 500 | }); | ||
| 501 | |||
| 502 | await assertRejects(() => mutation.runAndReturn("test")); | ||
| 503 | |||
| 504 | // Rollbacks should be called in reverse order | ||
| 505 | assertEquals(rollbackOrder, [3, 2, 1]); | ||
| 506 | }); | ||
| 507 | |||
| 508 | test("QueuedMutation - multiple mutations: rollbacks only affect failed mutation", async () => { | ||
| 509 | const { client } = createTestClient(); | ||
| 510 | const rollbackOrder: string[] = []; | ||
| 511 | |||
| 512 | const mutation = client.defineBlocking({ | ||
| 513 | async mutate(id: string) { | ||
| 514 | await delay(10); | ||
| 515 | if (id === "fail") { | ||
| 516 | throw new Error("mutation failed"); | ||
| 517 | } | ||
| 518 | return id; | ||
| 519 | }, | ||
| 520 | describe: "test mutation", | ||
| 521 | optimistic({ args: [id], onRestore }) { | ||
| 522 | onRestore(() => rollbackOrder.push(`rollback-${id}`)); | ||
| 523 | }, | ||
| 524 | async refetch() {}, | ||
| 525 | key() { | ||
| 526 | return "same-key"; | ||
| 527 | }, | ||
| 528 | }); | ||
| 529 | |||
| 530 | // First mutation succeeds | ||
| 531 | await mutation.runAndReturn("success"); | ||
| 532 | |||
| 533 | // Second mutation fails | ||
| 534 | await assertRejects(() => mutation.runAndReturn("fail")); | ||
| 535 | |||
| 536 | // Only the failed mutation's rollback should be called | ||
| 537 | // And all rollbacks from queued items | ||
| 538 | assertEquals(rollbackOrder, ["rollback-fail"]); | ||
| 539 | }); | ||
| 540 | |||
| 541 | test("QueuedMutation - onRestore throws error if called after optimistic phase", async () => { | ||
| 542 | const { client } = createTestClient(); | ||
| 543 | let capturedOnRestore: ((cb: () => void) => void) | null = null; | ||
| 544 | |||
| 545 | const mutation = client.defineBlocking({ | ||
| 546 | async mutate(_value: string) { | ||
| 547 | return "result"; | ||
| 548 | }, | ||
| 549 | describe: "test mutation", | ||
| 550 | optimistic({ onRestore }) { | ||
| 551 | capturedOnRestore = onRestore; | ||
| 552 | }, | ||
| 553 | async refetch() {}, | ||
| 554 | }); | ||
| 555 | |||
| 556 | await mutation.runAndReturn("test"); | ||
| 557 | |||
| 558 | // Calling onRestore after the optimistic phase should throw | ||
| 559 | let error: Error | null = null; | ||
| 560 | try { | ||
| 561 | capturedOnRestore!(() => {}); | ||
| 562 | } catch (e) { | ||
| 563 | error = e as Error; | ||
| 564 | } | ||
| 565 | |||
| 566 | assertEquals( | ||
| 567 | error?.message, | ||
| 568 | "Can only call onRestore from within the optimistic update function.", | ||
| 569 | ); | ||
| 570 | }); | ||
| 571 | |||
| 572 | test("QueuedMutation - onSuccess throws error if called after optimistic phase", async () => { | ||
| 573 | const { client } = createTestClient(); | ||
| 574 | let capturedOnSuccess: ((cb: (result: string) => void) => void) | null = null; | ||
| 575 | |||
| 576 | const mutation = client.defineBlocking({ | ||
| 577 | async mutate(_value: string) { | ||
| 578 | return "result"; | ||
| 579 | }, | ||
| 580 | describe: "test mutation", | ||
| 581 | optimistic({ onSuccess }) { | ||
| 582 | capturedOnSuccess = onSuccess; | ||
| 583 | }, | ||
| 584 | async refetch() {}, | ||
| 585 | }); | ||
| 586 | |||
| 587 | await mutation.runAndReturn("test"); | ||
| 588 | |||
| 589 | // Calling onSuccess after the optimistic phase should throw | ||
| 590 | let error: Error | null = null; | ||
| 591 | try { | ||
| 592 | capturedOnSuccess!(() => {}); | ||
| 593 | } catch (e) { | ||
| 594 | error = e as Error; | ||
| 595 | } | ||
| 596 | |||
| 597 | assertEquals( | ||
| 598 | error?.message, | ||
| 599 | "Can only call onSuccess from within the optimistic update function.", | ||
| 600 | ); | ||
| 601 | }); | ||
| 602 | |||
| 603 | test("QueuedMutation - error during optimistic update is rejected immediately", async () => { | ||
| 604 | const { client } = createTestClient(); | ||
| 605 | |||
| 606 | const mutation = client.defineBlocking({ | ||
| 607 | async mutate(_value: string) { | ||
| 608 | return "result"; | ||
| 609 | }, | ||
| 610 | describe: "test mutation", | ||
| 611 | optimistic() { | ||
| 612 | throw new Error("optimistic update failed"); | ||
| 613 | }, | ||
| 614 | async refetch() {}, | ||
| 615 | }); | ||
| 616 | |||
| 617 | await assertRejects( | ||
| 618 | () => mutation.runAndReturn("test"), | ||
| 619 | Error, | ||
| 620 | "optimistic update failed", | ||
| 621 | ); | ||
| 622 | }); | ||
| 623 | |||
| 624 | test("QueuedMutation - error during optimistic update rolls back registered callbacks", async () => { | ||
| 625 | const { client } = createTestClient(); | ||
| 626 | const rollbackOrder: number[] = []; | ||
| 627 | |||
| 628 | const mutation = client.defineBlocking({ | ||
| 629 | async mutate(_value: string) { | ||
| 630 | return "result"; | ||
| 631 | }, | ||
| 632 | describe: "test mutation", | ||
| 633 | optimistic({ onRestore }) { | ||
| 634 | onRestore(() => rollbackOrder.push(1)); | ||
| 635 | onRestore(() => rollbackOrder.push(2)); | ||
| 636 | throw new Error("optimistic update failed"); | ||
| 637 | }, | ||
| 638 | async refetch() {}, | ||
| 639 | }); | ||
| 640 | |||
| 641 | await assertRejects(() => mutation.runAndReturn("test")); | ||
| 642 | |||
| 643 | // Rollbacks should be called even though optimistic update failed | ||
| 644 | // Note: during optimistic error, rollbacks are executed in the order they were added | ||
| 645 | assertEquals(rollbackOrder, [1, 2]); | ||
| 646 | }); | ||
| 647 | |||
| 648 | test("QueuedMutation - refetch errors are reported but don't fail mutation", async () => { | ||
| 649 | const { client, errors } = createTestClient(); | ||
| 650 | |||
| 651 | const mutation = client.defineBlocking({ | ||
| 652 | async mutate(value: string) { | ||
| 653 | return value; | ||
| 654 | }, | ||
| 655 | describe: "test mutation", | ||
| 656 | optimistic() {}, | ||
| 657 | async refetch() { | ||
| 658 | throw new Error("refetch failed"); | ||
| 659 | }, | ||
| 660 | }); | ||
| 661 | |||
| 662 | // Mutation should still succeed | ||
| 663 | const result = await mutation.runAndReturn("test"); | ||
| 664 | assertEquals(result, "test"); | ||
| 665 | |||
| 666 | // But refetch error should be reported | ||
| 667 | await delay(20); | ||
| 668 | assertEquals(errors.length, 1); | ||
| 669 | assertEquals((errors[0] as Error).message, "refetch failed"); | ||
| 670 | }); | ||
| 671 | |||
| 672 | test("QueuedMutation - optimistic function receives args and helpers", async () => { | ||
| 673 | const { client } = createTestClient(); | ||
| 674 | let receivedArgs: unknown[] | undefined; | ||
| 675 | let receivedHelpers: unknown | undefined; | ||
| 676 | |||
| 677 | const mutation = client.defineBlocking({ | ||
| 678 | async mutate(_value: string) { | ||
| 679 | return "result"; | ||
| 680 | }, | ||
| 681 | describe: "test mutation", | ||
| 682 | optimistic({ args, helpers }) { | ||
| 683 | receivedArgs = args; | ||
| 684 | receivedHelpers = helpers; | ||
| 685 | }, | ||
| 686 | async refetch() {}, | ||
| 687 | }); | ||
| 688 | |||
| 689 | await mutation.runAndReturn("test"); | ||
| 690 | |||
| 691 | assertEquals(receivedArgs, ["test"]); | ||
| 692 | assertEquals(typeof receivedHelpers, "object"); | ||
| 693 | }); | ||
| 694 | |||
| 695 | test("QueuedMutation - refetch receives context and args", async () => { | ||
| 696 | const { client } = createTestClient(); | ||
| 697 | let receivedUserId: string | undefined; | ||
| 698 | let receivedArgs: unknown[] | undefined; | ||
| 699 | |||
| 700 | const mutation = client.defineBlocking({ | ||
| 701 | async mutate(_id: string, value: string) { | ||
| 702 | return value; | ||
| 703 | }, | ||
| 704 | describe: "test mutation", | ||
| 705 | optimistic() {}, | ||
| 706 | async refetch({ userId, args }) { | ||
| 707 | receivedUserId = userId; | ||
| 708 | receivedArgs = args; | ||
| 709 | }, | ||
| 710 | }); | ||
| 711 | |||
| 712 | await mutation.runAndReturn("test-id", "test-value"); | ||
| 713 | |||
| 714 | assertEquals(receivedUserId, "test-user"); | ||
| 715 | assertEquals(receivedArgs, ["test-id", "test-value"]); | ||
| 716 | }); | ||
| 717 | |||
| 718 | test("QueuedMutation - notifies error on mutation failure", async () => { | ||
| 719 | const { client } = createTestClient(); | ||
| 720 | const tracker = createEventTracker<string>(); | ||
| 721 | |||
| 722 | const mutation = client.defineBlocking({ | ||
| 723 | async mutate(_value: string) { | ||
| 724 | await delay(10); | ||
| 725 | throw new Error("mutation failed"); | ||
| 726 | }, | ||
| 727 | describe: "failing mutation", | ||
| 728 | optimistic() {}, | ||
| 729 | async refetch() {}, | ||
| 730 | }); | ||
| 731 | |||
| 732 | const key = mutation.key(["test"]); | ||
| 733 | mutation.subscribe(key, tracker.callback); | ||
| 734 | |||
| 735 | await assertRejects(() => mutation.runAndReturn("test")); | ||
| 736 | |||
| 737 | // Should have error event | ||
| 738 | const errorEvents = tracker.events.filter((e) => | ||
| 739 | e.status === "mutating" && e.error | ||
| 740 | ); | ||
| 741 | assertEquals(errorEvents.length > 0, true); | ||
| 742 | assertEquals((errorEvents[0]?.error as Error).message, "mutation failed"); | ||
| 743 | }); | ||
| 744 | |||
| 745 | test("QueuedMutation - multiple subscribers receive events", async () => { | ||
| 746 | const { client } = createTestClient(); | ||
| 747 | const tracker1 = createEventTracker<string>(); | ||
| 748 | const tracker2 = createEventTracker<string>(); | ||
| 749 | |||
| 750 | const mutation = client.defineBlocking({ | ||
| 751 | async mutate(value: string) { | ||
| 752 | await delay(5); | ||
| 753 | return value; | ||
| 754 | }, | ||
| 755 | describe: "test mutation", | ||
| 756 | optimistic() {}, | ||
| 757 | async refetch() {}, | ||
| 758 | refetchOnSuccess: false, | ||
| 759 | }); | ||
| 760 | |||
| 761 | const key = mutation.key(["test"]); | ||
| 762 | mutation.subscribe(key, tracker1.callback); | ||
| 763 | mutation.subscribe(key, tracker2.callback); | ||
| 764 | |||
| 765 | await mutation.runAndReturn("test"); | ||
| 766 | await delay(10); | ||
| 767 | |||
| 768 | // Both subscribers should receive events | ||
| 769 | assertEquals(tracker1.events.length, tracker2.events.length); | ||
| 770 | assertEquals(tracker1.events.length > 0, true); | ||
| 771 | }); | ||
| 772 | |||
| 773 | test("QueuedMutation - onSuccess is called before mutation resolves", async () => { | ||
| 774 | const { client } = createTestClient(); | ||
| 775 | const callOrder: string[] = []; | ||
| 776 | |||
| 777 | const mutation = client.defineBlocking({ | ||
| 778 | async mutate(value: string) { | ||
| 779 | return value; | ||
| 780 | }, | ||
| 781 | describe: "test mutation", | ||
| 782 | optimistic({ onSuccess }) { | ||
| 783 | onSuccess(() => { | ||
| 784 | callOrder.push("onSuccess"); | ||
| 785 | }); | ||
| 786 | }, | ||
| 787 | async refetch() {}, | ||
| 788 | refetchOnSuccess: false, | ||
| 789 | }); | ||
| 790 | |||
| 791 | const promise = mutation.runAndReturn("test"); | ||
| 792 | promise.then(() => { | ||
| 793 | callOrder.push("then"); | ||
| 794 | }); | ||
| 795 | |||
| 796 | await promise; | ||
| 797 | await delay(5); | ||
| 798 | |||
| 799 | // onSuccess should be called before the promise resolves | ||
| 800 | assertEquals(callOrder, ["onSuccess", "then"]); | ||
| 801 | }); | ||
| 802 | |||
| 803 | test("QueuedMutation - result is passed to notification on success", async () => { | ||
| 804 | const { client } = createTestClient(); | ||
| 805 | const tracker = createEventTracker<string>(); | ||
| 806 | |||
| 807 | const mutation = client.defineBlocking({ | ||
| 808 | async mutate(value: string) { | ||
| 809 | await delay(5); | ||
| 810 | return `result-${value}`; | ||
| 811 | }, | ||
| 812 | describe: "test mutation", | ||
| 813 | optimistic() {}, | ||
| 814 | async refetch() { | ||
| 815 | await delay(5); | ||
| 816 | }, | ||
| 817 | }); | ||
| 818 | |||
| 819 | const key = mutation.key(["test"]); | ||
| 820 | mutation.subscribe(key, tracker.callback); | ||
| 821 | |||
| 822 | await mutation.runAndReturn("test"); | ||
| 823 | await delay(20); | ||
| 824 | |||
| 825 | // Should have refetching event with result | ||
| 826 | const refetchingEvents = tracker.events.filter((e) => | ||
| 827 | e.status === "refetching" | ||
| 828 | ); | ||
| 829 | assertEquals(refetchingEvents.length > 0, true); | ||
| 830 | assertEquals(refetchingEvents[0]?.result, "result-test"); | ||
| 831 | }); | ||
| 832 | |||
| 833 | test("QueuedMutation - channel is reused for same key", async () => { | ||
| 834 | const { client } = createTestClient(); | ||
| 835 | const events: string[] = []; | ||
| 836 | |||
| 837 | const mutation = client.defineBlocking({ | ||
| 838 | async mutate(value: string) { | ||
| 839 | events.push(`mutate-${value}`); | ||
| 840 | return value; | ||
| 841 | }, | ||
| 842 | describe: "test mutation", | ||
| 843 | optimistic() {}, | ||
| 844 | async refetch() {}, | ||
| 845 | refetchOnSuccess: false, | ||
| 846 | }); | ||
| 847 | |||
| 848 | // First mutation | ||
| 849 | await mutation.runAndReturn("first"); | ||
| 850 | await delay(5); | ||
| 851 | |||
| 852 | // Second mutation with same key | ||
| 853 | await mutation.runAndReturn("second"); | ||
| 854 | await delay(5); | ||
| 855 | |||
| 856 | assertEquals(events, ["mutate-first", "mutate-second"]); | ||
| 857 | }); | ||
| 858 | |||
| 859 | test("QueuedMutation - empty queue after all mutations complete", async () => { | ||
| 860 | const { client } = createTestClient(); | ||
| 861 | |||
| 862 | const mutation = client.defineBlocking({ | ||
| 863 | async mutate(value: string) { | ||
| 864 | await delay(5); | ||
| 865 | return value; | ||
| 866 | }, | ||
| 867 | describe: "test mutation", | ||
| 868 | optimistic() {}, | ||
| 869 | async refetch() {}, | ||
| 870 | refetchOnSuccess: false, | ||
| 871 | key() { | ||
| 872 | return "test-key"; | ||
| 873 | }, | ||
| 874 | }); | ||
| 875 | |||
| 876 | // Run multiple mutations | ||
| 877 | await mutation.runAndReturn("1"); | ||
| 878 | await mutation.runAndReturn("2"); | ||
| 879 | await mutation.runAndReturn("3"); | ||
| 880 | await delay(10); | ||
| 881 | |||
| 882 | // All mutations should have completed | ||
| 883 | // (We can't directly check the queue, but we can verify by running another mutation) | ||
| 884 | const start = Date.now(); | ||
| 885 | await mutation.runAndReturn("4"); | ||
| 886 | const duration = Date.now() - start; | ||
| 887 | |||
| 888 | // Should execute immediately, not be queued (< 10ms if not queued) | ||
| 889 | assertEquals(duration < 15, true); | ||
| 890 | }); | ||
| 891 | |||
| 892 | test("QueuedMutation - multiple onSuccess callbacks are all called", async () => { | ||
| 893 | const { client } = createTestClient(); | ||
| 894 | const results: string[] = []; | ||
| 895 | |||
| 896 | const mutation = client.defineBlocking({ | ||
| 897 | async mutate(value: string) { | ||
| 898 | return value; | ||
| 899 | }, | ||
| 900 | describe: "test mutation", | ||
| 901 | optimistic({ onSuccess }) { | ||
| 902 | onSuccess((result) => results.push(`first-${result}`)); | ||
| 903 | onSuccess((result) => results.push(`second-${result}`)); | ||
| 904 | onSuccess((result) => results.push(`third-${result}`)); | ||
| 905 | }, | ||
| 906 | async refetch() {}, | ||
| 907 | refetchOnSuccess: false, | ||
| 908 | }); | ||
| 909 | |||
| 910 | await mutation.runAndReturn("test"); | ||
| 911 | |||
| 912 | assertEquals(results, ["first-test", "second-test", "third-test"]); | ||
| 913 | }); | ||
| 914 | |||
| 915 | test("QueuedMutation - refetchOnSuccess false skips refetch", async () => { | ||
| 916 | const { client } = createTestClient(); | ||
| 917 | let refetchCalled = false; | ||
| 918 | |||
| 919 | const mutation = client.defineBlocking({ | ||
| 920 | async mutate(value: string) { | ||
| 921 | return value; | ||
| 922 | }, | ||
| 923 | describe: "test mutation", | ||
| 924 | optimistic() {}, | ||
| 925 | async refetch() { | ||
| 926 | refetchCalled = true; | ||
| 927 | }, | ||
| 928 | refetchOnSuccess: false, | ||
| 929 | }); | ||
| 930 | |||
| 931 | await mutation.runAndReturn("test"); | ||
| 932 | await delay(10); | ||
| 933 | |||
| 934 | // Refetch should not have been called | ||
| 935 | assertEquals(refetchCalled, false); | ||
| 936 | }); | ||
| 937 | |||
| 938 | test("QueuedMutation - refetch error after mutation failure is reported", async () => { | ||
| 939 | const { client, errors } = createTestClient(); | ||
| 940 | |||
| 941 | const mutation = client.defineBlocking({ | ||
| 942 | async mutate(_value: string) { | ||
| 943 | throw new Error("mutation failed"); | ||
| 944 | }, | ||
| 945 | describe: "failing mutation", | ||
| 946 | optimistic() {}, | ||
| 947 | async refetch() { | ||
| 948 | throw new Error("refetch also failed"); | ||
| 949 | }, | ||
| 950 | }); | ||
| 951 | |||
| 952 | await assertRejects( | ||
| 953 | () => mutation.runAndReturn("test"), | ||
| 954 | Error, | ||
| 955 | "mutation failed", | ||
| 956 | ); | ||
| 957 | |||
| 958 | // Wait for refetch to complete and error to be reported | ||
| 959 | await delay(20); | ||
| 960 | |||
| 961 | // Should have both the mutation error and refetch error reported | ||
| 962 | assertEquals(errors.length >= 1, true); | ||
| 963 | assertEquals( | ||
| 964 | (errors[errors.length - 1] as Error).message, | ||
| 965 | "refetch also failed", | ||
| 966 | ); | ||
| 967 | }); | ||