authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-28 15:24:00-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-28 18:51:56-08:00
log331a3e7e7454101d1933ba3d28996ad554a0bf6a
tree8160eeedbf1b0891674630fb694e639fed662619
parentde7e1ab0f88b02444249d69a59c5fed7c981b6c9
signaturelock-open Commit is signed but in an unrecognized format.

feat: some more features


13 files changed, 132 insertions(+), 66 deletions(-)

jsr.json+1-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1{1{
2 "name": "@clo/react-mutation",2 "name": "@clo/react-mutation",
3 "version": "1.0.0-beta.1",3 "version": "1.0.0-beta.2",
4 "exports": {4 "exports": {
5 ".": "./src/mod.ts",5 ".": "./src/mod.ts",
6 "./tanstack-query.ts": "./src/tanstack-query.ts",6 "./tanstack-query.ts": "./src/tanstack-query.ts",
package-lock.json+9-2
...@@ -1,14 +1,15 @@...@@ -1,14 +1,15 @@
1{1{
2 "name": "@clo/react-mutation",2 "name": "@clo/react-mutation",
3 "version": "0.0.0",3 "version": "0.1.0",
4 "lockfileVersion": 3,4 "lockfileVersion": 3,
5 "requires": true,5 "requires": true,
6 "packages": {6 "packages": {
7 "": {7 "": {
8 "name": "@clo/react-mutation",8 "name": "@clo/react-mutation",
9 "version": "0.0.0",9 "version": "0.1.0",
10 "license": "ISC",10 "license": "ISC",
11 "dependencies": {11 "dependencies": {
12 "@clo/lib": "npm:@jsr/clo__lib@^3.0.0",
12 "@std/assert": "npm:@jsr/std__assert@^1.0.17"13 "@std/assert": "npm:@jsr/std__assert@^1.0.17"
13 },14 },
14 "devDependencies": {15 "devDependencies": {
...@@ -315,6 +316,12 @@...@@ -315,6 +316,12 @@
315 "node": ">=6.9.0"316 "node": ">=6.9.0"
316 }317 }
317 },318 },
319 "node_modules/@clo/lib": {
320 "name": "@jsr/clo__lib",
321 "version": "3.0.0",
322 "resolved": "https://npm.jsr.io/~/11/@jsr/clo__lib/3.0.0.tgz",
323 "integrity": "sha512-oseZwHCAcXNPbqnGZ37l7+wAoj6ikIXE1VM0s6eD6fz4DcgM030Slf0T7Lgtn7fIdas5hlfx4JF54TR+vo4THw=="
324 },
318 "node_modules/@esbuild/aix-ppc64": {325 "node_modules/@esbuild/aix-ppc64": {
319 "version": "0.27.2",326 "version": "0.27.2",
320 "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",327 "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
package.json+1
...@@ -13,6 +13,7 @@...@@ -13,6 +13,7 @@
13 "check": "tsc --noEmit"13 "check": "tsc --noEmit"
14 },14 },
15 "dependencies": {15 "dependencies": {
16 "@clo/lib": "npm:@jsr/clo__lib@^3.0.0",
16 "@std/assert": "npm:@jsr/std__assert@^1.0.17"17 "@std/assert": "npm:@jsr/std__assert@^1.0.17"
17 },18 },
18 "devDependencies": {19 "devDependencies": {
src/batch.ts+14-5
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import type { MutationClient, MutationClientFromConfig } from "./client.ts";1import type { MutationClient, MutationClientFromConfig } from "./client.ts";
2import type { MutationClientConfig } from "./client.ts";2import type { MutationClientConfig } from "./client.ts";
3import type { Mutation, MutationEvent } from "./types.ts";3import type { Mutation, MutationEvent } from "./types.ts";
4import { message as errMessage } from "@clo/lib/error.ts";
45
5export interface BatchMutationOptions<6export interface BatchMutationOptions<
6 Args extends unknown[],7 Args extends unknown[],
...@@ -124,6 +125,7 @@ export class BatchMutation<...@@ -124,6 +125,7 @@ export class BatchMutation<
124 #options: BatchMutationOptions<Args, Result, Optimistic, Config>;125 #options: BatchMutationOptions<Args, Result, Optimistic, Config>;
125 #client: MutationClientFromConfig<Config>;126 #client: MutationClientFromConfig<Config>;
126 #channels: Map<string, BatchChannel<Args, Result, Optimistic>> = new Map();127 #channels: Map<string, BatchChannel<Args, Result, Optimistic>> = new Map();
128 client: MutationClientFromConfig<Config>;
127129
128 constructor(130 constructor(
129 client: MutationClient<Config["context"], Config["optimisticHelpers"]>,131 client: MutationClient<Config["context"], Config["optimisticHelpers"]>,
...@@ -131,6 +133,7 @@ export class BatchMutation<...@@ -131,6 +133,7 @@ export class BatchMutation<
131 ) {133 ) {
132 this.#options = options;134 this.#options = options;
133 this.#client = client;135 this.#client = client;
136 this.client = client;
134 }137 }
135138
136 key(args: Args): string {139 key(args: Args): string {
...@@ -212,7 +215,10 @@ export class BatchMutation<...@@ -212,7 +215,10 @@ export class BatchMutation<
212 return describe;215 return describe;
213 }216 }
214217
215 describeResult(args: Args, initial: Optimistic, current: Optimistic, result: Result): string | undefined {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 {
216 const { describeResult } = this.#options;222 const { describeResult } = this.#options;
217 if (describeResult === null || describeResult === undefined) return undefined;223 if (describeResult === null || describeResult === undefined) return undefined;
218 return typeof describeResult === "function"224 return typeof describeResult === "function"
...@@ -229,7 +235,8 @@ export class BatchMutation<...@@ -229,7 +235,8 @@ export class BatchMutation<
229 /** Calling the mutation in a global scope. Errors and successes are turned into UI toasts. */235 /** Calling the mutation in a global scope. Errors and successes are turned into UI toasts. */
230 run(...args: Args): void {236 run(...args: Args): void {
231 this.#runAndReturn(args, true).catch((error) => {237 this.#runAndReturn(args, true).catch((error) => {
232 this.#client.reportError(error);238 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
239 this.#client.reportError(message, error);
233 });240 });
234 }241 }
235242
...@@ -400,7 +407,7 @@ export class BatchMutation<...@@ -400,7 +407,7 @@ export class BatchMutation<
400 // Report success globally if any of the pending items requested it407 // Report success globally if any of the pending items requested it
401 const shouldReportSuccess = pendingItems.some((item) => item.reportSuccessGlobally);408 const shouldReportSuccess = pendingItems.some((item) => item.reportSuccessGlobally);
402 if (shouldReportSuccess) {409 if (shouldReportSuccess) {
403 const message = this.describeResult(firstArgs, initial, current, result);410 const message = this.#describeResult(firstArgs, initial, current, result);
404 if (message && this.#client.reportSuccess) {411 if (message && this.#client.reportSuccess) {
405 this.#client.reportSuccess(message);412 this.#client.reportSuccess(message);
406 }413 }
...@@ -420,7 +427,8 @@ export class BatchMutation<...@@ -420,7 +427,8 @@ export class BatchMutation<
420 // Report any errors from refetch or callbacks427 // Report any errors from refetch or callbacks
421 results.forEach((result) => {428 results.forEach((result) => {
422 if (result.status === "rejected") {429 if (result.status === "rejected") {
423 this.#client.reportError(result.reason);430 const message = `Failed to refetch after ${this.describe(...firstArgs)}: ${errMessage(result.reason)}`;
431 this.#client.reportError(message, result.reason);
424 }432 }
425 });433 });
426 }).finally(() => {434 }).finally(() => {
...@@ -458,7 +466,8 @@ export class BatchMutation<...@@ -458,7 +466,8 @@ export class BatchMutation<
458 // Report any errors from refetch or callbacks466 // Report any errors from refetch or callbacks
459 results.forEach((result) => {467 results.forEach((result) => {
460 if (result.status === "rejected") {468 if (result.status === "rejected") {
461 this.#client.reportError(result.reason);469 const message = `Failed to refetch after ${this.describe(...firstArgs)}: ${errMessage(result.reason)}`;
470 this.#client.reportError(message, result.reason);
462 }471 }
463 });472 });
464 }).finally(() => {473 }).finally(() => {
src/client.ts+2-2
...@@ -21,7 +21,7 @@ export interface MutationClientOptions<...@@ -21,7 +21,7 @@ export interface MutationClientOptions<
21 getOptimisticHelpers: (21 getOptimisticHelpers: (
22 events: OptimisticEvents,22 events: OptimisticEvents,
23 ) => OptimisticHelpers;23 ) => OptimisticHelpers;
24 reportError: (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 determine27 * Compare two values for deep equality. Used by BatchMutation to determine
...@@ -42,7 +42,7 @@ export class MutationClient<...@@ -42,7 +42,7 @@ export class MutationClient<
42> {42> {
43 context: Context;43 context: Context;
44 getOptimisticHelpers: (event: OptimisticEvents) => OptimisticHelpers;44 getOptimisticHelpers: (event: OptimisticEvents) => OptimisticHelpers;
45 reportError: (error: unknown) => void;45 reportError: (message: string, error: unknown) => void;
46 reportSuccess?: (message: string) => void;46 reportSuccess?: (message: string) => void;
47 deepEquals: (a: unknown, b: unknown) => boolean;47 deepEquals: (a: unknown, b: unknown) => boolean;
4848
src/queued.ts+9-3
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import type { MutationClient, MutationClientFromConfig } from "./client.ts";1import type { MutationClient, MutationClientFromConfig } from "./client.ts";
2import type { MutationClientConfig } from "./client.ts";2import type { MutationClientConfig } from "./client.ts";
3import type { Mutation, MutationEvent } from "./types.ts";3import type { Mutation, MutationEvent } from "./types.ts";
4import { message as errMessage } from "@clo/lib/error.ts";
45
5/**6/**
6 * Argument to `defineMutation`.7 * Argument to `defineMutation`.
...@@ -96,6 +97,7 @@ export class QueuedMutation<...@@ -96,6 +97,7 @@ export class QueuedMutation<
96 #options: MutationOptions<Args, Result, Config>;97 #options: MutationOptions<Args, Result, Config>;
97 #client: MutationClientFromConfig<Config>;98 #client: MutationClientFromConfig<Config>;
98 #queues: Map<string, Channel<Args, Result>> = new Map();99 #queues: Map<string, Channel<Args, Result>> = new Map();
100 client: MutationClientFromConfig<Config>;
99101
100 constructor(102 constructor(
101 client: MutationClient<Config["context"], Config["optimisticHelpers"]>,103 client: MutationClient<Config["context"], Config["optimisticHelpers"]>,
...@@ -103,6 +105,7 @@ export class QueuedMutation<...@@ -103,6 +105,7 @@ export class QueuedMutation<
103 ) {105 ) {
104 this.#options = options;106 this.#options = options;
105 this.#client = client;107 this.#client = client;
108 this.client = client;
106 }109 }
107110
108 key(args: Args) {111 key(args: Args) {
...@@ -180,7 +183,8 @@ export class QueuedMutation<...@@ -180,7 +183,8 @@ export class QueuedMutation<
180 this.#client.reportSuccess(message);183 this.#client.reportSuccess(message);
181 }184 }
182 }).catch((error) => {185 }).catch((error) => {
183 this.#client.reportError(error);186 const message = `Failed to ${this.describe(...args)}: ${errMessage(error)}`;
187 this.#client.reportError(message, error);
184 });188 });
185 }189 }
186190
...@@ -287,7 +291,8 @@ export class QueuedMutation<...@@ -287,7 +291,8 @@ export class QueuedMutation<
287 // Report any errors from refetch or callbacks291 // Report any errors from refetch or callbacks
288 results.forEach((result) => {292 results.forEach((result) => {
289 if (result.status === "rejected") {293 if (result.status === "rejected") {
290 this.#client.reportError(result.reason);294 const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`;
295 this.#client.reportError(message, result.reason);
291 }296 }
292 });297 });
293 }).finally(() => {298 }).finally(() => {
...@@ -328,7 +333,8 @@ export class QueuedMutation<...@@ -328,7 +333,8 @@ export class QueuedMutation<
328 // Report any errors from refetch or callbacks333 // Report any errors from refetch or callbacks
329 results.forEach((result) => {334 results.forEach((result) => {
330 if (result.status === "rejected") {335 if (result.status === "rejected") {
331 this.#client.reportError(result.reason);336 const message = `Failed to refetch after ${this.describe(...args)}: ${errMessage(result.reason)}`;
337 this.#client.reportError(message, result.reason);
332 }338 }
333 });339 });
334 }).finally(() => {340 }).finally(() => {
src/react.tsx+45-12
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 useEffect,7 useEffect,
8 useState,8 useState,
9} from "react";9} from "react";
10import { message as errMessage } from "@clo/lib/error.ts";
10import type { Mutation } from "./types.ts";11import type { Mutation } from "./types.ts";
1112
12/**13/**
...@@ -46,6 +47,7 @@ export interface UseMutateSuccess<Result> {...@@ -46,6 +47,7 @@ export interface UseMutateSuccess<Result> {
46 status: "success";47 status: "success";
47 result: Result;48 result: Result;
48 error: undefined;49 error: undefined;
50 errorMessage: undefined;
49 /** `true` when a `mutate` function is currently running. */51 /** `true` when a `mutate` function is currently running. */
50 isMutating: false;52 isMutating: false;
51 /** `true` when a loading indicator should be shown. */53 /** `true` when a loading indicator should be shown. */
...@@ -61,6 +63,8 @@ export interface UseMutateError {...@@ -61,6 +63,8 @@ export interface UseMutateError {
61 status: "error";63 status: "error";
62 result: undefined;64 result: undefined;
63 error: unknown;65 error: unknown;
66 /** User-friendly in this format: `Failed to {action}: {details}` */
67 errorMessage: string;
64 /** `true` when a `mutate` function is currently running. */68 /** `true` when a `mutate` function is currently running. */
65 isMutating: false;69 isMutating: false;
66 /** `true` when a loading indicator should be shown. */70 /** `true` when a loading indicator should be shown. */
...@@ -76,6 +80,7 @@ export interface UseMutateIdle {...@@ -76,6 +80,7 @@ export interface UseMutateIdle {
76 status: "idle" | "mutating";80 status: "idle" | "mutating";
77 result: undefined;81 result: undefined;
78 error: undefined;82 error: undefined;
83 errorMessage: undefined;
79 /** `true` when a `mutate` function is currently running. */84 /** `true` when a `mutate` function is currently running. */
80 isMutating: boolean;85 isMutating: boolean;
81 /** `true` when a loading indicator should be shown. */86 /** `true` when a loading indicator should be shown. */
...@@ -91,12 +96,13 @@ export interface UseMutateIdle {...@@ -91,12 +96,13 @@ export interface UseMutateIdle {
91type AnyMutationState<Result> =96type AnyMutationState<Result> =
92 & Omit<97 & Omit<
93 UseMutateIdle,98 UseMutateIdle,
94 "status" | "result" | "error" | "isSuccess" | "isError"99 "status" | "result" | "error" | "isSuccess" | "isError" | "errorMessage"
95 >100 >
96 & {101 & {
97 status: "idle" | "mutating" | "error" | "success";102 status: "idle" | "mutating" | "error" | "success";
98 result: undefined | Result;103 result: undefined | Result;
99 error: undefined | unknown;104 error: undefined | unknown;
105 errorMessage: undefined | string;
100 isSuccess: boolean;106 isSuccess: boolean;
101 isError: boolean;107 isError: boolean;
102 };108 };
...@@ -106,6 +112,7 @@ function initialState() {...@@ -106,6 +112,7 @@ function initialState() {
106 status: "idle",112 status: "idle",
107 result: undefined,113 result: undefined,
108 error: undefined,114 error: undefined,
115 errorMessage: undefined,
109 isMutating: false,116 isMutating: false,
110 isPending: false,117 isPending: false,
111 isSuccess: false,118 isSuccess: false,
...@@ -119,6 +126,7 @@ class Observer<Args extends unknown[], Result> {...@@ -119,6 +126,7 @@ class Observer<Args extends unknown[], Result> {
119 mutation: Mutation<Args, Result> | null = null;126 mutation: Mutation<Args, Result> | null = null;
120 unsubscribe: (() => void) | null = null;127 unsubscribe: (() => void) | null = null;
121 currentKey: string | null = null;128 currentKey: string | null = null;
129 currentArgs: Args | null = null;
122130
123 constructor(setRerender: (fn: number) => void) {131 constructor(setRerender: (fn: number) => void) {
124 this.setRerender = setRerender;132 this.setRerender = setRerender;
...@@ -147,10 +155,20 @@ class Observer<Args extends unknown[], Result> {...@@ -147,10 +155,20 @@ class Observer<Args extends unknown[], Result> {
147 this.state = initialState();155 this.state = initialState();
148 }156 }
149157
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
150 binding: UseMutateResult<Args, Result> = ((self: this) => ({167 binding: UseMutateResult<Args, Result> = ((self: this) => ({
151 run(...args: Args) {168 run(...args: Args) {
152 const mutation = self.mutation;169 const mutation = self.mutation;
153 if (!mutation) return;170 if (!mutation) return;
171 self.currentArgs = args;
154 const key = mutation.key(args);172 const key = mutation.key(args);
155 if (key !== self.currentKey) {173 if (key !== self.currentKey) {
156 self.currentKey = key;174 self.currentKey = key;
...@@ -178,6 +196,7 @@ class Observer<Args extends unknown[], Result> {...@@ -178,6 +196,7 @@ class Observer<Args extends unknown[], Result> {
178 ? "mutating"196 ? "mutating"
179 : "idle",197 : "idle",
180 error: error ?? undefined,198 error: error ?? undefined,
199 errorMessage: self.computeErrorMessage(error ?? undefined),
181 result: result ?? undefined,200 result: result ?? undefined,
182 isMutating: status === "mutating",201 isMutating: status === "mutating",
183 isPending: status === "mutating" || status === "refetching",202 isPending: status === "mutating" || status === "refetching",
...@@ -189,19 +208,28 @@ class Observer<Args extends unknown[], Result> {...@@ -189,19 +208,28 @@ class Observer<Args extends unknown[], Result> {
189 },208 },
190 );209 );
191 }210 }
192 // use global error/success handling if this usage of the hook doesn't check for211 // Use global error/success handling if this usage of the hook doesn't check for
193 // errors or success. This makes it act pretty awesome in terms of defaults.212 // errors or success. This makes it act pretty awesome in terms of defaults.
194 // You don't have to worry about the errors/successes, they'll surface exactly once.213 // You don't have to worry about result UI, they'll surface exactly once.
195 if (214 const watchesError = self.watched.has("isError") ||
196 self.watched.has("isError") || self.watched.has("error") ||215 self.watched.has("error") || self.watched.has("errorMessage");
197 self.watched.has("isSuccess") || self.watched.has("result")216 const watchesSuccess = self.watched.has("isSuccess") ||
198 ) {217 self.watched.has("result");
199 mutation.runAndReturn(...args).catch(() => {218 mutation.runAndReturn(...args)
200 // caught in event listener219 .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 }
201 });232 });
202 } else {
203 mutation.run(...args);
204 }
205 },233 },
206 clear() {234 clear() {
207 self.setState({235 self.setState({
...@@ -211,6 +239,7 @@ class Observer<Args extends unknown[], Result> {...@@ -211,6 +239,7 @@ class Observer<Args extends unknown[], Result> {
211 isError: false,239 isError: false,
212 isSuccess: false,240 isSuccess: false,
213 error: undefined,241 error: undefined,
242 errorMessage: undefined,
214 result: undefined,243 result: undefined,
215 });244 });
216 },245 },
...@@ -226,6 +255,10 @@ class Observer<Args extends unknown[], Result> {...@@ -226,6 +255,10 @@ class Observer<Args extends unknown[], Result> {
226 self.watched.add("error");255 self.watched.add("error");
227 return self.state.error;256 return self.state.error;
228 },257 },
258 get errorMessage() {
259 self.watched.add("errorMessage");
260 return self.state.errorMessage;
261 },
229 get isMutating() {262 get isMutating() {
230 self.watched.add("isMutating");263 self.watched.add("isMutating");
231 return self.state.isMutating;264 return self.state.isMutating;
src/tanstack-query.ts+25-21
...@@ -307,7 +307,7 @@ class TanstackQueryOptimisticHelpers {...@@ -307,7 +307,7 @@ class TanstackQueryOptimisticHelpers {
307 }307 }
308308
309 /**309 /**
310 * Remove items from an array that match a predicate.310 * Remove items from an array that match `filter`.
311 * If the query or path doesn't exist, the updater is skipped.311 * If the query or path doesn't exist, the updater is skipped.
312 */312 */
313 objArrayRemove<313 objArrayRemove<
...@@ -316,7 +316,7 @@ class TanstackQueryOptimisticHelpers {...@@ -316,7 +316,7 @@ class TanstackQueryOptimisticHelpers {
316 >(316 >(
317 queryKey: QueryKeyAndFn<Data>,317 queryKey: QueryKeyAndFn<Data>,
318 path: Path,318 path: Path,
319 predicate: (319 filter: (
320 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,320 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
321 index: number,321 index: number,
322 ) => boolean,322 ) => boolean,
...@@ -326,7 +326,7 @@ class TanstackQueryOptimisticHelpers {...@@ -326,7 +326,7 @@ class TanstackQueryOptimisticHelpers {
326 const { value: original, exists } = getPath(prev, path);326 const { value: original, exists } = getPath(prev, path);
327 if (!exists || !Array.isArray(original)) return;327 if (!exists || !Array.isArray(original)) return;
328328
329 const newArray = original.filter((item, index) => !predicate(item, index));329 const newArray = original.filter((item, index) => !filter(item, index));
330 this.#set(330 this.#set(
331 queryKey,331 queryKey,
332 (obj) => obj ? setPath(obj, path, newArray as any) : obj,332 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
...@@ -347,13 +347,15 @@ class TanstackQueryOptimisticHelpers {...@@ -347,13 +347,15 @@ class TanstackQueryOptimisticHelpers {
347 >(347 >(
348 queryKey: QueryKeyAndFn<Data>,348 queryKey: QueryKeyAndFn<Data>,
349 path: Path,349 path: Path,
350 predicate: (350 { filter, update }: {
351 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,351 filter: (
352 index: number,352 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
353 ) => boolean,353 index: number,
354 updater: (354 ) => boolean;
355 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,355 update: (
356 ) => GetObjectPath<Data, Path> extends Array<infer T> ? T : never,356 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
357 ) => GetObjectPath<Data, Path> extends Array<infer T> ? T : never;
358 },
357 ) {359 ) {
358 const prev = this.#get(queryKey);360 const prev = this.#get(queryKey);
359 if (!prev) return;361 if (!prev) return;
...@@ -361,7 +363,7 @@ class TanstackQueryOptimisticHelpers {...@@ -361,7 +363,7 @@ class TanstackQueryOptimisticHelpers {
361 if (!exists || !Array.isArray(original)) return;363 if (!exists || !Array.isArray(original)) return;
362364
363 const newArray = original.map((item, index) =>365 const newArray = original.map((item, index) =>
364 predicate(item, index) ? updater(item) : item366 filter(item, index) ? update(item) : item
365 );367 );
366 this.#set(368 this.#set(
367 queryKey,369 queryKey,
...@@ -444,12 +446,12 @@ class TanstackQueryOptimisticHelpers {...@@ -444,12 +446,12 @@ class TanstackQueryOptimisticHelpers {
444 }446 }
445447
446 /**448 /**
447 * Remove items from an array that match a predicate.449 * Remove items from an array that match a `filter`.
448 * If the query or path doesn't exist, the updater is skipped.450 * If the query or path doesn't exist, the updater is skipped.
449 */451 */
450 arrayRemove<Data>(452 arrayRemove<Data>(
451 queryKey: QueryKeyAndFn<Data[]>,453 queryKey: QueryKeyAndFn<Data[]>,
452 predicate: (454 filter: (
453 item: Data,455 item: Data,
454 index: number,456 index: number,
455 ) => boolean,457 ) => boolean,
...@@ -457,7 +459,7 @@ class TanstackQueryOptimisticHelpers {...@@ -457,7 +459,7 @@ class TanstackQueryOptimisticHelpers {
457 const prev = this.#get(queryKey);459 const prev = this.#get(queryKey);
458 if (!prev || !Array.isArray(prev)) return;460 if (!prev || !Array.isArray(prev)) return;
459461
460 const newArray = prev.filter((item, index) => !predicate(item, index));462 const newArray = prev.filter((item, index) => !filter(item, index));
461 this.#set(queryKey, newArray);463 this.#set(queryKey, newArray);
462 this.#onRestore(() => {464 this.#onRestore(() => {
463 // TODO: splice items back in case original changed465 // TODO: splice items back in case original changed
...@@ -466,22 +468,24 @@ class TanstackQueryOptimisticHelpers {...@@ -466,22 +468,24 @@ class TanstackQueryOptimisticHelpers {
466 }468 }
467469
468 /**470 /**
469 * Update items in an array that match a predicate.471 * Update items in an array that match a `filter`.
470 * If the query or path doesn't exist, the updater is skipped.472 * If the query or path doesn't exist, the updater is skipped.
471 */473 */
472 arrayUpdate<Data>(474 arrayUpdate<Data>(
473 queryKey: QueryKeyAndFn<Data[]>,475 queryKey: QueryKeyAndFn<Data[]>,
474 predicate: (476 {
475 item: Data,477 filter,
476 index: number,478 update,
477 ) => boolean,479 }: {
478 updater: (item: Data) => Data,480 filter?: (item: Data, index: number) => boolean;
481 update: (item: Data) => Data;
482 },
479 ) {483 ) {
480 const prev = this.#get(queryKey);484 const prev = this.#get(queryKey);
481 if (!prev || !Array.isArray(prev)) return;485 if (!prev || !Array.isArray(prev)) return;
482486
483 const newArray = prev.map((item, index) =>487 const newArray = prev.map((item, index) =>
484 predicate(item, index) ? updater(item) : item488 (filter ? filter(item, index) : true) ? update(item) : item
485 );489 );
486 this.#set(queryKey, newArray);490 this.#set(queryKey, newArray);
487 this.#onRestore(() => {491 this.#onRestore(() => {
src/types.ts+4
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import type { MutationClient } from "./client.ts";
2
1export interface Mutation<Args extends unknown[], Result> {3export interface Mutation<Args extends unknown[], Result> {
2 /** Calling the mutation. Errors are turned into UI toasts. */4 /** Calling the mutation. Errors are turned into UI toasts. */
3 run(...args: Args): void;5 run(...args: Args): void;
...@@ -12,6 +14,8 @@ export interface Mutation<Args extends unknown[], Result> {...@@ -12,6 +14,8 @@ export interface Mutation<Args extends unknown[], Result> {
12 cb: (update: MutationEvent<Result>) => void,14 cb: (update: MutationEvent<Result>) => void,
13 ): () => void;15 ): () => void;
14 describe(...args: Args): string;16 describe(...args: Args): string;
17 describeResult?: (args: Args, result: Result) => string | undefined;
18 client: MutationClient<object, object>;
15}19}
1620
17export interface MutationEvent<Result> {21export interface MutationEvent<Result> {
test/batch.test.ts+3-3
...@@ -31,7 +31,7 @@ function createTestClient() {...@@ -31,7 +31,7 @@ function createTestClient() {
31 },31 },
32 };32 };
33 },33 },
34 reportError(error) {34 reportError(message, error) {
35 errors.push(error);35 errors.push(error);
36 },36 },
37 });37 });
...@@ -472,7 +472,7 @@ test("BatchMutation - uses deepEquals for comparison", async () => {...@@ -472,7 +472,7 @@ test("BatchMutation - uses deepEquals for comparison", async () => {
472 },472 },
473 };473 };
474 },474 },
475 reportError(error) {475 reportError(message, error) {
476 errors.push(error);476 errors.push(error);
477 },477 },
478 });478 });
...@@ -518,7 +518,7 @@ test("BatchMutation - custom deepEquals function", async () => {...@@ -518,7 +518,7 @@ test("BatchMutation - custom deepEquals function", async () => {
518 },518 },
519 };519 };
520 },520 },
521 reportError(error) {521 reportError(message, error) {
522 errors.push(error);522 errors.push(error);
523 },523 },
524 deepEquals(a, b) {524 deepEquals(a, b) {
test/object-path-types.test.ts+6-10
...@@ -3,16 +3,13 @@...@@ -3,16 +3,13 @@
3 * These tests verify that TypeScript types work correctly at compile time3 * These tests verify that TypeScript types work correctly at compile time
4 */4 */
55
6import type {6import type { AllObjectPaths, GetObjectPath } from "../src/object-path.ts";
7 AllObjectPaths,
8 GetObjectPath,
9} from "../src/object-path.ts";
107
11// Type testing utilities8// Type testing utilities
12type Expect<T extends true> = T;9type Expect<T extends true> = T;
13type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y10type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends
14 ? 111 <T>() => T extends Y ? 1
15 : 2 ? true12 : 2 ? true
16 : false;13 : false;
17type NotEqual<X, Y> = Equal<X, Y> extends true ? false : true;14type NotEqual<X, Y> = Equal<X, Y> extends true ? false : true;
18type IsAny<T> = 0 extends 1 & T ? true : false;15type IsAny<T> = 0 extends 1 & T ? true : false;
...@@ -348,7 +345,7 @@ type ArrayRemoveSignature<...@@ -348,7 +345,7 @@ type ArrayRemoveSignature<
348 Data extends object,345 Data extends object,
349 Path extends AllObjectPaths<Data>,346 Path extends AllObjectPaths<Data>,
350> = GetObjectPath<Data, Path> extends readonly (infer T)[]347> = GetObjectPath<Data, Path> extends readonly (infer T)[]
351 ? (path: Path, predicate: (item: T, index: number) => boolean) => void348 ? (path: Path, filter: (item: T, index: number) => boolean) => void
352 : never;349 : never;
353350
354declare const arrayRemoveItems: ArrayRemoveSignature<TestData, ["items"]>;351declare const arrayRemoveItems: ArrayRemoveSignature<TestData, ["items"]>;
...@@ -379,8 +376,7 @@ type IncrementNameTest = Expect<...@@ -379,8 +376,7 @@ type IncrementNameTest = Expect<
379type ToggleSignature<376type ToggleSignature<
380 Data extends object,377 Data extends object,
381 Path extends AllObjectPaths<Data>,378 Path extends AllObjectPaths<Data>,
382> = GetObjectPath<Data, Path> extends boolean379> = GetObjectPath<Data, Path> extends boolean ? (path: Path) => void
383 ? (path: Path) => void
384 : never;380 : never;
385381
386declare const toggle: ToggleSignature<TestData, ["active"]>;382declare const toggle: ToggleSignature<TestData, ["active"]>;
test/queued.test.ts+1-1
...@@ -16,7 +16,7 @@ function createTestClient() {...@@ -16,7 +16,7 @@ function createTestClient() {
16 },16 },
17 };17 };
18 },18 },
19 reportError(error) {19 reportError(message, error) {
20 errors.push(error);20 errors.push(error);
21 },21 },
22 });22 });
test/tanstack-query-helpers.test.ts+12-6
...@@ -586,8 +586,10 @@ test("arrayUpdateItem - should update items matching predicate", () => {...@@ -586,8 +586,10 @@ test("arrayUpdateItem - should update items matching predicate", () => {
586 helpers.objArrayUpdate(586 helpers.objArrayUpdate(
587 queryTest,587 queryTest,
588 ["items"],588 ["items"],
589 (item) => item.id === 2,589 {
590 (item) => ({ ...item, label: "UPDATED" }),590 filter: (item) => item.id === 2,
591 update: (item) => ({ ...item, label: "UPDATED" }),
592 },
591 );593 );
592594
593 const result = client.getQueryData<TestData>(queryTest.queryKey);595 const result = client.getQueryData<TestData>(queryTest.queryKey);
...@@ -611,8 +613,10 @@ test("arrayUpdateItem - should update multiple items", () => {...@@ -611,8 +613,10 @@ test("arrayUpdateItem - should update multiple items", () => {
611 helpers.objArrayUpdate(613 helpers.objArrayUpdate(
612 queryTest,614 queryTest,
613 ["items"],615 ["items"],
614 (item) => item.id > 1,616 {
615 (item) => ({ ...item, label: item.label.toUpperCase() }),617 filter: (item) => item.id > 1,
618 update: (item) => ({ ...item, label: item.label.toUpperCase() }),
619 },
616 );620 );
617621
618 const result = client.getQueryData<TestData>(queryTest.queryKey);622 const result = client.getQueryData<TestData>(queryTest.queryKey);
...@@ -632,8 +636,10 @@ test("arrayUpdateItem - predicate receives index", () => {...@@ -632,8 +636,10 @@ test("arrayUpdateItem - predicate receives index", () => {
632 helpers.objArrayUpdate(636 helpers.objArrayUpdate(
633 queryTest,637 queryTest,
634 ["items"],638 ["items"],
635 (_item, index) => index === 0,639 {
636 (item) => ({ ...item, label: "FIRST" }),640 filter: (_item, index) => index === 0,
641 update: (item) => ({ ...item, label: "FIRST" }),
642 },
637 );643 );
638644
639 const result = client.getQueryData<TestData>(queryTest.queryKey);645 const result = client.getQueryData<TestData>(queryTest.queryKey);