authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-23 17:48:03-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-27 18:45:35-08:00
logfd7e4484d26ac5e095cb39bfb2bfc17fe6527d43
treeb675cb4f43ce26262714e7fcf4ed6bfe2bcf7ed3
parentaf2a34170fc51b625182c42987277eec81558c1e
signaturelock-open Commit is signed but in an unrecognized format.

feat: the library mostly exists now


29 files changed, 6263 insertions(+), 625 deletions(-)

.gitignore+1
...@@ -1,2 +1,3 @@...@@ -1,2 +1,3 @@
1node_modules1node_modules
2coverage2coverage
3*.tsbuildinfo
.npmrc created+1
...@@ -0,0 +1 @@
1@jsr:registry=https://npm.jsr.io
example/index.html created+12
...@@ -0,0 +1,12 @@
1<!DOCTYPE html>
2<html lang="en">
3 <head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <title>React Mutation Demo</title>
7 </head>
8 <body>
9 <div id="root"></div>
10 <script type="module" src="./src/main.tsx"></script>
11 </body>
12</html>
example/package.json created+19
...@@ -0,0 +1,19 @@
1{
2 "private": true,
3 "type": "module",
4 "scripts": {
5 "dev": "vite"
6 },
7 "dependencies": {
8 "@tanstack/react-query": "^5.90.20",
9 "react": "^19.2.4",
10 "react-dom": "^19.2.4"
11 },
12 "devDependencies": {
13 "@types/node": "^24.10.1",
14 "@types/react": "^19.2.5",
15 "@types/react-dom": "^19.2.3",
16 "@vitejs/plugin-react": "^5.1.1",
17 "vite": "^7.2.4"
18 }
19}
example/src/App.tsx created+137
...@@ -0,0 +1,137 @@
1import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2import {
3 createMutationButton,
4 MutationClient,
5 queryClientOptimisticHelpers,
6 useMutation,
7} from "@clo/react-mutation";
8import { queryOptions as queryOptions } from "@tanstack/react-query";
9import { useSuspenseQuery } from "@tanstack/react-query";
10import { QueryKeyAndFn } from "../../src/tanstack-query.ts";
11
12const queryClient = new QueryClient();
13const mutationClient = new MutationClient({
14 context: {
15 client: queryClient,
16 get<T>(q: QueryKeyAndFn<T>): T | undefined {
17 return queryClient.getQueryData<T>(q.queryKey);
18 },
19 },
20 getOptimisticHelpers: queryClientOptimisticHelpers(queryClient),
21 reportError(error: unknown) {
22 console.error("Mutation error:", error);
23 },
24});
25
26// React query stuff
27let backendValue = 0;
28const queryCounter = queryOptions({
29 queryKey: ["demo"],
30 queryFn: () => ({ count: backendValue }),
31 staleTime: Infinity,
32});
33
34// Define a simple mutation
35// const mutIncrement = mutationClient.queuedMutation({
36// async mutate(amount: number) {
37// // Simulate API call
38// await new Promise((resolve) => setTimeout(resolve, 500));
39// if (Math.random() < 0.1) throw new Error("Random error occurred!");
40// backendValue += amount;
41
42// return amount;
43// },
44// describe: "Increment counter",
45// optimistic({ helpers, args: [amount] }) {
46// helpers.objSet(queryCounter, ["count"], (n) => n + amount);
47// },
48// async refetch({ client }) {
49// await client.invalidateQueries(queryCounter);
50// },
51// });
52const mutIncrement = mutationClient.defineBatched({
53 mode: "debounce",
54 time: 200,
55
56 optimistic({ helpers }, amount: number) {
57 helpers.objIncrement(queryCounter, ["count"], amount);
58 },
59 key: () => "counter",
60 getValue: ({ get }) => get(queryCounter)?.count ?? 0,
61
62 async commit({ initial, current }) {
63 const delta = current - initial;
64 // Simulate API call
65 await new Promise((resolve) => setTimeout(resolve, 500));
66 if (Math.random() < 0.1) throw new Error("Random error occurred!");
67 backendValue += delta;
68 return delta;
69 },
70
71 describe: "update counter",
72});
73
74function CustomButton(
75 { onClick, isPending, ...rest }: {
76 onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
77 isPending: boolean;
78 children: React.ReactNode;
79 className?: string;
80 },
81) {
82 return (
83 <button
84 {...rest}
85 onClick={onClick}
86 disabled={isPending}
87 />
88 );
89}
90
91const MutationButton = createMutationButton(CustomButton);
92
93function Counter() {
94 const { data: { count } } = useSuspenseQuery(queryCounter);
95 const mutation = useMutation(mutIncrement);
96
97 return (
98 <div className="counter-card">
99 <div className="counter-display">
100 <p className="count">Count: {count}</p>
101 </div>
102
103 <div className="mutation-info">
104 <pre><code>{JSON.stringify(mutation, null, 2)}</code></pre>
105 </div>
106
107 <div className="button-group">
108 <MutationButton
109 mutation={mutation}
110 args={[1]}
111 className="primary-button"
112 >
113 Increment +1
114 </MutationButton>
115 <MutationButton
116 mutation={mutation}
117 args={[-1]}
118 className="primary-button"
119 >
120 Decrement -1
121 </MutationButton>
122 </div>
123 </div>
124 );
125}
126
127function App() {
128 return (
129 <QueryClientProvider client={queryClient}>
130 <div className="app">
131 <Counter />
132 </div>
133 </QueryClientProvider>
134 );
135}
136
137export default App;
example/src/index.css created+198
...@@ -0,0 +1,198 @@
1:root {
2 font-family: system-ui, sans-serif;
3 line-height: 1.5;
4 font-weight: 400;
5
6 color-scheme: dark;
7 color: rgba(255, 255, 255, 0.87);
8 background-color: #242424;
9
10 --primary: #646cff;
11 --primary-hover: #535bf2;
12 --success: #4ade80;
13 --error: #ef4444;
14 --warning: #f59e0b;
15 --bg: #242424;
16 --surface: #1a1a1a;
17 --text: rgba(255, 255, 255, 0.87);
18 --text-muted: rgba(255, 255, 255, 0.6);
19}
20
21body {
22 margin: 0;
23 display: flex;
24 place-items: center;
25 min-width: 320px;
26 min-height: 100vh;
27}
28
29#root {
30 width: 100%;
31}
32
33* {
34 box-sizing: border-box;
35}
36.app {
37 max-width: 800px;
38 margin: 0 auto;
39 padding: 2rem;
40 text-align: center;
41}
42
43h1 {
44 font-size: 3.2em;
45 line-height: 1.1;
46 margin-bottom: 0.5rem;
47}
48
49.subtitle {
50 color: var(--text-muted);
51 margin-bottom: 2rem;
52}
53
54.counter-card {
55 background: var(--surface);
56 border-radius: 12px;
57 padding: 2rem;
58 box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
59}
60
61.counter-display {
62 margin: 2rem 0;
63}
64
65.count {
66 font-size: 4rem;
67 font-weight: bold;
68 margin: 0;
69 color: var(--primary);
70}
71
72.mutation-info {
73 margin: 2rem 0;
74 min-height: 100px;
75 text-align: left;
76}
77
78.status-badges {
79 display: flex;
80 gap: 0.5rem;
81 justify-content: center;
82 flex-wrap: wrap;
83 margin-bottom: 1rem;
84}
85
86.badge {
87 padding: 0.25rem 0.75rem;
88 border-radius: 12px;
89 font-size: 0.875rem;
90 font-weight: 500;
91}
92
93.badge.idle {
94 background: rgba(148, 163, 184, 0.2);
95 color: #94a3b8;
96}
97
98.badge.mutating {
99 background: rgba(245, 158, 11, 0.2);
100 color: var(--warning);
101}
102
103.badge.success {
104 background: rgba(74, 222, 128, 0.2);
105 color: var(--success);
106}
107
108.badge.error {
109 background: rgba(239, 68, 68, 0.2);
110 color: var(--error);
111}
112
113.badge.pending {
114 background: rgba(100, 108, 255, 0.2);
115 color: var(--primary);
116 animation: pulse 2s infinite;
117}
118
119.badge.optimistic {
120 background: rgba(168, 85, 247, 0.2);
121 color: #a855f7;
122}
123
124.success-message {
125 padding: 1rem;
126 background: rgba(74, 222, 128, 0.1);
127 border: 1px solid rgba(74, 222, 128, 0.3);
128 border-radius: 8px;
129 color: var(--success);
130 margin-top: 1rem;
131}
132
133.error-message {
134 padding: 1rem;
135 background: rgba(239, 68, 68, 0.1);
136 border: 1px solid rgba(239, 68, 68, 0.3);
137 border-radius: 8px;
138 color: var(--error);
139 margin-top: 1rem;
140}
141
142.button-group {
143 display: flex;
144 gap: 1rem;
145 justify-content: center;
146 flex-wrap: wrap;
147}
148
149button {
150 border-radius: 8px;
151 border: 1px solid transparent;
152 padding: 0.6em 1.2em;
153 font-size: 1em;
154 font-weight: 500;
155 font-family: inherit;
156 cursor: pointer;
157 transition: all 0.25s;
158}
159
160.primary-button,
161.mutation-button {
162 background-color: var(--primary);
163 color: white;
164}
165
166.primary-button:hover:not(:disabled),
167.mutation-button:hover:not(:disabled) {
168 background-color: var(--primary-hover);
169}
170
171.secondary-button {
172 background-color: transparent;
173 border-color: var(--text-muted);
174 color: var(--text);
175}
176
177.secondary-button:hover:not(:disabled) {
178 border-color: var(--primary);
179}
180
181button:disabled {
182 opacity: 0.6;
183 cursor: not-allowed;
184}
185
186button:focus,
187button:focus-visible {
188 outline: 4px auto -webkit-focus-ring-color;
189}
190
191@keyframes pulse {
192 0%, 100% {
193 opacity: 1;
194 }
195 50% {
196 opacity: 0.5;
197 }
198}
example/src/main.tsx created+10
...@@ -0,0 +1,10 @@
1import { StrictMode } from "react";
2import { createRoot } from "react-dom/client";
3import App from "./App.tsx";
4import "./index.css";
5
6createRoot(document.getElementById("root")!).render(
7 <StrictMode>
8 <App />
9 </StrictMode>,
10);
example/tsconfig.json created+25
...@@ -0,0 +1,25 @@
1{
2 "compilerOptions": {
3 "target": "ESNext",
4 "module": "NodeNext",
5 "lib": ["ESNext", "DOM"],
6 "rootDir": "../",
7 "strict": true,
8 "esModuleInterop": true,
9 "skipLibCheck": true,
10 "forceConsistentCasingInFileNames": true,
11 "declaration": true,
12 "declarationMap": true,
13 "sourceMap": true,
14 "moduleResolution": "nodenext",
15 "moduleDetection": "force",
16 "noEmit": true,
17 "allowImportingTsExtensions": true,
18 "jsx": "react-jsx",
19 "paths": {
20 "@clo/react-mutation": ["../src/index.ts"]
21 }
22 },
23 "include": ["src/**/*"],
24 "exclude": ["node_modules"]
25}
example/vite.config.ts created+13
...@@ -0,0 +1,13 @@
1import { defineConfig } from "vite";
2import react from "@vitejs/plugin-react";
3import path from "node:path";
4
5// https://vite.dev/config/
6export default defineConfig({
7 plugins: [react()],
8 resolve: {
9 alias: {
10 "@clo/react-mutation": path.resolve(__dirname, "../src"),
11 },
12 },
13});
package-lock.json+699-335
...@@ -1,17 +1,178 @@...@@ -1,17 +1,178 @@
1{1{
2 "name": "react-mutation",2 "name": "@clo/react-mutation",
3 "version": "1.0.0",3 "version": "0.0.0",
4 "lockfileVersion": 3,4 "lockfileVersion": 3,
5 "requires": true,5 "requires": true,
6 "packages": {6 "packages": {
7 "": {7 "": {
8 "name": "@clo/react-mutation",
9 "version": "0.0.0",
10 "license": "ISC",
11 "dependencies": {
12 "@std/assert": "npm:@jsr/std__assert@^1.0.17"
13 },
8 "devDependencies": {14 "devDependencies": {
9 "@tanstack/react-query": "^5.90.20",15 "@types/node": "^24.10.1",
10 "@types/react": "^19.2.9",16 "@types/react": "^19.2.10",
11 "@vitest/coverage-v8": "^4.0.18",17 "@types/react-dom": "^19.2.3",
12 "@vitest/ui": "^4.0.18",18 "@vitejs/plugin-react": "^5.1.1",
13 "react": "^19.2.3",19 "react": "^19.2.4",
20 "react-dom": "^19.2.4",
21 "vite": "^7.2.4",
14 "vitest": "^4.0.18"22 "vitest": "^4.0.18"
23 },
24 "peerDependencies": {
25 "@tanstack/react-query": "*",
26 "@types/react": "*",
27 "react": "*"
28 },
29 "peerDependenciesMeta": {
30 "@tanstack/react-query": {
31 "optional": true
32 }
33 }
34 },
35 "node_modules/@babel/code-frame": {
36 "version": "7.28.6",
37 "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
38 "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==",
39 "dev": true,
40 "license": "MIT",
41 "dependencies": {
42 "@babel/helper-validator-identifier": "^7.28.5",
43 "js-tokens": "^4.0.0",
44 "picocolors": "^1.1.1"
45 },
46 "engines": {
47 "node": ">=6.9.0"
48 }
49 },
50 "node_modules/@babel/compat-data": {
51 "version": "7.28.6",
52 "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz",
53 "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==",
54 "dev": true,
55 "license": "MIT",
56 "engines": {
57 "node": ">=6.9.0"
58 }
59 },
60 "node_modules/@babel/core": {
61 "version": "7.28.6",
62 "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
63 "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
64 "dev": true,
65 "license": "MIT",
66 "peer": true,
67 "dependencies": {
68 "@babel/code-frame": "^7.28.6",
69 "@babel/generator": "^7.28.6",
70 "@babel/helper-compilation-targets": "^7.28.6",
71 "@babel/helper-module-transforms": "^7.28.6",
72 "@babel/helpers": "^7.28.6",
73 "@babel/parser": "^7.28.6",
74 "@babel/template": "^7.28.6",
75 "@babel/traverse": "^7.28.6",
76 "@babel/types": "^7.28.6",
77 "@jridgewell/remapping": "^2.3.5",
78 "convert-source-map": "^2.0.0",
79 "debug": "^4.1.0",
80 "gensync": "^1.0.0-beta.2",
81 "json5": "^2.2.3",
82 "semver": "^6.3.1"
83 },
84 "engines": {
85 "node": ">=6.9.0"
86 },
87 "funding": {
88 "type": "opencollective",
89 "url": "https://opencollective.com/babel"
90 }
91 },
92 "node_modules/@babel/generator": {
93 "version": "7.28.6",
94 "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz",
95 "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==",
96 "dev": true,
97 "license": "MIT",
98 "dependencies": {
99 "@babel/parser": "^7.28.6",
100 "@babel/types": "^7.28.6",
101 "@jridgewell/gen-mapping": "^0.3.12",
102 "@jridgewell/trace-mapping": "^0.3.28",
103 "jsesc": "^3.0.2"
104 },
105 "engines": {
106 "node": ">=6.9.0"
107 }
108 },
109 "node_modules/@babel/helper-compilation-targets": {
110 "version": "7.28.6",
111 "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
112 "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
113 "dev": true,
114 "license": "MIT",
115 "dependencies": {
116 "@babel/compat-data": "^7.28.6",
117 "@babel/helper-validator-option": "^7.27.1",
118 "browserslist": "^4.24.0",
119 "lru-cache": "^5.1.1",
120 "semver": "^6.3.1"
121 },
122 "engines": {
123 "node": ">=6.9.0"
124 }
125 },
126 "node_modules/@babel/helper-globals": {
127 "version": "7.28.0",
128 "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
129 "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
130 "dev": true,
131 "license": "MIT",
132 "engines": {
133 "node": ">=6.9.0"
134 }
135 },
136 "node_modules/@babel/helper-module-imports": {
137 "version": "7.28.6",
138 "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
139 "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
140 "dev": true,
141 "license": "MIT",
142 "dependencies": {
143 "@babel/traverse": "^7.28.6",
144 "@babel/types": "^7.28.6"
145 },
146 "engines": {
147 "node": ">=6.9.0"
148 }
149 },
150 "node_modules/@babel/helper-module-transforms": {
151 "version": "7.28.6",
152 "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
153 "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
154 "dev": true,
155 "license": "MIT",
156 "dependencies": {
157 "@babel/helper-module-imports": "^7.28.6",
158 "@babel/helper-validator-identifier": "^7.28.5",
159 "@babel/traverse": "^7.28.6"
160 },
161 "engines": {
162 "node": ">=6.9.0"
163 },
164 "peerDependencies": {
165 "@babel/core": "^7.0.0"
166 }
167 },
168 "node_modules/@babel/helper-plugin-utils": {
169 "version": "7.28.6",
170 "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
171 "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
172 "dev": true,
173 "license": "MIT",
174 "engines": {
175 "node": ">=6.9.0"
15 }176 }
16 },177 },
17 "node_modules/@babel/helper-string-parser": {178 "node_modules/@babel/helper-string-parser": {
...@@ -34,6 +195,30 @@...@@ -34,6 +195,30 @@
34 "node": ">=6.9.0"195 "node": ">=6.9.0"
35 }196 }
36 },197 },
198 "node_modules/@babel/helper-validator-option": {
199 "version": "7.27.1",
200 "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
201 "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
202 "dev": true,
203 "license": "MIT",
204 "engines": {
205 "node": ">=6.9.0"
206 }
207 },
208 "node_modules/@babel/helpers": {
209 "version": "7.28.6",
210 "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
211 "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
212 "dev": true,
213 "license": "MIT",
214 "dependencies": {
215 "@babel/template": "^7.28.6",
216 "@babel/types": "^7.28.6"
217 },
218 "engines": {
219 "node": ">=6.9.0"
220 }
221 },
37 "node_modules/@babel/parser": {222 "node_modules/@babel/parser": {
38 "version": "7.28.6",223 "version": "7.28.6",
39 "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",224 "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
...@@ -50,28 +235,84 @@...@@ -50,28 +235,84 @@
50 "node": ">=6.0.0"235 "node": ">=6.0.0"
51 }236 }
52 },237 },
53 "node_modules/@babel/types": {238 "node_modules/@babel/plugin-transform-react-jsx-self": {
239 "version": "7.27.1",
240 "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
241 "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
242 "dev": true,
243 "license": "MIT",
244 "dependencies": {
245 "@babel/helper-plugin-utils": "^7.27.1"
246 },
247 "engines": {
248 "node": ">=6.9.0"
249 },
250 "peerDependencies": {
251 "@babel/core": "^7.0.0-0"
252 }
253 },
254 "node_modules/@babel/plugin-transform-react-jsx-source": {
255 "version": "7.27.1",
256 "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
257 "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
258 "dev": true,
259 "license": "MIT",
260 "dependencies": {
261 "@babel/helper-plugin-utils": "^7.27.1"
262 },
263 "engines": {
264 "node": ">=6.9.0"
265 },
266 "peerDependencies": {
267 "@babel/core": "^7.0.0-0"
268 }
269 },
270 "node_modules/@babel/template": {
54 "version": "7.28.6",271 "version": "7.28.6",
55 "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",272 "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
56 "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",273 "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
57 "dev": true,274 "dev": true,
58 "license": "MIT",275 "license": "MIT",
59 "dependencies": {276 "dependencies": {
60 "@babel/helper-string-parser": "^7.27.1",277 "@babel/code-frame": "^7.28.6",
61 "@babel/helper-validator-identifier": "^7.28.5"278 "@babel/parser": "^7.28.6",
279 "@babel/types": "^7.28.6"
62 },280 },
63 "engines": {281 "engines": {
64 "node": ">=6.9.0"282 "node": ">=6.9.0"
65 }283 }
66 },284 },
67 "node_modules/@bcoe/v8-coverage": {285 "node_modules/@babel/traverse": {
68 "version": "1.0.2",286 "version": "7.28.6",
69 "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",287 "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz",
70 "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",288 "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==",
71 "dev": true,289 "dev": true,
72 "license": "MIT",290 "license": "MIT",
291 "dependencies": {
292 "@babel/code-frame": "^7.28.6",
293 "@babel/generator": "^7.28.6",
294 "@babel/helper-globals": "^7.28.0",
295 "@babel/parser": "^7.28.6",
296 "@babel/template": "^7.28.6",
297 "@babel/types": "^7.28.6",
298 "debug": "^4.3.1"
299 },
73 "engines": {300 "engines": {
74 "node": ">=18"301 "node": ">=6.9.0"
302 }
303 },
304 "node_modules/@babel/types": {
305 "version": "7.28.6",
306 "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
307 "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
308 "dev": true,
309 "license": "MIT",
310 "dependencies": {
311 "@babel/helper-string-parser": "^7.27.1",
312 "@babel/helper-validator-identifier": "^7.28.5"
313 },
314 "engines": {
315 "node": ">=6.9.0"
75 }316 }
76 },317 },
77 "node_modules/@esbuild/aix-ppc64": {318 "node_modules/@esbuild/aix-ppc64": {
...@@ -516,6 +757,28 @@...@@ -516,6 +757,28 @@
516 "node": ">=18"757 "node": ">=18"
517 }758 }
518 },759 },
760 "node_modules/@jridgewell/gen-mapping": {
761 "version": "0.3.13",
762 "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
763 "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
764 "dev": true,
765 "license": "MIT",
766 "dependencies": {
767 "@jridgewell/sourcemap-codec": "^1.5.0",
768 "@jridgewell/trace-mapping": "^0.3.24"
769 }
770 },
771 "node_modules/@jridgewell/remapping": {
772 "version": "2.3.5",
773 "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
774 "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
775 "dev": true,
776 "license": "MIT",
777 "dependencies": {
778 "@jridgewell/gen-mapping": "^0.3.5",
779 "@jridgewell/trace-mapping": "^0.3.24"
780 }
781 },
519 "node_modules/@jridgewell/resolve-uri": {782 "node_modules/@jridgewell/resolve-uri": {
520 "version": "3.1.2",783 "version": "3.1.2",
521 "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",784 "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
...@@ -544,17 +807,22 @@...@@ -544,17 +807,22 @@
544 "@jridgewell/sourcemap-codec": "^1.4.14"807 "@jridgewell/sourcemap-codec": "^1.4.14"
545 }808 }
546 },809 },
547 "node_modules/@polka/url": {810 "node_modules/@jsr/std__internal": {
548 "version": "1.0.0-next.29",811 "version": "1.0.12",
549 "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",812 "resolved": "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.12.tgz",
550 "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",813 "integrity": "sha512-6xReMW9p+paJgqoFRpOE2nogJFvzPfaLHLIlyADYjKMUcwDyjKZxryIbgcU+gxiTygn8yCjld1HoI0ET4/iZeA=="
814 },
815 "node_modules/@rolldown/pluginutils": {
816 "version": "1.0.0-beta.53",
817 "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
818 "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==",
551 "dev": true,819 "dev": true,
552 "license": "MIT"820 "license": "MIT"
553 },821 },
554 "node_modules/@rollup/rollup-android-arm-eabi": {822 "node_modules/@rollup/rollup-android-arm-eabi": {
555 "version": "4.56.0",823 "version": "4.57.0",
556 "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz",824 "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz",
557 "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==",825 "integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==",
558 "cpu": [826 "cpu": [
559 "arm"827 "arm"
560 ],828 ],
...@@ -566,9 +834,9 @@...@@ -566,9 +834,9 @@
566 ]834 ]
567 },835 },
568 "node_modules/@rollup/rollup-android-arm64": {836 "node_modules/@rollup/rollup-android-arm64": {
569 "version": "4.56.0",837 "version": "4.57.0",
570 "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz",838 "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz",
571 "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==",839 "integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==",
572 "cpu": [840 "cpu": [
573 "arm64"841 "arm64"
574 ],842 ],
...@@ -580,9 +848,9 @@...@@ -580,9 +848,9 @@
580 ]848 ]
581 },849 },
582 "node_modules/@rollup/rollup-darwin-arm64": {850 "node_modules/@rollup/rollup-darwin-arm64": {
583 "version": "4.56.0",851 "version": "4.57.0",
584 "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz",852 "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz",
585 "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==",853 "integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==",
586 "cpu": [854 "cpu": [
587 "arm64"855 "arm64"
588 ],856 ],
...@@ -594,9 +862,9 @@...@@ -594,9 +862,9 @@
594 ]862 ]
595 },863 },
596 "node_modules/@rollup/rollup-darwin-x64": {864 "node_modules/@rollup/rollup-darwin-x64": {
597 "version": "4.56.0",865 "version": "4.57.0",
598 "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz",866 "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz",
599 "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==",867 "integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==",
600 "cpu": [868 "cpu": [
601 "x64"869 "x64"
602 ],870 ],
...@@ -608,9 +876,9 @@...@@ -608,9 +876,9 @@
608 ]876 ]
609 },877 },
610 "node_modules/@rollup/rollup-freebsd-arm64": {878 "node_modules/@rollup/rollup-freebsd-arm64": {
611 "version": "4.56.0",879 "version": "4.57.0",
612 "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz",880 "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz",
613 "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==",881 "integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==",
614 "cpu": [882 "cpu": [
615 "arm64"883 "arm64"
616 ],884 ],
...@@ -622,9 +890,9 @@...@@ -622,9 +890,9 @@
622 ]890 ]
623 },891 },
624 "node_modules/@rollup/rollup-freebsd-x64": {892 "node_modules/@rollup/rollup-freebsd-x64": {
625 "version": "4.56.0",893 "version": "4.57.0",
626 "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz",894 "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz",
627 "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==",895 "integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==",
628 "cpu": [896 "cpu": [
629 "x64"897 "x64"
630 ],898 ],
...@@ -636,9 +904,9 @@...@@ -636,9 +904,9 @@
636 ]904 ]
637 },905 },
638 "node_modules/@rollup/rollup-linux-arm-gnueabihf": {906 "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
639 "version": "4.56.0",907 "version": "4.57.0",
640 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz",908 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz",
641 "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==",909 "integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==",
642 "cpu": [910 "cpu": [
643 "arm"911 "arm"
644 ],912 ],
...@@ -650,9 +918,9 @@...@@ -650,9 +918,9 @@
650 ]918 ]
651 },919 },
652 "node_modules/@rollup/rollup-linux-arm-musleabihf": {920 "node_modules/@rollup/rollup-linux-arm-musleabihf": {
653 "version": "4.56.0",921 "version": "4.57.0",
654 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz",922 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz",
655 "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==",923 "integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==",
656 "cpu": [924 "cpu": [
657 "arm"925 "arm"
658 ],926 ],
...@@ -664,9 +932,9 @@...@@ -664,9 +932,9 @@
664 ]932 ]
665 },933 },
666 "node_modules/@rollup/rollup-linux-arm64-gnu": {934 "node_modules/@rollup/rollup-linux-arm64-gnu": {
667 "version": "4.56.0",935 "version": "4.57.0",
668 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz",936 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz",
669 "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==",937 "integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==",
670 "cpu": [938 "cpu": [
671 "arm64"939 "arm64"
672 ],940 ],
...@@ -678,9 +946,9 @@...@@ -678,9 +946,9 @@
678 ]946 ]
679 },947 },
680 "node_modules/@rollup/rollup-linux-arm64-musl": {948 "node_modules/@rollup/rollup-linux-arm64-musl": {
681 "version": "4.56.0",949 "version": "4.57.0",
682 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz",950 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz",
683 "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==",951 "integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==",
684 "cpu": [952 "cpu": [
685 "arm64"953 "arm64"
686 ],954 ],
...@@ -692,9 +960,9 @@...@@ -692,9 +960,9 @@
692 ]960 ]
693 },961 },
694 "node_modules/@rollup/rollup-linux-loong64-gnu": {962 "node_modules/@rollup/rollup-linux-loong64-gnu": {
695 "version": "4.56.0",963 "version": "4.57.0",
696 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz",964 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz",
697 "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==",965 "integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==",
698 "cpu": [966 "cpu": [
699 "loong64"967 "loong64"
700 ],968 ],
...@@ -706,9 +974,9 @@...@@ -706,9 +974,9 @@
706 ]974 ]
707 },975 },
708 "node_modules/@rollup/rollup-linux-loong64-musl": {976 "node_modules/@rollup/rollup-linux-loong64-musl": {
709 "version": "4.56.0",977 "version": "4.57.0",
710 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz",978 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz",
711 "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==",979 "integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==",
712 "cpu": [980 "cpu": [
713 "loong64"981 "loong64"
714 ],982 ],
...@@ -720,9 +988,9 @@...@@ -720,9 +988,9 @@
720 ]988 ]
721 },989 },
722 "node_modules/@rollup/rollup-linux-ppc64-gnu": {990 "node_modules/@rollup/rollup-linux-ppc64-gnu": {
723 "version": "4.56.0",991 "version": "4.57.0",
724 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz",992 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz",
725 "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==",993 "integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==",
726 "cpu": [994 "cpu": [
727 "ppc64"995 "ppc64"
728 ],996 ],
...@@ -734,9 +1002,9 @@...@@ -734,9 +1002,9 @@
734 ]1002 ]
735 },1003 },
736 "node_modules/@rollup/rollup-linux-ppc64-musl": {1004 "node_modules/@rollup/rollup-linux-ppc64-musl": {
737 "version": "4.56.0",1005 "version": "4.57.0",
738 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz",1006 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz",
739 "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==",1007 "integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==",
740 "cpu": [1008 "cpu": [
741 "ppc64"1009 "ppc64"
742 ],1010 ],
...@@ -748,9 +1016,9 @@...@@ -748,9 +1016,9 @@
748 ]1016 ]
749 },1017 },
750 "node_modules/@rollup/rollup-linux-riscv64-gnu": {1018 "node_modules/@rollup/rollup-linux-riscv64-gnu": {
751 "version": "4.56.0",1019 "version": "4.57.0",
752 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz",1020 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz",
753 "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==",1021 "integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==",
754 "cpu": [1022 "cpu": [
755 "riscv64"1023 "riscv64"
756 ],1024 ],
...@@ -762,9 +1030,9 @@...@@ -762,9 +1030,9 @@
762 ]1030 ]
763 },1031 },
764 "node_modules/@rollup/rollup-linux-riscv64-musl": {1032 "node_modules/@rollup/rollup-linux-riscv64-musl": {
765 "version": "4.56.0",1033 "version": "4.57.0",
766 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz",1034 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz",
767 "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==",1035 "integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==",
768 "cpu": [1036 "cpu": [
769 "riscv64"1037 "riscv64"
770 ],1038 ],
...@@ -776,9 +1044,9 @@...@@ -776,9 +1044,9 @@
776 ]1044 ]
777 },1045 },
778 "node_modules/@rollup/rollup-linux-s390x-gnu": {1046 "node_modules/@rollup/rollup-linux-s390x-gnu": {
779 "version": "4.56.0",1047 "version": "4.57.0",
780 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz",1048 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz",
781 "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==",1049 "integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==",
782 "cpu": [1050 "cpu": [
783 "s390x"1051 "s390x"
784 ],1052 ],
...@@ -790,9 +1058,9 @@...@@ -790,9 +1058,9 @@
790 ]1058 ]
791 },1059 },
792 "node_modules/@rollup/rollup-linux-x64-gnu": {1060 "node_modules/@rollup/rollup-linux-x64-gnu": {
793 "version": "4.56.0",1061 "version": "4.57.0",
794 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz",1062 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz",
795 "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==",1063 "integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==",
796 "cpu": [1064 "cpu": [
797 "x64"1065 "x64"
798 ],1066 ],
...@@ -804,9 +1072,9 @@...@@ -804,9 +1072,9 @@
804 ]1072 ]
805 },1073 },
806 "node_modules/@rollup/rollup-linux-x64-musl": {1074 "node_modules/@rollup/rollup-linux-x64-musl": {
807 "version": "4.56.0",1075 "version": "4.57.0",
808 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz",1076 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz",
809 "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==",1077 "integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==",
810 "cpu": [1078 "cpu": [
811 "x64"1079 "x64"
812 ],1080 ],
...@@ -818,9 +1086,9 @@...@@ -818,9 +1086,9 @@
818 ]1086 ]
819 },1087 },
820 "node_modules/@rollup/rollup-openbsd-x64": {1088 "node_modules/@rollup/rollup-openbsd-x64": {
821 "version": "4.56.0",1089 "version": "4.57.0",
822 "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz",1090 "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz",
823 "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==",1091 "integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==",
824 "cpu": [1092 "cpu": [
825 "x64"1093 "x64"
826 ],1094 ],
...@@ -832,9 +1100,9 @@...@@ -832,9 +1100,9 @@
832 ]1100 ]
833 },1101 },
834 "node_modules/@rollup/rollup-openharmony-arm64": {1102 "node_modules/@rollup/rollup-openharmony-arm64": {
835 "version": "4.56.0",1103 "version": "4.57.0",
836 "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz",1104 "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz",
837 "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==",1105 "integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==",
838 "cpu": [1106 "cpu": [
839 "arm64"1107 "arm64"
840 ],1108 ],
...@@ -846,9 +1114,9 @@...@@ -846,9 +1114,9 @@
846 ]1114 ]
847 },1115 },
848 "node_modules/@rollup/rollup-win32-arm64-msvc": {1116 "node_modules/@rollup/rollup-win32-arm64-msvc": {
849 "version": "4.56.0",1117 "version": "4.57.0",
850 "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz",1118 "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz",
851 "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==",1119 "integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==",
852 "cpu": [1120 "cpu": [
853 "arm64"1121 "arm64"
854 ],1122 ],
...@@ -860,9 +1128,9 @@...@@ -860,9 +1128,9 @@
860 ]1128 ]
861 },1129 },
862 "node_modules/@rollup/rollup-win32-ia32-msvc": {1130 "node_modules/@rollup/rollup-win32-ia32-msvc": {
863 "version": "4.56.0",1131 "version": "4.57.0",
864 "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz",1132 "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz",
865 "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==",1133 "integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==",
866 "cpu": [1134 "cpu": [
867 "ia32"1135 "ia32"
868 ],1136 ],
...@@ -874,9 +1142,9 @@...@@ -874,9 +1142,9 @@
874 ]1142 ]
875 },1143 },
876 "node_modules/@rollup/rollup-win32-x64-gnu": {1144 "node_modules/@rollup/rollup-win32-x64-gnu": {
877 "version": "4.56.0",1145 "version": "4.57.0",
878 "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz",1146 "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz",
879 "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==",1147 "integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==",
880 "cpu": [1148 "cpu": [
881 "x64"1149 "x64"
882 ],1150 ],
...@@ -888,9 +1156,9 @@...@@ -888,9 +1156,9 @@
888 ]1156 ]
889 },1157 },
890 "node_modules/@rollup/rollup-win32-x64-msvc": {1158 "node_modules/@rollup/rollup-win32-x64-msvc": {
891 "version": "4.56.0",1159 "version": "4.57.0",
892 "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz",1160 "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz",
893 "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==",1161 "integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==",
894 "cpu": [1162 "cpu": [
895 "x64"1163 "x64"
896 ],1164 ],
...@@ -908,32 +1176,58 @@...@@ -908,32 +1176,58 @@
908 "dev": true,1176 "dev": true,
909 "license": "MIT"1177 "license": "MIT"
910 },1178 },
911 "node_modules/@tanstack/query-core": {1179 "node_modules/@std/assert": {
912 "version": "5.90.20",1180 "name": "@jsr/std__assert",
913 "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",1181 "version": "1.0.17",
914 "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",1182 "resolved": "https://npm.jsr.io/~/11/@jsr/std__assert/1.0.17.tgz",
1183 "integrity": "sha512-3bJMMq3hoiKmC9UmxytkzKxj44YkspxuPV0qf3O+V0UpJbDChfTRwY2wACQzjSmi0e/ncFXcIlVizcdabz7IqQ==",
1184 "dependencies": {
1185 "@jsr/std__internal": "^1.0.12"
1186 }
1187 },
1188 "node_modules/@types/babel__core": {
1189 "version": "7.20.5",
1190 "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1191 "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
915 "dev": true,1192 "dev": true,
916 "license": "MIT",1193 "license": "MIT",
917 "funding": {1194 "dependencies": {
918 "type": "github",1195 "@babel/parser": "^7.20.7",
919 "url": "https://github.com/sponsors/tannerlinsley"1196 "@babel/types": "^7.20.7",
1197 "@types/babel__generator": "*",
1198 "@types/babel__template": "*",
1199 "@types/babel__traverse": "*"
920 }1200 }
921 },1201 },
922 "node_modules/@tanstack/react-query": {1202 "node_modules/@types/babel__generator": {
923 "version": "5.90.20",1203 "version": "7.27.0",
924 "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz",1204 "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
925 "integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==",1205 "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
926 "dev": true,1206 "dev": true,
927 "license": "MIT",1207 "license": "MIT",
928 "dependencies": {1208 "dependencies": {
929 "@tanstack/query-core": "5.90.20"1209 "@babel/types": "^7.0.0"
930 },1210 }
931 "funding": {1211 },
932 "type": "github",1212 "node_modules/@types/babel__template": {
933 "url": "https://github.com/sponsors/tannerlinsley"1213 "version": "7.4.4",
934 },1214 "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
935 "peerDependencies": {1215 "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
936 "react": "^18 || ^19"1216 "dev": true,
1217 "license": "MIT",
1218 "dependencies": {
1219 "@babel/parser": "^7.1.0",
1220 "@babel/types": "^7.0.0"
1221 }
1222 },
1223 "node_modules/@types/babel__traverse": {
1224 "version": "7.28.0",
1225 "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1226 "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1227 "dev": true,
1228 "license": "MIT",
1229 "dependencies": {
1230 "@babel/types": "^7.28.2"
937 }1231 }
938 },1232 },
939 "node_modules/@types/chai": {1233 "node_modules/@types/chai": {
...@@ -961,45 +1255,57 @@...@@ -961,45 +1255,57 @@
961 "dev": true,1255 "dev": true,
962 "license": "MIT"1256 "license": "MIT"
963 },1257 },
1258 "node_modules/@types/node": {
1259 "version": "24.10.9",
1260 "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz",
1261 "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
1262 "dev": true,
1263 "license": "MIT",
1264 "peer": true,
1265 "dependencies": {
1266 "undici-types": "~7.16.0"
1267 }
1268 },
964 "node_modules/@types/react": {1269 "node_modules/@types/react": {
965 "version": "19.2.9",1270 "version": "19.2.10",
966 "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz",1271 "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
967 "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",1272 "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
968 "dev": true,1273 "dev": true,
969 "license": "MIT",1274 "license": "MIT",
1275 "peer": true,
970 "dependencies": {1276 "dependencies": {
971 "csstype": "^3.2.2"1277 "csstype": "^3.2.2"
972 }1278 }
973 },1279 },
974 "node_modules/@vitest/coverage-v8": {1280 "node_modules/@types/react-dom": {
975 "version": "4.0.18",1281 "version": "19.2.3",
976 "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",1282 "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
977 "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==",1283 "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
1284 "dev": true,
1285 "license": "MIT",
1286 "peerDependencies": {
1287 "@types/react": "^19.2.0"
1288 }
1289 },
1290 "node_modules/@vitejs/plugin-react": {
1291 "version": "5.1.2",
1292 "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
1293 "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==",
978 "dev": true,1294 "dev": true,
979 "license": "MIT",1295 "license": "MIT",
980 "dependencies": {1296 "dependencies": {
981 "@bcoe/v8-coverage": "^1.0.2",1297 "@babel/core": "^7.28.5",
982 "@vitest/utils": "4.0.18",1298 "@babel/plugin-transform-react-jsx-self": "^7.27.1",
983 "ast-v8-to-istanbul": "^0.3.10",1299 "@babel/plugin-transform-react-jsx-source": "^7.27.1",
984 "istanbul-lib-coverage": "^3.2.2",1300 "@rolldown/pluginutils": "1.0.0-beta.53",
985 "istanbul-lib-report": "^3.0.1",1301 "@types/babel__core": "^7.20.5",
986 "istanbul-reports": "^3.2.0",1302 "react-refresh": "^0.18.0"
987 "magicast": "^0.5.1",
988 "obug": "^2.1.1",
989 "std-env": "^3.10.0",
990 "tinyrainbow": "^3.0.3"
991 },1303 },
992 "funding": {1304 "engines": {
993 "url": "https://opencollective.com/vitest"1305 "node": "^20.19.0 || >=22.12.0"
994 },1306 },
995 "peerDependencies": {1307 "peerDependencies": {
996 "@vitest/browser": "4.0.18",1308 "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
997 "vitest": "4.0.18"
998 },
999 "peerDependenciesMeta": {
1000 "@vitest/browser": {
1001 "optional": true
1002 }
1003 }1309 }
1004 },1310 },
1005 "node_modules/@vitest/expect": {1311 "node_modules/@vitest/expect": {
...@@ -1099,29 +1405,6 @@...@@ -1099,29 +1405,6 @@
1099 "url": "https://opencollective.com/vitest"1405 "url": "https://opencollective.com/vitest"
1100 }1406 }
1101 },1407 },
1102 "node_modules/@vitest/ui": {
1103 "version": "4.0.18",
1104 "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.18.tgz",
1105 "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==",
1106 "dev": true,
1107 "license": "MIT",
1108 "peer": true,
1109 "dependencies": {
1110 "@vitest/utils": "4.0.18",
1111 "fflate": "^0.8.2",
1112 "flatted": "^3.3.3",
1113 "pathe": "^2.0.3",
1114 "sirv": "^3.0.2",
1115 "tinyglobby": "^0.2.15",
1116 "tinyrainbow": "^3.0.3"
1117 },
1118 "funding": {
1119 "url": "https://opencollective.com/vitest"
1120 },
1121 "peerDependencies": {
1122 "vitest": "4.0.18"
1123 }
1124 },
1125 "node_modules/@vitest/utils": {1408 "node_modules/@vitest/utils": {
1126 "version": "4.0.18",1409 "version": "4.0.18",
1127 "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",1410 "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
...@@ -1146,18 +1429,72 @@...@@ -1146,18 +1429,72 @@
1146 "node": ">=12"1429 "node": ">=12"
1147 }1430 }
1148 },1431 },
1149 "node_modules/ast-v8-to-istanbul": {1432 "node_modules/baseline-browser-mapping": {
1150 "version": "0.3.10",1433 "version": "2.9.18",
1151 "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz",1434 "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz",
1152 "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==",1435 "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==",
1153 "dev": true,1436 "dev": true,
1437 "license": "Apache-2.0",
1438 "bin": {
1439 "baseline-browser-mapping": "dist/cli.js"
1440 }
1441 },
1442 "node_modules/browserslist": {
1443 "version": "4.28.1",
1444 "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
1445 "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
1446 "dev": true,
1447 "funding": [
1448 {
1449 "type": "opencollective",
1450 "url": "https://opencollective.com/browserslist"
1451 },
1452 {
1453 "type": "tidelift",
1454 "url": "https://tidelift.com/funding/github/npm/browserslist"
1455 },
1456 {
1457 "type": "github",
1458 "url": "https://github.com/sponsors/ai"
1459 }
1460 ],
1154 "license": "MIT",1461 "license": "MIT",
1462 "peer": true,
1155 "dependencies": {1463 "dependencies": {
1156 "@jridgewell/trace-mapping": "^0.3.31",1464 "baseline-browser-mapping": "^2.9.0",
1157 "estree-walker": "^3.0.3",1465 "caniuse-lite": "^1.0.30001759",
1158 "js-tokens": "^9.0.1"1466 "electron-to-chromium": "^1.5.263",
1467 "node-releases": "^2.0.27",
1468 "update-browserslist-db": "^1.2.0"
1469 },
1470 "bin": {
1471 "browserslist": "cli.js"
1472 },
1473 "engines": {
1474 "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1159 }1475 }
1160 },1476 },
1477 "node_modules/caniuse-lite": {
1478 "version": "1.0.30001766",
1479 "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
1480 "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==",
1481 "dev": true,
1482 "funding": [
1483 {
1484 "type": "opencollective",
1485 "url": "https://opencollective.com/browserslist"
1486 },
1487 {
1488 "type": "tidelift",
1489 "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1490 },
1491 {
1492 "type": "github",
1493 "url": "https://github.com/sponsors/ai"
1494 }
1495 ],
1496 "license": "CC-BY-4.0"
1497 },
1161 "node_modules/chai": {1498 "node_modules/chai": {
1162 "version": "6.2.2",1499 "version": "6.2.2",
1163 "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",1500 "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
...@@ -1168,6 +1505,13 @@...@@ -1168,6 +1505,13 @@
1168 "node": ">=18"1505 "node": ">=18"
1169 }1506 }
1170 },1507 },
1508 "node_modules/convert-source-map": {
1509 "version": "2.0.0",
1510 "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1511 "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1512 "dev": true,
1513 "license": "MIT"
1514 },
1171 "node_modules/csstype": {1515 "node_modules/csstype": {
1172 "version": "3.2.3",1516 "version": "3.2.3",
1173 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",1517 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
...@@ -1175,6 +1519,31 @@...@@ -1175,6 +1519,31 @@
1175 "dev": true,1519 "dev": true,
1176 "license": "MIT"1520 "license": "MIT"
1177 },1521 },
1522 "node_modules/debug": {
1523 "version": "4.4.3",
1524 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1525 "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1526 "dev": true,
1527 "license": "MIT",
1528 "dependencies": {
1529 "ms": "^2.1.3"
1530 },
1531 "engines": {
1532 "node": ">=6.0"
1533 },
1534 "peerDependenciesMeta": {
1535 "supports-color": {
1536 "optional": true
1537 }
1538 }
1539 },
1540 "node_modules/electron-to-chromium": {
1541 "version": "1.5.279",
1542 "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.279.tgz",
1543 "integrity": "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==",
1544 "dev": true,
1545 "license": "ISC"
1546 },
1178 "node_modules/es-module-lexer": {1547 "node_modules/es-module-lexer": {
1179 "version": "1.7.0",1548 "version": "1.7.0",
1180 "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",1549 "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
...@@ -1224,6 +1593,16 @@...@@ -1224,6 +1593,16 @@
1224 "@esbuild/win32-x64": "0.27.2"1593 "@esbuild/win32-x64": "0.27.2"
1225 }1594 }
1226 },1595 },
1596 "node_modules/escalade": {
1597 "version": "3.2.0",
1598 "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1599 "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1600 "dev": true,
1601 "license": "MIT",
1602 "engines": {
1603 "node": ">=6"
1604 }
1605 },
1227 "node_modules/estree-walker": {1606 "node_modules/estree-walker": {
1228 "version": "3.0.3",1607 "version": "3.0.3",
1229 "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",1608 "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
...@@ -1262,20 +1641,6 @@...@@ -1262,20 +1641,6 @@
1262 }1641 }
1263 }1642 }
1264 },1643 },
1265 "node_modules/fflate": {
1266 "version": "0.8.2",
1267 "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
1268 "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
1269 "dev": true,
1270 "license": "MIT"
1271 },
1272 "node_modules/flatted": {
1273 "version": "3.3.3",
1274 "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
1275 "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
1276 "dev": true,
1277 "license": "ISC"
1278 },
1279 "node_modules/fsevents": {1644 "node_modules/fsevents": {
1280 "version": "2.3.3",1645 "version": "2.3.3",
1281 "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",1646 "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
...@@ -1291,69 +1656,59 @@...@@ -1291,69 +1656,59 @@
1291 "node": "^8.16.0 || ^10.6.0 || >=11.0.0"1656 "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1292 }1657 }
1293 },1658 },
1294 "node_modules/has-flag": {1659 "node_modules/gensync": {
1295 "version": "4.0.0",1660 "version": "1.0.0-beta.2",
1296 "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",1661 "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1297 "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",1662 "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1298 "dev": true,1663 "dev": true,
1299 "license": "MIT",1664 "license": "MIT",
1300 "engines": {1665 "engines": {
1301 "node": ">=8"1666 "node": ">=6.9.0"
1302 }1667 }
1303 },1668 },
1304 "node_modules/html-escaper": {1669 "node_modules/js-tokens": {
1305 "version": "2.0.2",1670 "version": "4.0.0",
1306 "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",1671 "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1307 "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",1672 "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1308 "dev": true,1673 "dev": true,
1309 "license": "MIT"1674 "license": "MIT"
1310 },1675 },
1311 "node_modules/istanbul-lib-coverage": {1676 "node_modules/jsesc": {
1312 "version": "3.2.2",1677 "version": "3.1.0",
1313 "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",1678 "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1314 "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",1679 "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1315 "dev": true,1680 "dev": true,
1316 "license": "BSD-3-Clause",1681 "license": "MIT",
1682 "bin": {
1683 "jsesc": "bin/jsesc"
1684 },
1317 "engines": {1685 "engines": {
1318 "node": ">=8"1686 "node": ">=6"
1319 }1687 }
1320 },1688 },
1321 "node_modules/istanbul-lib-report": {1689 "node_modules/json5": {
1322 "version": "3.0.1",1690 "version": "2.2.3",
1323 "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",1691 "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1324 "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",1692 "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1325 "dev": true,1693 "dev": true,
1326 "license": "BSD-3-Clause",1694 "license": "MIT",
1327 "dependencies": {1695 "bin": {
1328 "istanbul-lib-coverage": "^3.0.0",1696 "json5": "lib/cli.js"
1329 "make-dir": "^4.0.0",
1330 "supports-color": "^7.1.0"
1331 },1697 },
1332 "engines": {1698 "engines": {
1333 "node": ">=10"1699 "node": ">=6"
1334 }1700 }
1335 },1701 },
1336 "node_modules/istanbul-reports": {1702 "node_modules/lru-cache": {
1337 "version": "3.2.0",1703 "version": "5.1.1",
1338 "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",1704 "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1339 "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",1705 "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1340 "dev": true,1706 "dev": true,
1341 "license": "BSD-3-Clause",1707 "license": "ISC",
1342 "dependencies": {1708 "dependencies": {
1343 "html-escaper": "^2.0.0",1709 "yallist": "^3.0.2"
1344 "istanbul-lib-report": "^3.0.0"
1345 },
1346 "engines": {
1347 "node": ">=8"
1348 }1710 }
1349 },1711 },
1350 "node_modules/js-tokens": {
1351 "version": "9.0.1",
1352 "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
1353 "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
1354 "dev": true,
1355 "license": "MIT"
1356 },
1357 "node_modules/magic-string": {1712 "node_modules/magic-string": {
1358 "version": "0.30.21",1713 "version": "0.30.21",
1359 "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",1714 "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
...@@ -1364,43 +1719,12 @@...@@ -1364,43 +1719,12 @@
1364 "@jridgewell/sourcemap-codec": "^1.5.5"1719 "@jridgewell/sourcemap-codec": "^1.5.5"
1365 }1720 }
1366 },1721 },
1367 "node_modules/magicast": {1722 "node_modules/ms": {
1368 "version": "0.5.1",1723 "version": "2.1.3",
1369 "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz",1724 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1370 "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==",1725 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1371 "dev": true,1726 "dev": true,
1372 "license": "MIT",1727 "license": "MIT"
1373 "dependencies": {
1374 "@babel/parser": "^7.28.5",
1375 "@babel/types": "^7.28.5",
1376 "source-map-js": "^1.2.1"
1377 }
1378 },
1379 "node_modules/make-dir": {
1380 "version": "4.0.0",
1381 "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
1382 "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
1383 "dev": true,
1384 "license": "MIT",
1385 "dependencies": {
1386 "semver": "^7.5.3"
1387 },
1388 "engines": {
1389 "node": ">=10"
1390 },
1391 "funding": {
1392 "url": "https://github.com/sponsors/sindresorhus"
1393 }
1394 },
1395 "node_modules/mrmime": {
1396 "version": "2.0.1",
1397 "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
1398 "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
1399 "dev": true,
1400 "license": "MIT",
1401 "engines": {
1402 "node": ">=10"
1403 }
1404 },1728 },
1405 "node_modules/nanoid": {1729 "node_modules/nanoid": {
1406 "version": "3.3.11",1730 "version": "3.3.11",
...@@ -1421,6 +1745,13 @@...@@ -1421,6 +1745,13 @@
1421 "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"1745 "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1422 }1746 }
1423 },1747 },
1748 "node_modules/node-releases": {
1749 "version": "2.0.27",
1750 "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
1751 "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
1752 "dev": true,
1753 "license": "MIT"
1754 },
1424 "node_modules/obug": {1755 "node_modules/obug": {
1425 "version": "2.1.1",1756 "version": "2.1.1",
1426 "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",1757 "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
...@@ -1490,9 +1821,9 @@...@@ -1490,9 +1821,9 @@
1490 }1821 }
1491 },1822 },
1492 "node_modules/react": {1823 "node_modules/react": {
1493 "version": "19.2.3",1824 "version": "19.2.4",
1494 "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",1825 "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
1495 "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",1826 "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
1496 "dev": true,1827 "dev": true,
1497 "license": "MIT",1828 "license": "MIT",
1498 "peer": true,1829 "peer": true,
...@@ -1500,10 +1831,33 @@...@@ -1500,10 +1831,33 @@
1500 "node": ">=0.10.0"1831 "node": ">=0.10.0"
1501 }1832 }
1502 },1833 },
1834 "node_modules/react-dom": {
1835 "version": "19.2.4",
1836 "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
1837 "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
1838 "dev": true,
1839 "license": "MIT",
1840 "dependencies": {
1841 "scheduler": "^0.27.0"
1842 },
1843 "peerDependencies": {
1844 "react": "^19.2.4"
1845 }
1846 },
1847 "node_modules/react-refresh": {
1848 "version": "0.18.0",
1849 "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
1850 "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
1851 "dev": true,
1852 "license": "MIT",
1853 "engines": {
1854 "node": ">=0.10.0"
1855 }
1856 },
1503 "node_modules/rollup": {1857 "node_modules/rollup": {
1504 "version": "4.56.0",1858 "version": "4.57.0",
1505 "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz",1859 "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz",
1506 "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==",1860 "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==",
1507 "dev": true,1861 "dev": true,
1508 "license": "MIT",1862 "license": "MIT",
1509 "dependencies": {1863 "dependencies": {
...@@ -1517,45 +1871,49 @@...@@ -1517,45 +1871,49 @@
1517 "npm": ">=8.0.0"1871 "npm": ">=8.0.0"
1518 },1872 },
1519 "optionalDependencies": {1873 "optionalDependencies": {
1520 "@rollup/rollup-android-arm-eabi": "4.56.0",1874 "@rollup/rollup-android-arm-eabi": "4.57.0",
1521 "@rollup/rollup-android-arm64": "4.56.0",1875 "@rollup/rollup-android-arm64": "4.57.0",
1522 "@rollup/rollup-darwin-arm64": "4.56.0",1876 "@rollup/rollup-darwin-arm64": "4.57.0",
1523 "@rollup/rollup-darwin-x64": "4.56.0",1877 "@rollup/rollup-darwin-x64": "4.57.0",
1524 "@rollup/rollup-freebsd-arm64": "4.56.0",1878 "@rollup/rollup-freebsd-arm64": "4.57.0",
1525 "@rollup/rollup-freebsd-x64": "4.56.0",1879 "@rollup/rollup-freebsd-x64": "4.57.0",
1526 "@rollup/rollup-linux-arm-gnueabihf": "4.56.0",1880 "@rollup/rollup-linux-arm-gnueabihf": "4.57.0",
1527 "@rollup/rollup-linux-arm-musleabihf": "4.56.0",1881 "@rollup/rollup-linux-arm-musleabihf": "4.57.0",
1528 "@rollup/rollup-linux-arm64-gnu": "4.56.0",1882 "@rollup/rollup-linux-arm64-gnu": "4.57.0",
1529 "@rollup/rollup-linux-arm64-musl": "4.56.0",1883 "@rollup/rollup-linux-arm64-musl": "4.57.0",
1530 "@rollup/rollup-linux-loong64-gnu": "4.56.0",1884 "@rollup/rollup-linux-loong64-gnu": "4.57.0",
1531 "@rollup/rollup-linux-loong64-musl": "4.56.0",1885 "@rollup/rollup-linux-loong64-musl": "4.57.0",
1532 "@rollup/rollup-linux-ppc64-gnu": "4.56.0",1886 "@rollup/rollup-linux-ppc64-gnu": "4.57.0",
1533 "@rollup/rollup-linux-ppc64-musl": "4.56.0",1887 "@rollup/rollup-linux-ppc64-musl": "4.57.0",
1534 "@rollup/rollup-linux-riscv64-gnu": "4.56.0",1888 "@rollup/rollup-linux-riscv64-gnu": "4.57.0",
1535 "@rollup/rollup-linux-riscv64-musl": "4.56.0",1889 "@rollup/rollup-linux-riscv64-musl": "4.57.0",
1536 "@rollup/rollup-linux-s390x-gnu": "4.56.0",1890 "@rollup/rollup-linux-s390x-gnu": "4.57.0",
1537 "@rollup/rollup-linux-x64-gnu": "4.56.0",1891 "@rollup/rollup-linux-x64-gnu": "4.57.0",
1538 "@rollup/rollup-linux-x64-musl": "4.56.0",1892 "@rollup/rollup-linux-x64-musl": "4.57.0",
1539 "@rollup/rollup-openbsd-x64": "4.56.0",1893 "@rollup/rollup-openbsd-x64": "4.57.0",
1540 "@rollup/rollup-openharmony-arm64": "4.56.0",1894 "@rollup/rollup-openharmony-arm64": "4.57.0",
1541 "@rollup/rollup-win32-arm64-msvc": "4.56.0",1895 "@rollup/rollup-win32-arm64-msvc": "4.57.0",
1542 "@rollup/rollup-win32-ia32-msvc": "4.56.0",1896 "@rollup/rollup-win32-ia32-msvc": "4.57.0",
1543 "@rollup/rollup-win32-x64-gnu": "4.56.0",1897 "@rollup/rollup-win32-x64-gnu": "4.57.0",
1544 "@rollup/rollup-win32-x64-msvc": "4.56.0",1898 "@rollup/rollup-win32-x64-msvc": "4.57.0",
1545 "fsevents": "~2.3.2"1899 "fsevents": "~2.3.2"
1546 }1900 }
1547 },1901 },
1902 "node_modules/scheduler": {
1903 "version": "0.27.0",
1904 "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
1905 "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
1906 "dev": true,
1907 "license": "MIT"
1908 },
1548 "node_modules/semver": {1909 "node_modules/semver": {
1549 "version": "7.7.3",1910 "version": "6.3.1",
1550 "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",1911 "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
1551 "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",1912 "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
1552 "dev": true,1913 "dev": true,
1553 "license": "ISC",1914 "license": "ISC",
1554 "bin": {1915 "bin": {
1555 "semver": "bin/semver.js"1916 "semver": "bin/semver.js"
1556 },
1557 "engines": {
1558 "node": ">=10"
1559 }1917 }
1560 },1918 },
1561 "node_modules/siginfo": {1919 "node_modules/siginfo": {
...@@ -1565,21 +1923,6 @@...@@ -1565,21 +1923,6 @@
1565 "dev": true,1923 "dev": true,
1566 "license": "ISC"1924 "license": "ISC"
1567 },1925 },
1568 "node_modules/sirv": {
1569 "version": "3.0.2",
1570 "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
1571 "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
1572 "dev": true,
1573 "license": "MIT",
1574 "dependencies": {
1575 "@polka/url": "^1.0.0-next.24",
1576 "mrmime": "^2.0.0",
1577 "totalist": "^3.0.0"
1578 },
1579 "engines": {
1580 "node": ">=18"
1581 }
1582 },
1583 "node_modules/source-map-js": {1926 "node_modules/source-map-js": {
1584 "version": "1.2.1",1927 "version": "1.2.1",
1585 "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",1928 "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
...@@ -1604,19 +1947,6 @@...@@ -1604,19 +1947,6 @@
1604 "dev": true,1947 "dev": true,
1605 "license": "MIT"1948 "license": "MIT"
1606 },1949 },
1607 "node_modules/supports-color": {
1608 "version": "7.2.0",
1609 "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
1610 "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
1611 "dev": true,
1612 "license": "MIT",
1613 "dependencies": {
1614 "has-flag": "^4.0.0"
1615 },
1616 "engines": {
1617 "node": ">=8"
1618 }
1619 },
1620 "node_modules/tinybench": {1950 "node_modules/tinybench": {
1621 "version": "2.9.0",1951 "version": "2.9.0",
1622 "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",1952 "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
...@@ -1661,14 +1991,42 @@...@@ -1661,14 +1991,42 @@
1661 "node": ">=14.0.0"1991 "node": ">=14.0.0"
1662 }1992 }
1663 },1993 },
1664 "node_modules/totalist": {1994 "node_modules/undici-types": {
1665 "version": "3.0.1",1995 "version": "7.16.0",
1666 "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",1996 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
1667 "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",1997 "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
1998 "dev": true,
1999 "license": "MIT"
2000 },
2001 "node_modules/update-browserslist-db": {
2002 "version": "1.2.3",
2003 "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
2004 "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
1668 "dev": true,2005 "dev": true,
2006 "funding": [
2007 {
2008 "type": "opencollective",
2009 "url": "https://opencollective.com/browserslist"
2010 },
2011 {
2012 "type": "tidelift",
2013 "url": "https://tidelift.com/funding/github/npm/browserslist"
2014 },
2015 {
2016 "type": "github",
2017 "url": "https://github.com/sponsors/ai"
2018 }
2019 ],
1669 "license": "MIT",2020 "license": "MIT",
1670 "engines": {2021 "dependencies": {
1671 "node": ">=6"2022 "escalade": "^3.2.0",
2023 "picocolors": "^1.1.1"
2024 },
2025 "bin": {
2026 "update-browserslist-db": "cli.js"
2027 },
2028 "peerDependencies": {
2029 "browserslist": ">= 4.21.0"
1672 }2030 }
1673 },2031 },
1674 "node_modules/vite": {2032 "node_modules/vite": {
...@@ -1753,7 +2111,6 @@...@@ -1753,7 +2111,6 @@
1753 "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",2111 "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
1754 "dev": true,2112 "dev": true,
1755 "license": "MIT",2113 "license": "MIT",
1756 "peer": true,
1757 "dependencies": {2114 "dependencies": {
1758 "@vitest/expect": "4.0.18",2115 "@vitest/expect": "4.0.18",
1759 "@vitest/mocker": "4.0.18",2116 "@vitest/mocker": "4.0.18",
...@@ -1842,6 +2199,13 @@...@@ -1842,6 +2199,13 @@
1842 "engines": {2199 "engines": {
1843 "node": ">=8"2200 "node": ">=8"
1844 }2201 }
2202 },
2203 "node_modules/yallist": {
2204 "version": "3.1.1",
2205 "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
2206 "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
2207 "dev": true,
2208 "license": "ISC"
1845 }2209 }
1846 }2210 }
1847}2211}
package.json+30-6
...@@ -1,14 +1,38 @@...@@ -1,14 +1,38 @@
1{1{
2 "name": "@clo/react-mutation",
3 "version": "0.0.0",
4 "private": true,
5 "description": "",
6 "license": "ISC",
7 "author": "",
2 "type": "module",8 "type": "module",
9 "main": "index.js",
3 "scripts": {10 "scripts": {
4 "test": "vitest run"11 "dev": "vitest dev",
12 "test": "vitest run",
13 "check": "tsc --noEmit"
14 },
15 "dependencies": {
16 "@std/assert": "npm:@jsr/std__assert@^1.0.17"
5 },17 },
6 "devDependencies": {18 "devDependencies": {
7 "@tanstack/react-query": "^5.90.20",19 "@types/node": "^24.10.1",
8 "@types/react": "^19.2.9",20 "@types/react": "^19.2.10",
9 "@vitest/coverage-v8": "^4.0.18",21 "@types/react-dom": "^19.2.3",
10 "@vitest/ui": "^4.0.18",22 "@vitejs/plugin-react": "^5.1.1",
11 "react": "^19.2.3",23 "react": "^19.2.4",
24 "react-dom": "^19.2.4",
25 "vite": "^7.2.4",
12 "vitest": "^4.0.18"26 "vitest": "^4.0.18"
27 },
28 "peerDependencies": {
29 "@tanstack/react-query": "*",
30 "@types/react": "*",
31 "react": "*"
32 },
33 "peerDependenciesMeta": {
34 "@tanstack/react-query": {
35 "optional": true
36 }
13 }37 }
14}38}
readme.md created+107
...@@ -0,0 +1,107 @@
1# `@clo/react-mutation`
2
3## Motivation
4
5At work, we found React Query, with a few helper functions, to be extremely
6useful for fetching and synchronizing dynamic state in the browser. However,
7their mutation story falls apart, is confusing, and misses a few obvious
8features. Additionally, coworkers using AI agents continue to propagate bad
9patterns and verbose code that is hard to review.
10
11The primary gains React Mutation provides are
12
13- **Automatic error handling**. If a `useMutation` hook does not observe
14 `isError`, unhandled errors will be propagated to a global handler, which can
15 display a UI toast. Otherwise, the component can display the error locally.
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.
18- Batched Mutations are just so awesome to use.
19
20## Usage
21
22This library declares two kinds of mutations. Each kind has different behavior
23around concurrent operations.
24
25- [**Queued Mutations**](#Queued-Mutations): A mutation blocks the UI until it
26 is complete. You press a button, a pending state appears, then it completes.
27 This works great for forms, creations and deletions, and is similar to React
28 Query's mutation system.
29- [**Batched Mutations**](#Batched-Mutations): Each call to the mutation applies
30 new optimistic state, and after a debounce or throttle, the new optimistic
31 state is committed to the API. UI never shows a pending state for batches.
32 This works great for auto-saving input fields, follow buttons, and is
33 preferred whenever possible.
34
35React Mutation starts with a `MutationClient`, which shares global state for an application.
36
37```ts
38const queryClient = new QueryClient();
39export const mutations = new MutationClient({
40 // All properties in `context` are available within mutation functions.
41 context: {
42 client: queryClient,
43 // Can add easy helpers for your codebase.
44 get: (k: QueryKey) => client.getQueryData(k),
45 },
46
47 // Optimistic helpers are a second type of context, only available
48 // within optimistic update functions. The built in React Query helpers
49 // add many query cache mutating operations that automatically
50 getOptimisticHelpers: queryClientOptimisticHelpers(queryClient),
51
52 // When call sites do not opt into handling errors, or a pending
53 // mutation hook is unmounted, errors are sent to this function.
54 // An example is to bind this to global a UI toast.
55 reportError(description: string, error: unknown) {
56 console.error("Mutation error:", error);
57 },
58})
59
60```
61
62### Queued Mutations
63
64A queued mutation is defined with `mutations.defineQueued`. These are useful for creating
65
66```tsx
67const queryCounter = queryOptions({
68 queryKey: ["items"],
69 queryFn: (): Promise<string[]> => fetch(),
70});
71const queryItem = (id: string) => queryOptions({
72 queryKey: ["items", id],
73 queryFn: (): Promise<object> => fetch(),
74});
75
76const mutDeleteItem = mutations.defineQueued({
77 // `mutate` comes first, is only worried about syncing with the backend.
78 async mutate(id: string) {
79 const response = await fetch(`/items/${id}`, { method: "delete" });
80 if (!response.ok) throw new Error(`HTTP ${response.status}`);
81 },
82
83 optimistic({ helpers, args: [amount] }) {
84 helpers.removeFromArray(queryItemList, ".", (n) => );
85 helpers.removeQuery(queryItem)
86 },
87
88 describe({ client, args: [id] }) {
89 const { title } = client.getQueryData<object>(queryItem().queryKey);
90 return `delete '${title}'`;
91 },
92
93 // since the optimistic handler is perfect, there is no need
94 // to refetch any data once a success case is hit.
95 refetchOnSuccess: false,
96});
97
98export function Example({ id }: { id: string }) {
99 const { data: list } = useSuspenseQuery(queryItemList);
100 const { run } = useMutation(mutDeleteItem);
101
102 return list.map((id) => <li key={id}>
103 <Item id={id} />
104 <button onClick={() => run(id)}>delete</button>
105 </li>);
106}
107```
src/batch.ts+435-3
...@@ -1,3 +1,435 @@...@@ -1,3 +1,435 @@
1export function defineBatchMutation<Args extends unknown[], Result, Optimistic>(1import type { MutationClient, MutationClientFromConfig } from "./client.ts";
2 options: BatchMutationOptions<Args, Result, Optimistic>,2import type { MutationClientConfig } from "./client.ts";
3): Mutation<Args, Result> {}3import type { Mutation, MutationEvent } from "./types.ts";
4
5export interface BatchMutationOptions<
6 Args extends unknown[],
7 Result,
8 Optimistic,
9 Config extends MutationClientConfig,
10> {
11 /**
12 * Update the UI for one call to the optimistic function.
13 * A rest params type is used to allow type inference. Place this function first to
14 * ensure TypeScript correctly infers the argument type for the rest of the functions.
15 */
16 optimistic: (context: BatchOptimisticContext<Config>, ...args: Args) => void;
17 /**
18 * Retrieve the current/optimistic value of the mutation. When this returns
19 * the same thing as when the mutation started, it means that `mutate` does
20 * not need to be called since the data is the same.
21 *
22 * Don't snapshot unrelated state that this mutation isn't concerned with.
23 */
24 getValue: (context: Config["context"], ...args: Args) => Optimistic;
25
26 mode: "debounce" | "throttle";
27 /**
28 * Milliseconds
29 * @default 200
30 */
31 time?: number;
32
33 /** A key to associate batch items. For example, returning a user ID */
34 key: (
35 context: Config["context"] & { args: NoInfer<Args> },
36 ) => string | string[];
37
38 /**
39 * Commit the optimistic state. Throw on failure.
40 */
41 commit: (
42 context: BatchMutatorArgs<NoInfer<Args>, Optimistic, Config>,
43 ) => Promise<Result>;
44 /**
45 * Used in error messages and debug tools.
46 * "Failed to {action}"
47 */
48 describe:
49 | string
50 | ((
51 context: BatchMutatorArgs<NoInfer<Args>, Optimistic, Config>,
52 ) => string);
53 /**
54 * Refetch all of the data this mutation could have affected.
55 */
56 refetch?: () => Promise<void>;
57}
58
59export type BatchOptimisticContext<Config extends MutationClientConfig> =
60 & Config["context"]
61 & {
62 /** Add an event listener to roll back the update */
63 onRestore: (cb: () => void) => void;
64 helpers: Config["optimisticHelpers"];
65 };
66
67export type BatchMutatorArgs<
68 Args,
69 Optimistic,
70 Config extends MutationClientConfig,
71> = Config["context"] & {
72 /** One of the arguments. Use this only to extract the shared key */
73 args: Args;
74 /** The initial snapshot */
75 initial: Optimistic;
76 /** The compared snapshot */
77 current: Optimistic;
78};
79
80interface BatchChannel<Args extends unknown[], Result, Optimistic> {
81 listeners: Set<(update: MutationEvent<Result>) => void>;
82 status: "idle" | "waiting" | "mutating" | "refetching";
83
84 // Snapshot before first call in current batch
85 initial: Optimistic | null;
86 // First args in batch (used for commit/describe/getValue)
87 firstArgs: Args | null;
88 rollbacks: Array<() => void>;
89 refetches: Array<() => Promise<void>>;
90 timer: ReturnType<typeof setTimeout> | null;
91
92 // Track last commit time for throttle mode
93 lastCommitTime: number;
94
95 // Pending promises from callers in current batch
96 pending: Array<{
97 args: Args;
98 resolve: (result: Result) => void;
99 reject: (error: unknown) => void;
100 }>;
101}
102
103export class BatchMutation<
104 Args extends unknown[],
105 Result,
106 Optimistic,
107 Config extends MutationClientConfig,
108> implements Mutation<Args, Result> {
109 #options: BatchMutationOptions<Args, Result, Optimistic, Config>;
110 #client: MutationClientFromConfig<Config>;
111 #channels: Map<string, BatchChannel<Args, Result, Optimistic>> = new Map();
112
113 constructor(
114 client: MutationClient<Config["context"], Config["optimisticHelpers"]>,
115 options: BatchMutationOptions<Args, Result, Optimistic, Config>,
116 ) {
117 this.#options = options;
118 this.#client = client;
119 }
120
121 key(args: Args): string {
122 const k = this.#options.key({ ...this.#client.context, args });
123 return JSON.stringify(k);
124 }
125
126 #getOrPutChannel(key: string): BatchChannel<Args, Result, Optimistic> {
127 let channel = this.#channels.get(key);
128 if (!channel) {
129 channel = {
130 listeners: new Set(),
131 status: "idle",
132 initial: null,
133 firstArgs: null,
134 rollbacks: [],
135 refetches: [],
136 timer: null,
137 lastCommitTime: 0,
138 pending: [],
139 };
140 this.#channels.set(key, channel);
141 }
142 return channel;
143 }
144
145 subscribe(
146 key: string,
147 cb: (update: MutationEvent<Result>) => void,
148 ): () => void {
149 const channel = this.#getOrPutChannel(key);
150 channel.listeners.add(cb);
151 return () => channel?.listeners.delete(cb);
152 }
153
154 #notify(
155 channel: BatchChannel<Args, Result, Optimistic>,
156 status: MutationEvent<Result>["status"],
157 result: Result | null = null,
158 error: unknown = null,
159 ) {
160 const event: MutationEvent<Result> = { status, result, error };
161 channel.listeners.forEach((cb) => cb(event));
162 }
163
164 #setIdle(key: string, channel: BatchChannel<Args, Result, Optimistic>) {
165 channel.status = "idle";
166 this.#notify(channel, "idle", null, null);
167 // Clean up the channel if there are no listeners
168 if (channel.listeners.size === 0) {
169 this.#channels.delete(key);
170 }
171 }
172
173 #resetBatchState(channel: BatchChannel<Args, Result, Optimistic>) {
174 channel.initial = null;
175 channel.firstArgs = null;
176 channel.rollbacks = [];
177 channel.refetches = [];
178 channel.pending = [];
179 if (channel.timer !== null) {
180 clearTimeout(channel.timer);
181 channel.timer = null;
182 }
183 }
184
185 describe(...args: Args): string {
186 const { describe } = this.#options;
187 if (typeof describe === "function") {
188 // For describe, we need initial/current but may not have them yet
189 // Use placeholder values when called outside of commit context
190 return describe({
191 ...this.#client.context,
192 args,
193 initial: null as unknown as Optimistic,
194 current: null as unknown as Optimistic,
195 });
196 }
197 return describe;
198 }
199
200 /** Calling the mutation in a global scope. Errors are turned into UI toasts. */
201 run(...args: Args): void {
202 this.runAndReturn(...args).catch((error) => {
203 this.#client.reportError(error);
204 });
205 }
206
207 /** Calls the mutation, treating the errors as promise rejection. */
208 runAndReturn(...args: Args): Promise<Result> {
209 const key = this.key(args);
210 const channel = this.#getOrPutChannel(key);
211
212 // If this is the first call in the batch, take a snapshot
213 if (channel.initial === null) {
214 channel.initial = this.#options.getValue(this.#client.context, ...args);
215 channel.firstArgs = args;
216 }
217
218 // Apply optimistic update
219 let expired = false;
220 const onRestore = (cb: () => void) => {
221 if (expired) {
222 throw new Error(
223 "Can only call onRestore from within the optimistic update function.",
224 );
225 }
226 channel.rollbacks.push(cb);
227 };
228 const onRefetch = (cb: () => Promise<void>) => {
229 if (expired) {
230 throw new Error(
231 "Can only call onRefetch from within the optimistic update function.",
232 );
233 }
234 channel.refetches.push(cb);
235 };
236
237 try {
238 this.#options.optimistic(
239 {
240 ...this.#client.context,
241 onRestore,
242 helpers: this.#client.getOptimisticHelpers({
243 onRestore,
244 onRefetch,
245 }),
246 },
247 ...args,
248 );
249 } catch (error) {
250 expired = true;
251 // Rollback just this call's rollbacks
252 // We don't know how many were added, so we can't do partial rollback easily
253 // For simplicity, rollback everything and reject
254 let next;
255 while ((next = channel.rollbacks.pop())) next();
256 this.#resetBatchState(channel);
257 return Promise.reject(error);
258 }
259 expired = true;
260
261 // Create promise for this caller
262 const { promise, resolve, reject } = Promise.withResolvers<Result>();
263 channel.pending.push({ args, resolve, reject });
264
265 // Set status to waiting and notify
266 if (channel.status === "idle") {
267 channel.status = "waiting";
268 this.#notify(channel, "waiting");
269 }
270
271 // Schedule commit based on mode
272 this.#scheduleCommit(key, channel);
273
274 return promise;
275 }
276
277 #scheduleCommit(
278 key: string,
279 channel: BatchChannel<Args, Result, Optimistic>,
280 ) {
281 const time = this.#options.time ?? 200;
282
283 if (this.#options.mode === "debounce") {
284 // Debounce: reset timer on each call
285 if (channel.timer !== null) {
286 clearTimeout(channel.timer);
287 }
288 channel.timer = setTimeout(() => this.#commit(key, channel), time);
289 } else {
290 // Throttle: commit immediately if enough time passed, otherwise wait
291 // Use status to track if a commit is in progress
292 if (channel.timer === null && channel.status === "waiting") {
293 const elapsed = Date.now() - channel.lastCommitTime;
294 if (elapsed >= time) {
295 // Enough time has passed, commit immediately
296 this.#commit(key, channel);
297 } else {
298 // Wait for remaining time
299 channel.timer = setTimeout(
300 () => this.#commit(key, channel),
301 time - elapsed,
302 );
303 }
304 }
305 // If timer exists or commit is in progress, do nothing - will commit when ready
306 }
307 }
308
309 #commit(key: string, channel: BatchChannel<Args, Result, Optimistic>) {
310 // Clear timer
311 if (channel.timer !== null) {
312 clearTimeout(channel.timer);
313 channel.timer = null;
314 }
315
316 // Safety check
317 if (channel.firstArgs === null || channel.initial === null) {
318 this.#setIdle(key, channel);
319 return;
320 }
321
322 const firstArgs = channel.firstArgs;
323 const initial = channel.initial;
324 const pendingItems = [...channel.pending];
325 const rollbacks = [...channel.rollbacks];
326 const refetchCallbacks = [...channel.refetches];
327
328 // Get current snapshot
329 const current = this.#options.getValue(
330 this.#client.context,
331 ...firstArgs,
332 );
333
334 // Check if anything changed
335 if (this.#client.deepEquals(initial, current)) {
336 // No change - resolve all pending with a null result and reset
337 pendingItems.forEach(({ resolve }) => resolve(null as Result));
338 this.#resetBatchState(channel);
339 this.#setIdle(key, channel);
340 return;
341 }
342
343 // Set status to mutating
344 channel.status = "mutating";
345 this.#notify(channel, "mutating");
346
347 // Clear batch state before async operation (but keep rollbacks/refetches for error case)
348 channel.initial = null;
349 channel.firstArgs = null;
350 channel.pending = [];
351 channel.rollbacks = [];
352 channel.refetches = [];
353
354 // Call commit
355 this.#options
356 .commit({
357 ...this.#client.context,
358 args: firstArgs,
359 initial,
360 current,
361 })
362 .then((result) => {
363 // Success - rollbacks are discarded (optimistic was correct)
364 // Resolve all pending promises
365 pendingItems.forEach(({ resolve }) => resolve(result));
366
367 // Record commit time for throttle mode
368 channel.lastCommitTime = Date.now();
369
370 // Refetch
371 channel.status = "refetching";
372 this.#notify(channel, "refetching", result);
373 // Call refetch and all refetch callbacks in parallel
374 Promise.allSettled([
375 this.#options.refetch?.(),
376 ...refetchCallbacks.map((cb) => cb()),
377 ]).then((results) => {
378 // Report any errors from refetch or callbacks
379 results.forEach((result) => {
380 if (result.status === "rejected") {
381 this.#client.reportError(result.reason);
382 }
383 });
384 }).finally(() => {
385 // Check if new calls came in during the commit
386 if (channel.pending.length > 0) {
387 // There are pending calls that need to be committed
388 channel.status = "waiting";
389 this.#notify(channel, "waiting");
390 this.#scheduleCommit(key, channel);
391 } else {
392 this.#setIdle(key, channel);
393 }
394 });
395 })
396 .catch((error) => {
397 // Error - call all rollbacks in reverse order
398 let next;
399 const rollbacksCopy = [...rollbacks];
400 while ((next = rollbacksCopy.pop())) next();
401
402 // Reject all pending promises
403 pendingItems.forEach(({ reject }) => reject(error));
404
405 // Notify listeners of the error
406 this.#notify(channel, "mutating", null, error);
407
408 // Refetch to restore correct state
409 channel.status = "refetching";
410 this.#notify(channel, "refetching", null, error);
411 // Call refetch and all refetch callbacks in parallel
412 Promise.allSettled([
413 this.#options.refetch?.(),
414 ...refetchCallbacks.map((cb) => cb()),
415 ]).then((results) => {
416 // Report any errors from refetch or callbacks
417 results.forEach((result) => {
418 if (result.status === "rejected") {
419 this.#client.reportError(result.reason);
420 }
421 });
422 }).finally(() => {
423 // Check if new calls came in during the commit
424 if (channel.pending.length > 0) {
425 // There are pending calls that need to be committed
426 channel.status = "waiting";
427 this.#notify(channel, "waiting");
428 this.#scheduleCommit(key, channel);
429 } else {
430 this.#setIdle(key, channel);
431 }
432 });
433 });
434 }
435}
src/client.ts+63-24
...@@ -1,5 +1,17 @@...@@ -1,5 +1,17 @@
1import { type MutationOptions, QueuedMutation } from "./queued";1import { BatchMutation, type BatchMutationOptions } from "./batch.ts";
2import type { Mutation } from "./types";2import { type MutationOptions, QueuedMutation } from "./queued.ts";
3import type { Mutation } from "./types.ts";
4
5export interface MutationClientConfig {
6 context: {};
7 optimisticHelpers: {};
8}
9
10export type MutationClientFromConfig<Config extends MutationClientConfig> =
11 MutationClient<Config["context"], Config["optimisticHelpers"]>;
12
13const defaultDeepEquals = (a: unknown, b: unknown): boolean =>
14 JSON.stringify(a) === JSON.stringify(b);
315
4export interface MutationClientOptions<16export interface MutationClientOptions<
5 Context extends object,17 Context extends object,
...@@ -7,8 +19,20 @@ export interface MutationClientOptions<...@@ -7,8 +19,20 @@ export interface MutationClientOptions<
7> {19> {
8 context: Context;20 context: Context;
9 getOptimisticHelpers: (21 getOptimisticHelpers: (
10 onRestore: (cb: () => void) => void,22 events: OptimisticEvents,
11 ) => OptimisticHelpers;23 ) => OptimisticHelpers;
24 reportError: (error: unknown) => void;
25 /**
26 * Compare two values for deep equality. Used by BatchMutation to determine
27 * if the optimistic state has changed from the initial snapshot.
28 * @default JSON.stringify based comparison
29 */
30 deepEquals?: (a: unknown, b: unknown) => boolean;
31}
32
33export interface OptimisticEvents {
34 onRestore: (cb: () => void) => void;
35 onRefetch: (cb: () => Promise<void>) => void;
12}36}
1337
14export class MutationClient<38export class MutationClient<
...@@ -16,25 +40,23 @@ export class MutationClient<...@@ -16,25 +40,23 @@ export class MutationClient<
16 OptimisticHelpers extends object,40 OptimisticHelpers extends object,
17> {41> {
18 context: Context;42 context: Context;
19 getOptimisticHelpers: (43 getOptimisticHelpers: (event: OptimisticEvents) => OptimisticHelpers;
20 onRestore: (cb: () => void) => void,44 reportError: (error: unknown) => void;
21 ) => OptimisticHelpers;45 deepEquals: (a: unknown, b: unknown) => boolean;
22
23 constructor(
24 { context, getOptimisticHelpers }: MutationClientOptions<
25 Context,
26 OptimisticHelpers
27 >,
28 ) {
29 this.context = context;
30 this.getOptimisticHelpers = getOptimisticHelpers;
31 }
3246
33 reportError(error: unknown) {47 constructor(options: MutationClientOptions<Context, OptimisticHelpers>) {
34 //48 this.context = options.context;
49 this.getOptimisticHelpers = options.getOptimisticHelpers;
50 this.reportError = options.reportError;
51 this.deepEquals = options.deepEquals ?? defaultDeepEquals;
35 }52 }
3653
37 defineMutation<Args extends unknown[], Result>(54 /**
55 * Define a queued mutation. A mutation blocks the UI until it is complete.
56 * You press a button, a pending state appears, then it completes. This works
57 * great for forms, and is similar to React Query's mutation system.
58 */
59 defineQueued<const Args extends unknown[], Result>(
38 options: MutationOptions<60 options: MutationOptions<
39 Args,61 Args,
40 Result,62 Result,
...@@ -48,9 +70,26 @@ export class MutationClient<...@@ -48,9 +70,26 @@ export class MutationClient<
48 >(this, options);70 >(this, options);
49 }71 }
5072
51 // defineBatchMutation<Args extends unknown[], Result>(73 /**
52 // options: BatchMutationOptions<Args, Result>,74 * Define a batched mutation. Each call to the mutation applies new optimistic
53 // ): Mutation<Args, Result> {75 * state, and after a debounce or throttle, the new optimistic state is
54 // return new BatchedMutation(this, options);76 * committed to the API. UI never shows a pending state for batches. This
55 // }77 * works great for auto-saving input fields, follow buttons, and is preferred
78 * whenever possible.
79 */
80 defineBatched<const Args extends unknown[], Result, Optimistic>(
81 options: BatchMutationOptions<
82 Args,
83 Result,
84 Optimistic,
85 { context: Context; optimisticHelpers: OptimisticHelpers }
86 >,
87 ): Mutation<Args, Result> {
88 return new BatchMutation<
89 Args,
90 Result,
91 Optimistic,
92 { context: Context; optimisticHelpers: OptimisticHelpers }
93 >(this, options);
94 }
56}95}
src/index.ts created+17
...@@ -0,0 +1,17 @@
1export { type MutationOptions, type OptimisticContext } from "./queued.ts";
2export {
3 MutationClient,
4 type MutationClientFromConfig,
5 type MutationClientOptions,
6} from "./client.ts";
7export {
8 createMutationButton,
9 type MutationButtonProps,
10 useMutation,
11 type UseMutationError,
12 type UseMutationIdle,
13 type UseMutationResult,
14 type UseMutationResultBase,
15 type UseMutationSuccess,
16} from "./react.tsx";
17export { queryClientOptimisticHelpers } from "./tanstack-query.ts";
src/object-path.ts created+77
...@@ -0,0 +1,77 @@
1import { assert } from "@std/assert";
2
3export type AllObjectPaths<T, Filter = unknown> = T extends
4 ReadonlyArray<infer Y> ? [] | [number, ...AllObjectPaths<Y>]
5 : T extends object ?
6 | {
7 [K in keyof T]-?: [K, ...AllObjectPaths<T[K]>];
8 }[keyof T]
9 | []
10 : [];
11export type GetObjectPath<T, P extends unknown[]> = P extends
12 [infer K extends keyof T, ...infer Rest] ? GetObjectPath<T[K], Rest>
13 : T;
14
15/** Get an object path property */
16export function getPath<T extends object, const P extends AllObjectPaths<T>>(
17 target: T,
18 path: P,
19): { value: GetObjectPath<T, P>; exists: true } | {
20 value: undefined;
21 exists: false;
22} {
23 let current: any = target;
24
25 for (const key of path) {
26 if (current == null || typeof current !== "object") {
27 return { value: undefined, exists: false };
28 }
29
30 if (!(key in current)) {
31 return { value: undefined, exists: false };
32 }
33
34 current = current[key];
35 }
36
37 return { value: current, exists: true };
38}
39
40export function setPath<T extends object, const P extends AllObjectPaths<T>>(
41 target: T,
42 path: P,
43 value: GetObjectPath<T, P>,
44): T {
45 if (path.length === 0) {
46 return value as T;
47 }
48
49 const [firstKey, ...restPath] = path as PropertyKey[];
50
51 if (restPath.length === 0) {
52 // Base case: shallow clone and set the value
53 if (Array.isArray(target)) {
54 const result = [...target];
55 result[firstKey as any] = value;
56 return result as T;
57 } else {
58 return { ...target, [firstKey]: value };
59 }
60 }
61
62 // Recursive case: shallow clone, recursively update nested value
63 const nested = (target as any)[firstKey];
64 const updatedNested = setPath(
65 nested ?? (typeof restPath[0] === "number" ? [] : {}),
66 restPath as any,
67 value,
68 );
69
70 if (Array.isArray(target)) {
71 const result = [...target];
72 result[firstKey as any] = updatedNested;
73 return result as T;
74 } else {
75 return { ...target, [firstKey]: updatedNested };
76 }
77}
src/queued.ts+206-45
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { MutationClient } from "./client.ts";1import type { MutationClient, MutationClientFromConfig } from "./client.ts";
2import type { GlobalMutationConfig, MutationClientConfig } from "./register.ts";2import type { MutationClientConfig } from "./client.ts";
3import type { Mutation, MutationEvent } from "./types.ts";3import type { Mutation, MutationEvent } from "./types.ts";
44
5/**5/**
...@@ -11,7 +11,7 @@ import type { Mutation, MutationEvent } from "./types.ts";...@@ -11,7 +11,7 @@ import type { Mutation, MutationEvent } from "./types.ts";
11export interface MutationOptions<11export interface MutationOptions<
12 Args extends unknown[],12 Args extends unknown[],
13 Result,13 Result,
14 Config extends MutationClientConfig = GlobalMutationConfig,14 Config extends MutationClientConfig,
15> {15> {
16 /**16 /**
17 * This function is only responsible for performing the underlying API call,17 * This function is only responsible for performing the underlying API call,
...@@ -20,7 +20,7 @@ export interface MutationOptions<...@@ -20,7 +20,7 @@ export interface MutationOptions<
20 * ensure TypeScript correctly infers the argument type for the rest of the20 * ensure TypeScript correctly infers the argument type for the rest of the
21 * functions.21 * functions.
22 */22 */
23 mutate: (...args: Args) => Promise<Result>;23 mutate: (context: Config["context"], ...args: Args) => Promise<Result>;
24 /**24 /**
25 * Used in error messages and debug tools.25 * Used in error messages and debug tools.
26 * Phrase it considering the template `Failed to ${describe(...)}`26 * Phrase it considering the template `Failed to ${describe(...)}`
...@@ -35,7 +35,7 @@ export interface MutationOptions<...@@ -35,7 +35,7 @@ export interface MutationOptions<
35 * Refetch all of the data this mutation could have affected.35 * Refetch all of the data this mutation could have affected.
36 * This is called automatically on errors.36 * This is called automatically on errors.
37 */37 */
38 refetch: (context: Config["context"] & { args: Args }) => Promise<void>;38 refetch?: (context: Config["context"] & { args: Args }) => Promise<void>;
39 /**39 /**
40 * If the optimistic updator function is perfect, then this may be set to false.40 * If the optimistic updator function is perfect, then this may be set to false.
41 * @default true41 * @default true
...@@ -52,58 +52,99 @@ export interface MutationOptions<...@@ -52,58 +52,99 @@ export interface MutationOptions<
52export type OptimisticContext<52export type OptimisticContext<
53 Args extends unknown[],53 Args extends unknown[],
54 Result,54 Result,
55 Config extends MutationClientConfig = GlobalMutationConfig,55 Config extends MutationClientConfig,
56> = Config["context"] & {56> = Config["context"] & {
57 helpers: Config["optimisticHelpers"];
58 args: Args;57 args: Args;
58 helpers: Config["optimisticHelpers"];
59 /** Add an event listener to roll back the update */59 /** Add an event listener to roll back the update */
60 onRestore: (cb: () => void) => void;60 onRestore: (cb: () => void) => void;
61 /** Add an event listener to apply `Result` to the store. */61 /** Add an event listener to apply `Result` to the store. */
62 onSuccess: (cb: (result: Result) => void) => void;62 onSuccess: (cb: (result: Result) => void) => void;
63};63};
6464
65interface Channel<Args extends unknown[], Result> {
66 listeners: Set<(update: MutationEvent<Result>) => void>;
67 status: "idle" | "mutating" | "refetching";
68 rollbacks: Array<() => void>;
69 refetches: Array<() => Promise<void>>;
70 queue: Array<Item<Args, Result>>;
71}
72
73interface Item<Args extends unknown[], Result> {
74 args: Args;
75 rollbacks: number;
76 onSuccess: Array<(result: Result) => void>;
77 resolve: (result: Result) => void;
78 reject: (error: unknown) => void;
79}
80
65export class QueuedMutation<81export class QueuedMutation<
66 Args extends unknown[],82 Args extends unknown[],
67 Result,83 Result,
68 Config extends MutationClientConfig,84 Config extends MutationClientConfig,
69> implements Mutation<Args, Result> {85> implements Mutation<Args, Result> {
70 #options: MutationOptions<Args, Result, Config>;86 #options: MutationOptions<Args, Result, Config>;
71 #client: MutationClient<Config["context"], Config["optimisticHelpers"]>;87 #client: MutationClientFromConfig<Config>;
72 #queues: Map<string, {88 #queues: Map<string, Channel<Args, Result>> = new Map();
73 listeners: Set<(update: MutationEvent<Result>) => void>;
74 rollbacks: Array<() => void>;
75 queue: Array<{
76 args: Args;
77 rollbacks: number;
78 }>;
79 }> = new Map();
8089
81 constructor(90 constructor(
82 client: MutationClient<Config["context"], Config["optimisticHelpers"]>,91 client: MutationClient<Config["context"], Config["optimisticHelpers"]>,
83 options: MutationOptions<Args, Result>,92 options: MutationOptions<Args, Result, Config>,
84 ) {93 ) {
85 this.#options = options;94 this.#options = options;
86 this.#client = client;95 this.#client = client;
87 }96 }
8897
89 #key(args: Args) {98 key(args: Args) {
90 const k = this.#options.key?.({ ...this.#client.context, args }) ??99 const k = this.#options.key?.({ ...this.#client.context, args }) ??
91 "shared";100 "shared";
92 return JSON.stringify(k);101 return JSON.stringify(k);
93 }102 }
94103
104 #getOrPutChannel(key: string) {
105 let channel = this.#queues.get(key);
106 if (!channel) {
107 const rollbacks: Array<() => []> = [];
108 channel = {
109 listeners: new Set(),
110 status: "idle",
111 rollbacks,
112 refetches: [],
113 queue: [],
114 };
115 this.#queues.set(key, channel);
116 }
117 return channel;
118 }
119
95 subscribe(120 subscribe(
96 args: Args,121 key: string,
97 cb: (update: MutationEvent<Result>) => void,122 cb: (update: MutationEvent<Result>) => void,
98 ): () => void {123 ): () => void {
99 const k = this.#key(args);124 const channel = this.#getOrPutChannel(key);
100 let entry = this.#queues.get(k);125 channel.listeners.add(cb);
101 if (!entry) {126 return () => channel?.listeners.delete(cb);
102 entry = { listeners: new Set(), rollbacks: [], queue: [] };127 }
103 this.#queues.set(k, entry);128
129 #notify(
130 channel: Channel<Args, Result>,
131 status: MutationEvent<Result>["status"],
132 result: Result | null = null,
133 error: unknown = null,
134 ) {
135 const event: MutationEvent<Result> = { status, result, error };
136 channel.listeners.forEach((cb) => cb(event));
137 }
138
139 #setIdle(key: string, channel: Channel<Args, Result>) {
140 channel.status = "idle";
141 // Discard any unconsumed refetch callbacks
142 channel.refetches = [];
143 this.#notify(channel, "idle", null, null);
144 // Clean up the channel if there are no listeners
145 if (channel.listeners.size === 0) {
146 this.#queues.delete(key);
104 }147 }
105 entry.listeners.add(cb);
106 return () => entry?.listeners.delete(cb);
107 }148 }
108149
109 describe(...args: Args): string {150 describe(...args: Args): string {
...@@ -122,36 +163,156 @@ export class QueuedMutation<...@@ -122,36 +163,156 @@ export class QueuedMutation<
122163
123 /** Calls the mutation, treating the errors as promise rejection. */164 /** Calls the mutation, treating the errors as promise rejection. */
124 runAndReturn(...args: Args): Promise<Result> {165 runAndReturn(...args: Args): Promise<Result> {
125 // return this.#dedupe?.get(...args) ?? this.#execute(...args);166 const key = this.key(args);
126 }167 const channel = this.#getOrPutChannel(key);
127168
128 async #execute(...args: Args): Promise<Result> {169 const onSuccess: Array<(result: Result) => void> = [];
129 const restoreCallbacks: Array<() => void> = [];170 let expired = false;
130 const successCallbacks: Array<(result: Result) => void> = [];171 let rollbacks = 0;
131 const onRestore = (cb: () => void) => restoreCallbacks.push(cb);172 const onRestore = (cb: () => void) => {
132 const onSuccess = (cb: (result: Result) => void) =>173 if (expired) {
133 successCallbacks.push(cb);174 throw new Error(
175 "Can only call onRestore from within the optimistic update function.",
176 );
177 }
178 channel.rollbacks.push(cb);
179 rollbacks += 1;
180 };
181 const onRefetch = (cb: () => Promise<void>) => {
182 if (expired) {
183 throw new Error(
184 "Can only call onRefetch from within the optimistic update function.",
185 );
186 }
187 channel.refetches.push(cb);
188 };
134189
135 try {190 try {
136 this.#options.optimistic({191 this.#options.optimistic({
137 ...this.#client.context,
138 args,192 args,
193 helpers: this.#client.getOptimisticHelpers({
194 onRestore,
195 onRefetch,
196 }),
139 onRestore,197 onRestore,
140 onSuccess,198 onSuccess(cb) {
199 if (expired) {
200 throw new Error(
201 "Can only call onSuccess from within the optimistic update function.",
202 );
203 }
204 onSuccess.push(cb);
205 },
141 });206 });
207 } catch (error) {
208 expired = true;
209 let next;
210 while (
211 next =
212 channel.rollbacks.splice(channel.rollbacks.length - rollbacks, 1)[0]
213 ) {
214 next();
215 }
216 return Promise.reject(error);
217 }
218 expired = true;
219
220 const { promise, resolve, reject } = Promise.withResolvers<Result>();
221 channel.queue.push({
222 args,
223 rollbacks,
224 onSuccess,
225 resolve,
226 reject,
227 });
228
229 if (channel.status === "idle") {
230 this.#executeNext(key, channel);
231 }
232
233 return promise;
234 }
235
236 #executeNext(key: string, channel: Channel<Args, Result>) {
237 const item = channel.queue.shift();
238 if (!item) {
239 this.#setIdle(key, channel);
240 return;
241 }
242
243 const { args, onSuccess, resolve, reject } = item;
244 channel.status = "mutating";
245 this.#notify(channel, "mutating");
246
247 this.#options.mutate(this.#client.context, ...args).then((result) => {
248 // remove rollbacks and apply optimistic success handlers
249 channel.rollbacks.splice(0, item.rollbacks);
250 onSuccess.forEach((cb) => cb(result));
142251
143 const result = await this.#options.mutate(...args);
144 successCallbacks.forEach((cb) => cb(result));
145 if (this.#options.refetchOnSuccess !== false) {252 if (this.#options.refetchOnSuccess !== false) {
146 this.#options.refetch({253 channel.status = "refetching";
254 this.#notify(channel, "refetching", result);
255 // Call refetch and all refetch callbacks in parallel
256 const refetchCallbacks = channel.refetches.splice(0);
257 Promise.allSettled([
258 this.#options.refetch?.({
259 ...this.#client.context,
260 args,
261 }),
262 ...refetchCallbacks.map((cb) => cb()),
263 ]).then((results) => {
264 // Report any errors from refetch or callbacks
265 results.forEach((result) => {
266 if (result.status === "rejected") {
267 this.#client.reportError(result.reason);
268 }
269 });
270 }).finally(() => {
271 this.#executeNext(key, channel);
272 });
273 } else {
274 // Discard refetch callbacks if refetchOnSuccess is false
275 channel.refetches = [];
276 this.#executeNext(key, channel);
277 }
278 resolve(result);
279 }, (error) => {
280 // if an error happens, then every rollback is called in reverse order
281 let next;
282 while (next = channel.rollbacks.pop()) next();
283
284 // Cancel all remaining items in the queue
285 const remainingItems = channel.queue.splice(0);
286 remainingItems.forEach((queuedItem) => {
287 queuedItem.reject(error);
288 });
289
290 // Notify listeners of the error
291 this.#notify(channel, "mutating", null, error);
292
293 // Refetch to restore correct state
294 channel.status = "refetching";
295 this.#notify(channel, "refetching", null, error);
296 // Call refetch and all refetch callbacks in parallel
297 const refetchCallbacks = channel.refetches.splice(0);
298 Promise.allSettled([
299 this.#options.refetch?.({
147 ...this.#client.context,300 ...this.#client.context,
148 args,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 this.#client.reportError(result.reason);
309 }
149 });310 });
150 }311 }).finally(() => {
151 return result;312 this.#setIdle(key, channel);
152 } catch (err) {313 });
153 restoreCallbacks.forEach((cb) => cb());314
154 throw err;315 reject(error);
155 }316 });
156 }317 }
157}318}
src/react.tsx+241-82
...@@ -1,9 +1,3 @@...@@ -1,9 +1,3 @@
1import {
2 defineMutation,
3 type GlobalMutationContext,
4 Mutation,
5 type OptimisticContext,
6} from "./types.ts";
7import {1import {
8 type FC,2 type FC,
9 type MouseEvent,3 type MouseEvent,
...@@ -11,64 +5,245 @@ import {...@@ -11,64 +5,245 @@ import {
11 type ReactNode,5 type ReactNode,
12 useCallback,6 useCallback,
13 useEffect,7 useEffect,
14 useRef,
15 useState,8 useState,
16} from "react";9} from "react";
10import type { Mutation } from "./types.ts";
1711
12/**
13 * Subscribe to a mutation's status, as well as accessing a local `run` method.
14 * The resulting mutation can be used directly or passed to a {@link createMutationButton|mutation button}.
15 */
18export function useMutation<16export function useMutation<
19 Args extends unknown[],17 Args extends unknown[],
20 Result,18 Result,
21 M extends Mutation<Args, Result> | null,
22>(19>(
23 mutation: M extends Mutation<Args, Result> ? M : never,20 mutation: Mutation<Args, Result> | null,
24) {21): UseMutationResult<Args, Result> {
25 const ref = useRef<Observer | null>(null);
26 const [_, setRerender] = useState(0);22 const [_, setRerender] = useState(0);
27 ref.current ??= new Observer(setRerender);23 const [observer] = useState(() => new Observer<Args, Result>(setRerender));
28 useEffect(() => {24 useEffect(() => () => void observer.reset(), []);
29 ref.current ??= new Observer(setRerender);25 if (mutation !== observer.mutation) {
30 return () => {26 observer.mutation = mutation;
31 ref.current?.close();27 observer.reset();
32 ref.current = null;28 }
33 };29 return observer.binding;
34 }, []);30}
35 ref.current.mutation = mutation;31
36 return ref.current.binding;32export type UseMutationResult<Args extends unknown[], Result> =
33 & UseMutationResultBase<Args>
34 & (
35 | UseMutationSuccess<Result>
36 | UseMutationError
37 | UseMutationIdle
38 );
39
40export interface UseMutationResultBase<Args extends unknown[]> {
41 run: (...args: Args) => void;
42 clear: () => void;
43}
44
45export interface UseMutationSuccess<Result> {
46 status: "success";
47 result: Result;
48 error: undefined;
49 /** `true` when a `mutate` function is currently running. */
50 isMutating: false;
51 /** `true` when a loading indicator should be shown. */
52 isPending: false;
53 /** `true` when a mutation has completed and has a result. */
54 isSuccess: true;
55 /** `true` when a mutation has failed. */
56 isError: false;
57 /** `true` when there is optimistic state applied. */
58 isOptimisticData: boolean;
59}
60export interface UseMutationError {
61 status: "error";
62 result: undefined;
63 error: unknown;
64 /** `true` when a `mutate` function is currently running. */
65 isMutating: false;
66 /** `true` when a loading indicator should be shown. */
67 isPending: false;
68 /** `true` when a mutation has completed and has a result. */
69 isSuccess: false;
70 /** `true` when a mutation has failed. */
71 isError: true;
72 /** `true` when there is optimistic state applied. */
73 isOptimisticData: boolean;
74}
75export interface UseMutationIdle {
76 status: "idle" | "mutating";
77 result: undefined;
78 error: undefined;
79 /** `true` when a `mutate` function is currently running. */
80 isMutating: boolean;
81 /** `true` when a loading indicator should be shown. */
82 isPending: boolean;
83 /** `true` when a mutation has completed and has a result. */
84 isSuccess: false;
85 /** `true` when a mutation has failed. */
86 isError: false;
87 /** `true` when there is optimistic state applied. */
88 isOptimisticData: boolean;
37}89}
3890
39class Observer<A extends unknown[], R> {91type AnyMutationState<Result> =
40 setRerender: (n: number) => void;92 & Omit<
41 mutation: Mutation<A, R> | null = null;93 UseMutationIdle,
94 "status" | "result" | "error" | "isSuccess" | "isError"
95 >
96 & {
97 status: "idle" | "mutating" | "error" | "success";
98 result: undefined | Result;
99 error: undefined | unknown;
100 isSuccess: boolean;
101 isError: boolean;
102 };
42103
43 constructor(setRerender: (n: number) => void) {104function initialState() {
105 return {
106 status: "idle",
107 result: undefined,
108 error: undefined,
109 isMutating: false,
110 isPending: false,
111 isSuccess: false,
112 isError: false,
113 isOptimisticData: false,
114 } as const;
115}
116
117class Observer<Args extends unknown[], Result> {
118 setRerender: (fn: number) => void;
119 mutation: Mutation<Args, Result> | null = null;
120 unsubscribe: (() => void) | null = null;
121 currentKey: string | null = null;
122
123 constructor(setRerender: (fn: number) => void) {
44 this.setRerender = setRerender;124 this.setRerender = setRerender;
45 }125 }
46126
47 binding = ((self: this) => ({127 watched: Set<string> = new Set();
48 mutate(...args: A) {128 state: AnyMutationState<Result> = initialState();
49 self.mutation?.runAndReturn(...args);129 setState(newState: Partial<AnyMutationState<Result>>) {
130 let updateUi = false;
131 const current: Record<string, unknown> = this.state;
132 for (const [key, value] of Object.entries(newState)) {
133 if (value !== current[key]) {
134 current[key] = value;
135 updateUi ||= this.watched.has(key);
136 }
137 }
138 if (updateUi) {
139 this.setRerender(Math.random());
140 }
141 }
142
143 reset() {
144 this.unsubscribe?.();
145 this.unsubscribe = null;
146 this.currentKey = null;
147 this.state = initialState();
148 }
149
150 binding: UseMutationResult<Args, Result> = ((self: this) => ({
151 run(...args: Args) {
152 const mutation = self.mutation;
153 if (!mutation) return;
154 const key = mutation.key(args);
155 if (key !== self.currentKey) {
156 self.currentKey = key;
157 self.unsubscribe?.();
158 self.unsubscribe = mutation.subscribe(
159 mutation.key(args),
160 ({ status, error, result }) => {
161 if (status === "idle") {
162 self.setState({
163 isMutating: false,
164 isPending: false,
165 isOptimisticData: false,
166 });
167 return;
168 }
169 const hasError = error != null;
170 const hasResult = result != null;
171
172 self.setState({
173 status: hasError
174 ? "error"
175 : hasResult
176 ? "success"
177 : status === "mutating"
178 ? "mutating"
179 : "idle",
180 error: error ?? undefined,
181 result: result ?? undefined,
182 isMutating: status === "mutating",
183 isPending: status === "mutating" || status === "refetching",
184 isSuccess: hasResult && !hasError,
185 isError: hasError,
186 isOptimisticData: status === "waiting" || status === "mutating" ||
187 status === "refetching",
188 });
189 },
190 );
191 }
192 // use global error handling if this usage of the hook doesnt check for
193 // errors this makes it act pretty awesome in terms of defaults. you don't
194 // have to worry about the errors, they'll surface exactly once.
195 if (self.watched.has("isError") || self.watched.has("error")) {
196 mutation.runAndReturn(...args).catch(() => {
197 // caught in event listener
198 });
199 } else {
200 mutation.run(...args);
201 }
50 },202 },
51 get isPending() {203 clear() {
52 return false;204 self.setState({
205 status: ["error", "success"].includes(self.state.status)
206 ? "idle"
207 : self.state.status,
208 isError: false,
209 isSuccess: false,
210 error: undefined,
211 result: undefined,
212 });
53 },213 },
54 get isMutating() {214 get status() {
55 return false;215 self.watched.add("status");
216 return self.state.status;
56 },217 },
57 get isUpToDate() {218 get result() {
58 return true;219 self.watched.add("result");
220 return self.state.result;
59 },221 },
60 get error() {222 get error() {
61 return null;223 self.watched.add("error");
224 return self.state.error;
225 },
226 get isMutating() {
227 self.watched.add("isMutating");
228 return self.state.isMutating;
229 },
230 get isPending() {
231 self.watched.add("isPending");
232 return self.state.isPending;
62 },233 },
63 get isSuccess() {234 get isSuccess() {
64 return false;235 self.watched.add("isSuccess");
236 return self.state.isSuccess;
65 },237 },
66 get result() {238 get isError() {
67 return null;239 self.watched.add("isError");
240 return self.state.isError;
68 },241 },
69 }))(this);242 get isOptimisticData() {
70243 self.watched.add("isOptimisticData");
71 close() {}244 return self.state.isOptimisticData;
245 },
246 } as UseMutationResult<Args, Result>))(this);
72}247}
73248
74interface BaseButtonProps {249interface BaseButtonProps {
...@@ -86,7 +261,9 @@ interface MutationButtonComponent<Props> {...@@ -86,7 +261,9 @@ interface MutationButtonComponent<Props> {
86}261}
87262
88export interface MutationButtonProps<Args extends unknown[], Result> {263export interface MutationButtonProps<Args extends unknown[], Result> {
89 mutation: Mutation<Args, Result>;264 mutation:
265 | Mutation<Args, Result>
266 | Pick<UseMutationResult<Args, Result>, "run" | "status" | "isPending">;
90 /** Preventing default will interrupt the mutation */267 /** Preventing default will interrupt the mutation */
91 args: Args | ((e: MouseEvent) => Args | null);268 args: Args | ((e: MouseEvent) => Args | null);
92 /** Preventing default will interrupt the mutation */269 /** Preventing default will interrupt the mutation */
...@@ -96,13 +273,16 @@ export interface MutationButtonProps<Args extends unknown[], Result> {...@@ -96,13 +273,16 @@ export interface MutationButtonProps<Args extends unknown[], Result> {
96/**273/**
97 * Wraps a custom button component with logic to execute a mutation. The wrapped274 * Wraps a custom button component with logic to execute a mutation. The wrapped
98 * component must accept `onClick` and an `isPending` property. When the inner275 * component must accept `onClick` and an `isPending` property. When the inner
99 * component emits `onClick`, that will begin the mutation.276 * component emits `onClick`, that will begin the mutation. This is a trival
277 * abstraction on top of `useMutation`, but with type gymnastics to allow safe
278 * types.
100 */279 */
101export function createMutationButton<Props>(280export function createMutationButton<Props>(
102 // Prevent calling this function if missing `onClick`281 // Prevent calling this function if missing `onClick`
103 Component: Required<Props> extends BaseButtonProps ? FC<Props>282 base: Required<Props> extends BaseButtonProps ? FC<Props>
104 : "Missing required props",283 : "Base component is missing required props",
105): MutationButtonComponent<Flatten<Omit<Props, keyof BaseButtonProps>>> {284): MutationButtonComponent<Flatten<Omit<Props, keyof BaseButtonProps>>> {
285 const Component = base as ResolveMutationButtonFc<Props, unknown[], unknown>;
106 // apply the generics at a type level to allow `.bind` to work286 // apply the generics at a type level to allow `.bind` to work
107 type BareProps = Omit<Props, keyof BaseButtonProps>;287 type BareProps = Omit<Props, keyof BaseButtonProps>;
108 const bound = (GenericMutationButton<Props, unknown[], unknown>)288 const bound = (GenericMutationButton<Props, unknown[], unknown>)
...@@ -119,57 +299,36 @@ export function createMutationButton<Props>(...@@ -119,57 +299,36 @@ export function createMutationButton<Props>(
119299
120type Identity<T> = T;300type Identity<T> = T;
121type Flatten<T> = Identity<{ [K in keyof T]: T[K] }>;301type Flatten<T> = Identity<{ [K in keyof T]: T[K] }>;
302type ResolveMutationButtonFc<Props, Args extends unknown[], Result> = FC<
303 & Omit<Props, keyof MutationButtonProps<Args, Result>>
304 & BaseButtonProps
305>;
122306
123function GenericMutationButton<307function GenericMutationButton<
124 Props,308 Props,
125 Args extends unknown[],309 Args extends unknown[],
126 Result,310 Result,
127>(311>(
128 Component: FC<Props>,312 Component: ResolveMutationButtonFc<Props, Args, Result>,
129 props: MutationButtonProps<Args, Result> & Props,313 props: MutationButtonProps<Args, Result> & Props,
130) {314) {
131 const { mutation, args, onClick, ...forwarded } = props;315 const { mutation, args, onClick, ...forwarded } = props;
132 forwarded satisfies Omit<Props, keyof MutationButtonProps<Args, Result>>;316 forwarded satisfies Omit<Props, keyof MutationButtonProps<Args, Result>>;
133317
134 const state = useMutation(mutation);318 const localHook = useMutation("subscribe" in mutation ? mutation : null);
319 const state = "subscribe" in mutation ? localHook : mutation;
135320
136 return (321 return (
137 <Component322 <Component
138 {...forwarded}323 {...forwarded}
324 onClick={useCallback((e: MouseEvent) => {
325 onClick?.(e);
326 if (e.defaultPrevented) return;
327 const computedArgs = typeof args === "function" ? args(e) : args;
328 if (!computedArgs || e.defaultPrevented) return;
329 state.run(...computedArgs);
330 }, [state])}
331 isPending={state.isPending}
139 />332 />
140 ) // onClick={useCallback((e) => {333 );
141 ; // onClick?.(e);
142 // if (e.defaultPrevented) return;
143 // const computedArgs = typeof args === "function" ? args(e) : args;
144 // if (e.defaultPrevented) return;
145 // state.run(computedArgs);
146 // }, [state])}
147 // isPending={state.isPending}
148}
149
150function BaseButton(
151 props: { a: boolean; isPending: boolean; onClick?: (e: MouseEvent) => void },
152) {
153 /** Preventing default will interrupt the mutation */
154 return <button type="button" />;
155}334}
156const DefaultMutationButton = createMutationButton(BaseButton);
157
158const def = defineMutation({
159 async mutate(meow: string) {
160 return true;
161 },
162 describe: "meow",
163 optimistic(context) {
164 },
165 async refetch(context) {
166 },
167});
168
169const y = (
170 <DefaultMutationButton
171 mutation={def}
172 args={["meow"]}
173 a={false}
174 />
175);
src/register.ts deleted-9
...@@ -1,9 +0,0 @@
1export interface MutationClientConfig {
2 context: {};
3 optimisticHelpers: {};
4}
5
6export interface GlobalMutationConfig {
7 context: {};
8 optimisticHelpers: {};
9}
src/tanstack-query.ts created+403
...@@ -0,0 +1,403 @@
1import {
2 QueryClient,
3 QueryFunction,
4 QueryFunctionContext,
5 QueryKey,
6 QueryOptions,
7 SkipToken,
8 Updater,
9 UseQueryOptions,
10} from "@tanstack/react-query";
11import {
12 type AllObjectPaths,
13 type GetObjectPath,
14 getPath as getPath,
15 setPath as setPath,
16} from "./object-path.ts";
17import { OptimisticEvents } from "./client.ts";
18
19export type QueryKeyAndFn<T = unknown> = {
20 queryKey: QueryKey;
21 queryFn?: QueryFunction<T, any, never> | undefined;
22};
23
24export function queryClientOptimisticHelpers(client: QueryClient) {
25 return ({ onRefetch, onRestore }: OptimisticEvents) => {
26 const refetchHashes: string[] = [];
27
28 function get<T>({ queryKey }: QueryKeyAndFn<T>) {
29 return client.getQueryData<T>(queryKey);
30 }
31 function set<T>(
32 query: QueryKeyAndFn<T>,
33 updater: Updater<NoInfer<T> | undefined, NoInfer<T> | undefined>,
34 ) {
35 const result = client.setQueryData<T>(query.queryKey, updater);
36 if (result) {
37 client.cancelQueries({ queryKey: query.queryKey, exact: true })
38 .catch(() => {});
39 const state = client.getQueryCache().find({
40 queryKey: query.queryKey,
41 exact: true,
42 });
43 if (!state || refetchHashes.includes(state.queryHash)) return;
44 refetchHashes.push(state.queryHash);
45 onRefetch(async () => {
46 await client.refetchQueries(query);
47 });
48 }
49 }
50
51 return ({
52 /**
53 * Set the entire query data.
54 * If the query doesn't exist, the new query is created.
55 */
56 set<Data>(
57 queryKey: QueryKeyAndFn<Data>,
58 value: Data | ((prev: Data | undefined) => Data | undefined),
59 ) {
60 const prev = get(queryKey);
61
62 const newValue = typeof value === "function"
63 ? (value as (prev: Data | undefined) => Data | undefined)(prev)
64 : value;
65
66 set(queryKey, newValue);
67 onRestore(() => {
68 if (prev === undefined) {
69 client.removeQueries({ queryKey: queryKey.queryKey, exact: true });
70 } else {
71 set(queryKey, prev);
72 }
73 });
74 },
75
76 /**
77 * Update the entire query data.
78 * If the query doesn't exist, it cancels
79 */
80 updateExisting<Data>(
81 queryKey: QueryKeyAndFn<Data>,
82 value: Data | ((prev: Data) => Data),
83 ) {
84 const prev = get(queryKey);
85 if (!prev) return false;
86
87 const newValue = typeof value === "function"
88 ? (value as (prev: Data | undefined) => Data | undefined)(prev)
89 : value;
90
91 set(queryKey, newValue);
92 onRestore(() => {
93 set(queryKey, prev);
94 });
95 },
96
97 /**
98 * Set a property at an object path.
99 * If the query or path doesn't exist, the updater is skipped.
100 */
101 objSet<Data extends object, const Path extends AllObjectPaths<Data>>(
102 queryKey: QueryKeyAndFn<Data>,
103 path: Path,
104 value:
105 | Exclude<GetObjectPath<Data, Path>, Function>
106 | ((prev: GetObjectPath<Data, Path>) => GetObjectPath<Data, Path>),
107 ) {
108 const prev = get(queryKey);
109 if (!prev) return;
110 const { value: original, exists } = getPath(prev, path);
111 if (!exists) return;
112
113 set(
114 queryKey,
115 (obj) =>
116 obj
117 ? setPath(
118 obj,
119 path,
120 typeof value === "function"
121 ? (value as ((
122 prev: GetObjectPath<Data, Path>,
123 ) => GetObjectPath<Data, Path>))(original)
124 : value,
125 )
126 : obj,
127 );
128 onRestore(() => {
129 set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
130 });
131 },
132
133 /**
134 * Increment a numeric property at an object path.
135 * If the query or path doesn't exist, the updater is skipped.
136 */
137 objIncrement<
138 Data extends object,
139 const Path extends AllObjectPaths<Data>,
140 >(
141 queryKey: QueryKeyAndFn<Data>,
142 path: Path,
143 amount: number = 1,
144 ) {
145 const prev = get(queryKey);
146 if (!prev) return;
147 const { value: original, exists } = getPath(prev, path);
148 if (!exists || typeof original !== "number") return;
149
150 set(
151 queryKey,
152 (obj) => obj ? setPath(obj, path, (original + amount) as any) : obj,
153 );
154 onRestore(() => {
155 set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
156 });
157 },
158
159 /**
160 * Decrement a numeric property at an object path.
161 * If the query or path doesn't exist, the updater is skipped.
162 */
163 objDecrement<
164 Data extends object,
165 const Path extends AllObjectPaths<Data>,
166 >(
167 queryKey: QueryKeyAndFn<Data>,
168 path: Path,
169 amount: number = 1,
170 ) {
171 const prev = get(queryKey);
172 if (!prev) return;
173 const { value: original, exists } = getPath(prev, path);
174 if (!exists || typeof original !== "number") return;
175
176 set(
177 queryKey,
178 (obj) => obj ? setPath(obj, path, (original - amount) as any) : obj,
179 );
180 onRestore(() => {
181 set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
182 });
183 },
184
185 /**
186 * Toggle a boolean property at an object path.
187 * If the query or path doesn't exist, the updater is skipped.
188 */
189 objToggle<Data extends object, const Path extends AllObjectPaths<Data>>(
190 queryKey: QueryKeyAndFn<Data>,
191 path: Path,
192 ) {
193 const prev = get(queryKey);
194 if (!prev) return;
195 const { value: original, exists } = getPath(prev, path);
196 if (!exists || typeof original !== "boolean") return;
197
198 set(
199 queryKey,
200 (obj) => obj ? setPath(obj, path, (!original) as any) : obj,
201 );
202 onRestore(() => {
203 set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
204 });
205 },
206
207 /**
208 * Shallow merge multiple properties at an object path.
209 * If the query or path doesn't exist, the updater is skipped.
210 */
211 objSetMany<Data extends object, const Path extends AllObjectPaths<Data>>(
212 queryKey: QueryKeyAndFn<Data>,
213 path: Path,
214 updates: Partial<GetObjectPath<Data, Path>>,
215 ) {
216 const prev = get(queryKey);
217 if (!prev) return;
218 const { value: original, exists } = getPath(prev, path);
219 if (!exists || typeof original !== "object" || original === null) {
220 return;
221 }
222
223 const merged = { ...original as object, ...updates } as GetObjectPath<
224 Data,
225 Path
226 >;
227 set(
228 queryKey,
229 (obj) => obj ? setPath(obj, path, merged) : obj,
230 );
231 onRestore(() => {
232 set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
233 });
234 },
235
236 /**
237 * Push item(s) to the end of an array at an object path.
238 * If the query or path doesn't exist, the updater is skipped.
239 */
240 arrayPush<Data extends object, const Path extends AllObjectPaths<Data>>(
241 queryKey: QueryKeyAndFn<Data>,
242 path: Path,
243 ...items: GetObjectPath<Data, Path> extends Array<infer T> ? T[]
244 : never
245 ) {
246 const prev = get(queryKey);
247 if (!prev) return;
248 const { value: original, exists } = getPath(prev, path);
249 if (!exists || !Array.isArray(original)) return;
250
251 const newArray = [...original, ...items];
252 set(
253 queryKey,
254 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
255 );
256 onRestore(() => {
257 set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
258 });
259 },
260
261 /**
262 * Add item(s) to the beginning of an array at an object path.
263 * If the query or path doesn't exist, the updater is skipped.
264 */
265 arrayUnshift<
266 Data extends object,
267 const Path extends AllObjectPaths<Data>,
268 >(
269 queryKey: QueryKeyAndFn<Data>,
270 path: Path,
271 ...items: GetObjectPath<Data, Path> extends Array<infer T> ? T[]
272 : never
273 ) {
274 const prev = get(queryKey);
275 if (!prev) return;
276 const { value: original, exists } = getPath(prev, path);
277 if (!exists || !Array.isArray(original)) return;
278
279 const newArray = [...items, ...original];
280 set(
281 queryKey,
282 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
283 );
284 onRestore(() => {
285 set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
286 });
287 },
288
289 /**
290 * Remove items from an array that match a predicate.
291 * If the query or path doesn't exist, the updater is skipped.
292 */
293 arrayRemoveItem<
294 Data extends object,
295 const Path extends AllObjectPaths<Data>,
296 >(
297 queryKey: QueryKeyAndFn<Data>,
298 path: Path,
299 predicate: (
300 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
301 index: number,
302 ) => boolean,
303 ) {
304 const prev = get(queryKey);
305 if (!prev) return;
306 const { value: original, exists } = getPath(prev, path);
307 if (!exists || !Array.isArray(original)) return;
308
309 const newArray = original.filter((item, index) =>
310 !predicate(item, index)
311 );
312 set(
313 queryKey,
314 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
315 );
316 onRestore(() => {
317 set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
318 });
319 },
320
321 /**
322 * Update items in an array that match a predicate.
323 * If the query or path doesn't exist, the updater is skipped.
324 */
325 arrayUpdateItem<
326 Data extends object,
327 const Path extends AllObjectPaths<Data>,
328 >(
329 queryKey: QueryKeyAndFn<Data>,
330 path: Path,
331 predicate: (
332 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
333 index: number,
334 ) => boolean,
335 updater: (
336 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
337 ) => GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
338 ) {
339 const prev = get(queryKey);
340 if (!prev) return;
341 const { value: original, exists } = getPath(prev, path);
342 if (!exists || !Array.isArray(original)) return;
343
344 const newArray = original.map((item, index) =>
345 predicate(item, index) ? updater(item) : item
346 );
347 set(
348 queryKey,
349 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
350 );
351 onRestore(() => {
352 set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
353 });
354 },
355
356 /**
357 * Insert item(s) at a specific index in an array.
358 * If the query or path doesn't exist, the updater is skipped.
359 */
360 arrayInsertIndex<
361 Data extends object,
362 const Path extends AllObjectPaths<Data>,
363 >(
364 queryKey: QueryKeyAndFn<Data>,
365 path: Path,
366 index: number,
367 ...items: GetObjectPath<Data, Path> extends Array<infer T> ? T[]
368 : never
369 ) {
370 const prev = get(queryKey);
371 if (!prev) return;
372 const { value: original, exists } = getPath(prev, path);
373 if (!exists || !Array.isArray(original)) return;
374
375 const newArray = [
376 ...original.slice(0, index),
377 ...items,
378 ...original.slice(index),
379 ];
380 set(
381 queryKey,
382 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
383 );
384 onRestore(() => {
385 set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
386 });
387 },
388
389 /**
390 * Remove a query from the cache entirely.
391 */
392 removeQuery(queryKey: QueryKeyAndFn) {
393 const prev = get(queryKey);
394 if (!prev) return;
395
396 client.removeQueries({ queryKey: queryKey.queryKey, exact: true });
397 onRestore(() => {
398 set(queryKey, prev);
399 });
400 },
401 });
402 };
403}
src/types.ts+5-4
...@@ -1,20 +1,21 @@...@@ -1,20 +1,21 @@
1export interface Mutation<Args extends unknown[], Result> {1export interface Mutation<Args extends unknown[], Result> {
2 /** Calling the mutation. Errors are turned into UI toasts. */2 /** Calling the mutation. Errors are turned into UI toasts. */
3 run(...args: Args): void;3 run(...args: Args): void;
4
5 /** Calls the mutation, treating the errors as promise rejection. */4 /** Calls the mutation, treating the errors as promise rejection. */
6 runAndReturn(...args: Args): Promise<Result>;5 runAndReturn(...args: Args): Promise<Result>;
76
7 /** Returns the concurrency key used for a given set of arguments */
8 key(args: Args): string;
9 /** Subscribe to status changes using the key from `key()` */
8 subscribe(10 subscribe(
9 args: Args,11 key: string,
10 cb: (update: MutationEvent<Result>) => void,12 cb: (update: MutationEvent<Result>) => void,
11 ): () => void;13 ): () => void;
12
13 describe(...args: Args): string;14 describe(...args: Args): string;
14}15}
1516
16export interface MutationEvent<Result> {17export interface MutationEvent<Result> {
17 status: "waiting" | "mutating" | "refetching";18 status: "idle" | "waiting" | "mutating" | "refetching";
18 result: Result | null;19 result: Result | null;
19 error: unknown;20 error: unknown;
20}21}
test/batch.test.ts created+1018
...@@ -0,0 +1,1018 @@
1import { assertEquals, assertRejects } from "@std/assert";
2import { MutationClient } from "../src/client.ts";
3import type { MutationEvent } from "../src/types.ts";
4import { test } from "vitest";
5import { UIEventHandler } from "react";
6
7// Shared test store for optimistic updates
8const testStore = new Map<string, number>();
9
10// Helper to create a test mutation client
11function createTestClient() {
12 const errors: unknown[] = [];
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(error) {
36 errors.push(error);
37 },
38 });
39
40 return { client, errors };
41}
42
43// Helper to track mutation events
44function createEventTracker<Result>() {
45 const events: Array<MutationEvent<Result>> = [];
46 const callback = (event: MutationEvent<Result>) => {
47 events.push({ ...event });
48 };
49 return { events, callback };
50}
51
52// Helper to wait for async operations
53function delay(ms: number) {
54 return new Promise((resolve) => setTimeout(resolve, ms));
55}
56
57// ============================================================================
58// Basic functionality tests
59// ============================================================================
60
61test("BatchMutation - basic mutation success with debounce", async () => {
62 const { client } = createTestClient();
63 testStore.clear();
64 testStore.set("counter", 0);
65
66 let commitCallCount = 0;
67 let refetchCallCount = 0;
68
69 const mutation = client.defineBatched({
70 optimistic({ helpers }, amount: number) {
71 helpers.increment("counter", amount);
72 },
73 mode: "debounce",
74 time: 50,
75 key: () => "test-key",
76 getValue: (_) => testStore.get("counter") ?? 0,
77 async commit({ initial, current }) {
78 commitCallCount++;
79 await delay(10);
80 return current - initial;
81 },
82 describe: "increment counter",
83 async refetch() {
84 refetchCallCount++;
85 },
86 });
87
88 const result = await mutation.runAndReturn(5);
89 await delay(20); // Wait for refetch
90
91 assertEquals(result, 5);
92 assertEquals(commitCallCount, 1);
93 assertEquals(refetchCallCount, 1);
94 assertEquals(testStore.get("counter"), 5);
95});
96
97test("BatchMutation - run() catches errors", async () => {
98 const { client, errors } = createTestClient();
99 testStore.clear();
100 testStore.set("counter", 0);
101
102 const mutation = client.defineBatched({
103 optimistic({ helpers }, amount: number) {
104 helpers.increment("counter", amount);
105 },
106 mode: "debounce",
107 time: 20,
108 key: () => "test-key",
109 getValue: (_) => testStore.get("counter") ?? 0,
110 async commit() {
111 throw new Error("commit failed");
112 },
113 describe: "failing mutation",
114 async refetch() {},
115 });
116
117 mutation.run(5);
118 await delay(100);
119
120 assertEquals(errors.length, 1);
121 assertEquals((errors[0] as Error).message, "commit failed");
122});
123
124test("BatchMutation - runAndReturn() rejects on error", async () => {
125 const { client } = createTestClient();
126 testStore.clear();
127 testStore.set("counter", 0);
128
129 const mutation = client.defineBatched({
130 optimistic({ helpers }, amount: number) {
131 helpers.increment("counter", amount);
132 },
133 mode: "debounce",
134 time: 10,
135 key: () => "test-key",
136 getValue: (_) => testStore.get("counter") ?? 0,
137 async commit() {
138 throw new Error("commit failed");
139 },
140 describe: "failing mutation",
141 async refetch() {},
142 });
143
144 await assertRejects(
145 () => mutation.runAndReturn(5),
146 Error,
147 "commit failed",
148 );
149});
150
151// ============================================================================
152// Debounce mode tests
153// ============================================================================
154
155test("BatchMutation - debounce batches rapid calls", async () => {
156 const { client } = createTestClient();
157 testStore.clear();
158 testStore.set("counter", 0);
159
160 let commitCallCount = 0;
161 const commitArgs: Array<{ initial: number; current: number }> = [];
162
163 const mutation = client.defineBatched({
164 optimistic({ helpers }, amount: number) {
165 helpers.increment("counter", amount);
166 },
167 mode: "debounce",
168 time: 50,
169 key: () => "test-key",
170 getValue: (_) => testStore.get("counter") ?? 0,
171 async commit({ initial, current }) {
172 commitCallCount++;
173 commitArgs.push({ initial, current });
174 return current - initial;
175 },
176 describe: "increment counter",
177 async refetch() {},
178 });
179
180 // Rapid calls within debounce window
181 const promise1 = mutation.runAndReturn(1);
182 const promise2 = mutation.runAndReturn(2);
183 const promise3 = mutation.runAndReturn(3);
184
185 // Optimistic updates should be applied immediately
186 assertEquals(testStore.get("counter"), 6);
187
188 const results = await Promise.all([promise1, promise2, promise3]);
189
190 // All should resolve with the same result (total delta)
191 assertEquals(results, [6, 6, 6]);
192
193 // Only one commit should have been made
194 assertEquals(commitCallCount, 1);
195 assertEquals(commitArgs, [{ initial: 0, current: 6 }]);
196});
197
198test("BatchMutation - debounce resets timer on each call", async () => {
199 const { client } = createTestClient();
200 testStore.clear();
201 testStore.set("counter", 0);
202
203 let commitCallCount = 0;
204
205 const mutation = client.defineBatched({
206 optimistic({ helpers }, amount: number) {
207 helpers.increment("counter", amount);
208 },
209 mode: "debounce",
210 time: 30,
211 key: () => "test-key",
212 getValue: (_) => testStore.get("counter") ?? 0,
213 async commit({ initial, current }) {
214 commitCallCount++;
215 return current - initial;
216 },
217 describe: "increment counter",
218 async refetch() {},
219 });
220
221 // First call
222 const promise1 = mutation.runAndReturn(1);
223
224 // Wait less than debounce time
225 await delay(15);
226
227 // Second call should reset the timer
228 const promise2 = mutation.runAndReturn(2);
229
230 // Wait less than debounce time again
231 await delay(15);
232
233 // Commit should not have happened yet
234 assertEquals(commitCallCount, 0);
235
236 // Third call
237 const promise3 = mutation.runAndReturn(3);
238
239 // Wait for all to complete
240 await Promise.all([promise1, promise2, promise3]);
241
242 // Only one commit
243 assertEquals(commitCallCount, 1);
244});
245
246test("BatchMutation - debounce separates batches after timeout", async () => {
247 const { client } = createTestClient();
248 testStore.clear();
249 testStore.set("counter", 0);
250
251 let commitCallCount = 0;
252 const commitArgs: Array<{ initial: number; current: number }> = [];
253
254 const mutation = client.defineBatched({
255 optimistic({ helpers }, amount: number) {
256 helpers.increment("counter", amount);
257 },
258 mode: "debounce",
259 time: 30,
260 key: () => "test-key",
261 getValue: (_) => testStore.get("counter") ?? 0,
262 async commit({ initial, current }) {
263 commitCallCount++;
264 commitArgs.push({ initial, current });
265 return current - initial;
266 },
267 describe: "increment counter",
268 async refetch() {},
269 });
270
271 // First batch
272 await mutation.runAndReturn(1);
273 await delay(50); // Wait for first batch to complete
274
275 // Second batch (after timeout)
276 await mutation.runAndReturn(2);
277 await delay(50);
278
279 // Two separate commits
280 assertEquals(commitCallCount, 2);
281 assertEquals(commitArgs, [
282 { initial: 0, current: 1 },
283 { initial: 1, current: 3 },
284 ]);
285});
286
287// ============================================================================
288// Throttle mode tests
289// ============================================================================
290
291test("BatchMutation - throttle commits immediately on first call", async () => {
292 const { client } = createTestClient();
293 testStore.clear();
294 testStore.set("counter", 0);
295
296 let commitTime = 0;
297 const startTime = Date.now();
298
299 const mutation = client.defineBatched({
300 optimistic({ helpers }, amount: number) {
301 helpers.increment("counter", amount);
302 },
303 mode: "throttle",
304 time: 100,
305 key: () => "test-key",
306 getValue: (_) => testStore.get("counter") ?? 0,
307 async commit({ initial, current }) {
308 commitTime = Date.now() - startTime;
309 return current - initial;
310 },
311 describe: "increment counter",
312 async refetch() {},
313 });
314
315 await mutation.runAndReturn(5);
316
317 // First call should commit immediately (within a small tolerance)
318 assertEquals(commitTime < 20, true);
319});
320
321test("BatchMutation - throttle batches calls within time window", async () => {
322 const { client } = createTestClient();
323 testStore.clear();
324 testStore.set("counter", 0);
325
326 let commitCallCount = 0;
327 const commitArgs: Array<{ initial: number; current: number }> = [];
328
329 const mutation = client.defineBatched({
330 optimistic({ helpers }, amount: number) {
331 helpers.increment("counter", amount);
332 },
333 mode: "throttle",
334 time: 100,
335 key: () => "test-key",
336 getValue: (_) => testStore.get("counter") ?? 0,
337 async commit({ initial, current }) {
338 commitCallCount++;
339 commitArgs.push({ initial, current });
340 await delay(10);
341 return current - initial;
342 },
343 describe: "increment counter",
344 async refetch() {},
345 });
346
347 // First call commits immediately
348 const promise1 = mutation.runAndReturn(1);
349 await delay(5);
350
351 // Second call within throttle window - should batch
352 const promise2 = mutation.runAndReturn(2);
353 await delay(5);
354
355 // Third call within throttle window - should batch with second
356 const promise3 = mutation.runAndReturn(3);
357
358 // Wait for first to complete
359 await promise1;
360
361 // First commit happened immediately
362 assertEquals(commitCallCount, 1);
363 assertEquals(commitArgs[0], { initial: 0, current: 1 });
364
365 // Wait for throttle window to pass and second batch to commit
366 await Promise.all([promise2, promise3]);
367 await delay(50);
368
369 // Second batch committed
370 assertEquals(commitCallCount, 2);
371 assertEquals(commitArgs[1], { initial: 1, current: 6 });
372});
373
374test("BatchMutation - throttle allows new batch after time window", async () => {
375 const { client } = createTestClient();
376 testStore.clear();
377 testStore.set("counter", 0);
378
379 let commitCallCount = 0;
380
381 const mutation = client.defineBatched({
382 optimistic({ helpers }, amount: number) {
383 helpers.increment("counter", amount);
384 },
385 mode: "throttle",
386 time: 50,
387 key: () => "test-key",
388 getValue: (_) => testStore.get("counter") ?? 0,
389 async commit({ initial, current }) {
390 commitCallCount++;
391 return current - initial;
392 },
393 describe: "increment counter",
394 async refetch() {},
395 });
396
397 // First call
398 await mutation.runAndReturn(1);
399 await delay(10);
400
401 assertEquals(commitCallCount, 1);
402
403 // Wait for throttle window to pass
404 await delay(60);
405
406 // Second call should commit immediately
407 await mutation.runAndReturn(2);
408 await delay(10);
409
410 assertEquals(commitCallCount, 2);
411});
412
413// ============================================================================
414// No-op detection tests
415// ============================================================================
416
417test("BatchMutation - skips commit when value unchanged", async () => {
418 const { client } = createTestClient();
419 testStore.clear();
420 testStore.set("counter", 5);
421
422 let commitCallCount = 0;
423
424 const mutation = client.defineBatched({
425 optimistic({ helpers }, amount: number) {
426 helpers.increment("counter", amount);
427 },
428 mode: "debounce",
429 time: 20,
430 key: () => "test-key",
431 getValue: (_) => testStore.get("counter") ?? 0,
432 async commit({ initial, current }) {
433 commitCallCount++;
434 return current - initial;
435 },
436 describe: "increment counter",
437 async refetch() {},
438 });
439
440 // +5 and -5 cancel out
441 const promise1 = mutation.runAndReturn(5);
442 const promise2 = mutation.runAndReturn(-5);
443
444 const [result1, result2] = await Promise.all([promise1, promise2]);
445
446 // No commit should have been made
447 assertEquals(commitCallCount, 0);
448
449 // Results should be null (no actual change)
450 assertEquals(result1, null);
451 assertEquals(result2, null);
452
453 // Store should be unchanged
454 assertEquals(testStore.get("counter"), 5);
455});
456
457test("BatchMutation - uses deepEquals for comparison", async () => {
458 const errors: unknown[] = [];
459 const objectStore: { value: { count: number } | null } = {
460 value: { count: 0 },
461 };
462
463 const client = new MutationClient({
464 context: {},
465 getOptimisticHelpers({ onRestore }) {
466 return {
467 setCount(count: number) {
468 const old = objectStore.value;
469 objectStore.value = { count };
470 onRestore(() => {
471 objectStore.value = old;
472 });
473 },
474 };
475 },
476 reportError(error) {
477 errors.push(error);
478 },
479 });
480
481 let commitCallCount = 0;
482
483 const mutation = client.defineBatched({
484 optimistic({ helpers }, count: number) {
485 helpers.setCount(count);
486 },
487 mode: "debounce",
488 time: 20,
489 key: () => "test-key",
490 getValue: (_) => objectStore.value,
491 async commit() {
492 commitCallCount++;
493 return null;
494 },
495 describe: "set count",
496 async refetch() {},
497 });
498
499 // Set to same value (different object reference but same content)
500 await mutation.runAndReturn(0);
501 await delay(30);
502
503 // Should skip commit because value is deeply equal
504 assertEquals(commitCallCount, 0);
505});
506
507test("BatchMutation - custom deepEquals function", async () => {
508 const errors: unknown[] = [];
509 let compareCallCount = 0;
510
511 const client = new MutationClient({
512 context: {},
513 getOptimisticHelpers({ onRestore }) {
514 return {
515 increment(key: string, amount: number) {
516 const old = testStore.get(key) ?? 0;
517 testStore.set(key, old + amount);
518 onRestore(() => testStore.set(key, old));
519 },
520 };
521 },
522 reportError(error) {
523 errors.push(error);
524 },
525 deepEquals(a, b) {
526 compareCallCount++;
527 // Custom comparison
528 return a === b;
529 },
530 });
531
532 testStore.clear();
533 testStore.set("counter", 0);
534
535 const mutation = client.defineBatched({
536 optimistic({ helpers }, amount: number) {
537 helpers.increment("counter", amount);
538 },
539 mode: "debounce",
540 time: 10,
541 key: () => "test-key",
542 getValue: (_) => testStore.get("counter") ?? 0,
543 async commit() {
544 throw new Error("commit failed");
545 },
546 describe: "failing mutation",
547 async refetch() {},
548 });
549
550 await mutation.runAndReturn(5);
551 await delay(30);
552
553 // Custom deepEquals should have been called
554 assertEquals(compareCallCount > 0, true);
555});
556
557// ============================================================================
558// Rollback tests
559// ============================================================================
560
561test("BatchMutation - rollback on commit error", async () => {
562 const { client } = createTestClient();
563 testStore.clear();
564 testStore.set("counter", 10);
565
566 const mutation = client.defineBatched({
567 optimistic({ helpers }, amount: number) {
568 helpers.increment("counter", amount);
569 },
570 mode: "debounce",
571 time: 20,
572 key: () => "test-key",
573 getValue: (_) => testStore.get("counter") ?? 0,
574 async commit() {
575 throw new Error("commit failed");
576 },
577 describe: "failing mutation",
578 async refetch() {},
579 });
580
581 // Optimistic update applied
582 const promise = mutation.runAndReturn(5);
583 assertEquals(testStore.get("counter"), 15);
584
585 await assertRejects(() => promise, Error, "commit failed");
586
587 // Should be rolled back
588 assertEquals(testStore.get("counter"), 10);
589});
590
591test("BatchMutation - error event includes error details", async () => {
592 const { client } = createTestClient();
593 testStore.clear();
594 testStore.set("counter", 0);
595
596 const tracker = createEventTracker<number>();
597
598 const mutation = client.defineBatched({
599 optimistic({ helpers }, amount: number) {
600 helpers.increment("counter", amount);
601 },
602 mode: "debounce",
603 time: 20,
604 key: () => "test-key",
605 getValue: (_) => testStore.get("counter") ?? 0,
606 async commit() {
607 throw new Error("commit failed");
608 },
609 describe: "failing mutation",
610 async refetch() {},
611 });
612
613 const key = mutation.key([5]);
614 mutation.subscribe(key, tracker.callback);
615
616 await assertRejects(() => mutation.runAndReturn(5));
617 await delay(30);
618
619 // Should have error in events
620 const errorEvents = tracker.events.filter((e) => e.error !== null);
621 assertEquals(errorEvents.length > 0, true);
622 assertEquals((errorEvents[0]?.error as Error).message, "commit failed");
623});
624
625// ============================================================================
626// Key handling tests
627// ============================================================================
628
629test("BatchMutation - key() returns JSON stringified key", () => {
630 const { client } = createTestClient();
631 testStore.clear();
632
633 const mutation = client.defineBatched({
634 optimistic(_ctx, _id: string) {},
635 mode: "debounce",
636 time: 20,
637 key: ({ args }) => args[0],
638 getValue: (_) => 0,
639 async commit() {
640 return null;
641 },
642 describe: "test mutation",
643 async refetch() {},
644 });
645
646 assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id"));
647});
648
649test("BatchMutation - key() can return array", () => {
650 const { client } = createTestClient();
651 testStore.clear();
652
653 const mutation = client.defineBatched({
654 optimistic(_ctx, _id: string) {},
655 mode: "debounce",
656 time: 20,
657 key: ({ args }) => ["user", args[0]],
658 getValue: (_) => 0,
659 async commit() {
660 return null;
661 },
662 describe: "test mutation",
663 async refetch() {},
664 });
665
666 assertEquals(
667 mutation.key(["123"]),
668 JSON.stringify(["user", "123"]),
669 );
670});
671
672test("BatchMutation - different keys create separate batches", async () => {
673 const { client } = createTestClient();
674 testStore.clear();
675 testStore.set("counter-a", 0);
676 testStore.set("counter-b", 0);
677
678 let commitCallCount = 0;
679
680 const mutation = client.defineBatched({
681 optimistic({ helpers }, key: string, amount: number) {
682 helpers.increment(`counter-${key}`, amount);
683 },
684 mode: "debounce",
685 time: 20,
686 key: ({ args }) => args[0],
687 getValue: (_, key) => testStore.get(`counter-${key}`) ?? 0,
688 async commit({ current }) {
689 commitCallCount++;
690 return current;
691 },
692 describe: "test mutation",
693 async refetch() {},
694 });
695
696 // Two different keys
697 const promise1 = mutation.runAndReturn("a", 5);
698 const promise2 = mutation.runAndReturn("b", 10);
699
700 await Promise.all([promise1, promise2]);
701 await delay(30);
702
703 // Should have two separate commits
704 assertEquals(commitCallCount, 2);
705 assertEquals(testStore.get("counter-a"), 5);
706 assertEquals(testStore.get("counter-b"), 10);
707});
708
709// ============================================================================
710// Describe tests
711// ============================================================================
712
713test("BatchMutation - describe() with string", () => {
714 const { client } = createTestClient();
715 testStore.clear();
716
717 const mutation = client.defineBatched({
718 optimistic(_ctx, _amount: number) {},
719 mode: "debounce",
720 time: 20,
721 key: () => "test-key",
722 getValue: (_) => 0,
723 async commit() {
724 return null;
725 },
726 describe: "update counter",
727 async refetch() {},
728 });
729
730 assertEquals(mutation.describe(5), "update counter");
731});
732
733test("BatchMutation - describe() with function", () => {
734 const { client } = createTestClient();
735 testStore.clear();
736
737 const mutation = client.defineBatched({
738 optimistic(_ctx, _amount: number) {},
739 mode: "debounce",
740 time: 20,
741 key: () => "test-key",
742 getValue: (_) => 0,
743 async commit() {
744 return null;
745 },
746 describe: ({ args }) => `increment by ${args[0]}`,
747 async refetch() {},
748 });
749
750 assertEquals(mutation.describe(5), "increment by 5");
751});
752
753// ============================================================================
754// Promise resolution tests
755// ============================================================================
756
757test("BatchMutation - all pending promises resolve with same result", async () => {
758 const { client } = createTestClient();
759 testStore.clear();
760 testStore.set("counter", 0);
761
762 const mutation = client.defineBatched({
763 optimistic({ helpers }, amount: number) {
764 helpers.increment("counter", amount);
765 },
766 mode: "debounce",
767 time: 30,
768 key: () => "test-key",
769 getValue: (_) => testStore.get("counter") ?? 0,
770 async commit({ initial, current }) {
771 return { delta: current - initial, timestamp: Date.now() };
772 },
773 describe: "increment counter",
774 async refetch() {},
775 });
776
777 const promise1 = mutation.runAndReturn(1);
778 const promise2 = mutation.runAndReturn(2);
779 const promise3 = mutation.runAndReturn(3);
780
781 const [result1, result2, result3] = await Promise.all([
782 promise1,
783 promise2,
784 promise3,
785 ]);
786
787 // All should get the same result object
788 assertEquals(result1, result2);
789 assertEquals(result2, result3);
790 assertEquals(result1.delta, 6);
791});
792
793test("BatchMutation - all pending promises reject with same error", async () => {
794 const { client } = createTestClient();
795 testStore.clear();
796 testStore.set("counter", 0);
797
798 const mutation = client.defineBatched({
799 optimistic({ helpers }, amount: number) {
800 helpers.increment("counter", amount);
801 },
802 mode: "debounce",
803 time: 30,
804 key: () => "test-key",
805 getValue: (_) => testStore.get("counter") ?? 0,
806 async commit() {
807 throw new Error("batch commit failed");
808 },
809 describe: "increment counter",
810 async refetch() {},
811 });
812
813 const promise1 = mutation.runAndReturn(1);
814 const promise2 = mutation.runAndReturn(2);
815 const promise3 = mutation.runAndReturn(3);
816
817 const errors: Error[] = [];
818 await Promise.all([
819 promise1.catch((e) => errors.push(e)),
820 promise2.catch((e) => errors.push(e)),
821 promise3.catch((e) => errors.push(e)),
822 ]);
823
824 // All should get the same error
825 assertEquals(errors.length, 3);
826 assertEquals(errors[0].message, "batch commit failed");
827 assertEquals(errors[1].message, "batch commit failed");
828 assertEquals(errors[2].message, "batch commit failed");
829});
830
831// ============================================================================
832// Edge case tests
833// ============================================================================
834
835test("BatchMutation - handles empty getValue result", async () => {
836 const { client } = createTestClient();
837 testStore.clear();
838
839 let commitCallCount = 0;
840
841 const mutation = client.defineBatched({
842 optimistic({ helpers }, amount: number) {
843 helpers.setValue("nonexistent", amount);
844 },
845 mode: "debounce",
846 time: 20,
847 key: () => "test-key",
848 getValue: (_) => testStore.get("nonexistent"),
849 async commit({ initial, current }) {
850 commitCallCount++;
851 return { initial, current };
852 },
853 describe: "test mutation",
854 });
855
856 const result = await mutation.runAndReturn(5);
857 await delay(30);
858
859 assertEquals(commitCallCount, 1);
860 assertEquals(result.initial, undefined);
861 assertEquals(result.current, 5);
862});
863
864test("BatchMutation - channel cleanup after idle with no listeners", async () => {
865 const { client } = createTestClient();
866 testStore.clear();
867 testStore.set("counter", 0);
868
869 const mutation = client.defineBatched({
870 optimistic({ helpers }, amount: number) {
871 helpers.increment("counter", amount);
872 },
873 mode: "debounce",
874 time: 20,
875 key: () => "test-key",
876 getValue: (_) => testStore.get("counter") ?? 0,
877 async commit({ initial, current }) {
878 return current - initial;
879 },
880 describe: "test mutation",
881 async refetch() {},
882 });
883
884 // Run mutation without subscribing
885 await mutation.runAndReturn(5);
886 await delay(30);
887
888 // Run another mutation - should work fine (channel recreated if needed)
889 const result = await mutation.runAndReturn(3);
890 await delay(30);
891
892 assertEquals(result, 3);
893 assertEquals(testStore.get("counter"), 8);
894});
895
896test("BatchMutation - default time is 200ms", async () => {
897 const { client } = createTestClient();
898 testStore.clear();
899 testStore.set("counter", 0);
900
901 let commitTime: number | null = null;
902 const startTime = Date.now();
903
904 const mutation = client.defineBatched({
905 optimistic({ helpers }, amount: number) {
906 helpers.increment("counter", amount);
907 },
908 mode: "debounce",
909 // time not specified, should default to 200
910 key: () => "test-key",
911 getValue: (_) => testStore.get("counter") ?? 0,
912 async commit({ initial, current }) {
913 commitTime = Date.now() - startTime;
914 return current - initial;
915 },
916 describe: "test mutation",
917 async refetch() {},
918 });
919
920 await mutation.runAndReturn(5);
921
922 // Should commit after ~200ms (with some tolerance)
923 assertEquals(commitTime !== null, true);
924 assertEquals(commitTime! >= 180, true);
925 assertEquals(commitTime! <= 250, true);
926});
927
928test("BatchMutation - context is passed to getValue", async () => {
929 const { client } = createTestClient();
930 testStore.clear();
931 testStore.set("counter", 0);
932
933 let receivedUserId: string | undefined;
934
935 const mutation = client.defineBatched({
936 optimistic({ helpers }, amount: number) {
937 helpers.increment("counter", amount);
938 },
939 mode: "debounce",
940 time: 20,
941 key: () => "test-key",
942 getValue: ({ userId }, _) => {
943 receivedUserId = userId;
944 return testStore.get("counter") ?? 0;
945 },
946 async commit({ initial, current }) {
947 return current - initial;
948 },
949 describe: "test mutation",
950 async refetch() {},
951 });
952
953 await mutation.runAndReturn(5);
954 await delay(30);
955
956 assertEquals(receivedUserId, "test-user");
957});
958
959test("BatchMutation - context is passed to commit", async () => {
960 const { client } = createTestClient();
961 testStore.clear();
962 testStore.set("counter", 0);
963
964 let receivedUserId: string | undefined;
965
966 const mutation = client.defineBatched({
967 optimistic({ helpers }, amount: number) {
968 helpers.increment("counter", amount);
969 },
970 mode: "debounce",
971 time: 20,
972 key: () => "test-key",
973 getValue: (_) => testStore.get("counter") ?? 0,
974 async commit({ userId, initial, current }) {
975 receivedUserId = userId;
976 return current - initial;
977 },
978 describe: "test mutation",
979 async refetch() {},
980 });
981
982 await mutation.runAndReturn(5);
983 await delay(30);
984
985 assertEquals(receivedUserId, "test-user");
986});
987
988test("BatchMutation - first args are used for commit", async () => {
989 const { client } = createTestClient();
990 testStore.clear();
991 testStore.set("counter", 0);
992
993 let receivedArgs: [string, number] | undefined;
994
995 const mutation = client.defineBatched({
996 optimistic({ helpers }, _label: string, amount: number) {
997 helpers.increment("counter", amount);
998 },
999 mode: "debounce",
1000 time: 30,
1001 key: () => "test-key",
1002 getValue: (_) => testStore.get("counter") ?? 0,
1003 async commit({ args, initial, current }) {
1004 receivedArgs = args;
1005 return current - initial;
1006 },
1007 describe: "test mutation",
1008 async refetch() {},
1009 });
1010
1011 mutation.runAndReturn("first", 1);
1012 mutation.runAndReturn("second", 2);
1013 await mutation.runAndReturn("third", 3);
1014 await delay(10);
1015
1016 // Should use first args
1017 assertEquals(receivedArgs, ["first", 1]);
1018});
test/example.test.tsx deleted-96
...@@ -1,96 +0,0 @@
1import { QueryClient, type QueryOptions } from "@tanstack/react-query";
2import { MutationClient } from "../src/client.ts";
3import {
4 type ButtonHTMLAttributes,
5 type DetailedHTMLProps,
6 useState,
7} from "react";
8import { createMutationButton, useMutation } from "../src/react.tsx";
9
10const client = new QueryClient();
11const mutationClient = new MutationClient({
12 context: {
13 client,
14 },
15 getOptimisticHelpers(onRestore) {
16 return {
17 setProp<T extends object, K extends keyof T>(
18 queryKey: QueryOptions<T>,
19 key: K,
20 value: T[K],
21 ) {
22 const prev = client.getQueryData<T>(queryKey.queryKey!);
23 if (!prev) return;
24 client.setQueryData<T>(
25 queryKey.queryKey!,
26 (obj) => obj ? ({ ...obj, [key]: value }) : obj,
27 );
28 onRestore(() =>
29 client.setQueryData<T>(
30 queryKey.queryKey!,
31 (obj) => obj ? ({ ...obj, [key]: prev[key] }) : obj,
32 )
33 );
34 },
35 };
36 },
37});
38
39function queryChat(id: string): QueryOptions<{ title: string }> {
40 throw new Error();
41}
42
43const MutationButton = createMutationButton((
44 props:
45 & DetailedHTMLProps<
46 ButtonHTMLAttributes<HTMLButtonElement>,
47 HTMLButtonElement
48 >
49 & { isPending: true },
50) => <button {...props} disabled={props.disabled || props.isPending} />);
51
52const mutSetChatName = mutationClient.defineMutation({
53 async mutate(chatId: string, newName: string) {
54 const res = await fetch(`/api/chats/${chatId}`, {
55 method: "PATCH",
56 body: JSON.stringify({ name: newName }),
57 });
58 if (!res.ok) throw new Error(`HTTP ${res.status}`);
59 },
60
61 describe: "Rename Chat",
62
63 optimistic({ args: [chatId, newName], helpers }) {
64 helpers.setProp(queryChat(chatId), "title", newName);
65 },
66
67 async refetch({ client, args: [chatId] }) {
68 await client.invalidateQueries(queryChat(chatId));
69 },
70});
71
72function Form({ chatId }: { chatId: string }) {
73 const mutation = useMutation(mutSetChatName);
74 const [newName, setNewName] = useState("");
75 return (
76 <>
77 <input
78 onChange={(e) => setNewName(e.target.value)}
79 disabled={mutation.isPending}
80 />
81 {/* button that contains a loading state */}
82 <MutationButton
83 mutation={mutation}
84 args={() => [chatId, newName]}
85 >
86 Save
87 </MutationButton>
88 {/* trigger programatically */}
89 <button
90 onClick={() => {
91 mutation.run(chatId, newName);
92 }}
93 />
94 </>
95 );
96}
test/object-path-types.test.ts created+411
...@@ -0,0 +1,411 @@
1/**
2 * Type-level tests for object-path system
3 * These tests verify that TypeScript types work correctly at compile time
4 */
5
6import type {
7 AllObjectPaths,
8 GetObjectPath,
9} from "../src/object-path.ts";
10
11// Type testing utilities
12type Expect<T extends true> = T;
13type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y
14 ? 1
15 : 2 ? true
16 : false;
17type NotEqual<X, Y> = Equal<X, Y> extends true ? false : true;
18type IsAny<T> = 0 extends 1 & T ? true : false;
19type NotAny<T> = IsAny<T> extends true ? false : true;
20
21// Test interface
22interface TestData {
23 name: string;
24 count: number;
25 active: boolean;
26 settings: {
27 theme: string;
28 notifications: boolean;
29 };
30 items: Array<{ id: number; label: string }>;
31 tags: string[];
32 nested: {
33 deep: {
34 value: boolean;
35 config: {
36 enabled: true;
37 };
38 };
39 };
40}
41
42// ============================================================================
43// AllObjectPaths tests
44// ============================================================================
45
46// Should allow top-level paths
47type TestPath1 = Expect<
48 Equal<["name"], Extract<AllObjectPaths<TestData>, ["name"]>>
49>;
50type TestPath2 = Expect<
51 Equal<["count"], Extract<AllObjectPaths<TestData>, ["count"]>>
52>;
53
54// Should allow nested paths
55type TestPath3 = Expect<
56 Equal<
57 ["settings", "theme"],
58 Extract<AllObjectPaths<TestData>, ["settings", "theme"]>
59 >
60>;
61
62// Should allow deep nested paths
63type TestPath4 = Expect<
64 Equal<
65 ["nested", "deep", "value"],
66 Extract<AllObjectPaths<TestData>, ["nested", "deep", "value"]>
67 >
68>;
69
70// Should allow array index access
71type TestPath5 = Expect<
72 Equal<[number], Extract<AllObjectPaths<string[]>, [number]>>
73>;
74
75// Should allow array element property access
76type TestPath6 = Expect<
77 Equal<
78 ["items", number, "id"],
79 Extract<AllObjectPaths<TestData>, ["items", number, "id"]>
80 >
81>;
82
83// Should allow empty path for nested objects
84type TestPath7 = Expect<
85 Equal<[], Extract<AllObjectPaths<TestData>, []>>
86>;
87
88// ============================================================================
89// GetObjectPath tests
90// ============================================================================
91
92// Top-level property access
93type GetTest1 = Expect<Equal<GetObjectPath<TestData, ["name"]>, string>>;
94type GetTest2 = Expect<Equal<GetObjectPath<TestData, ["count"]>, number>>;
95type GetTest3 = Expect<Equal<GetObjectPath<TestData, ["active"]>, boolean>>;
96
97// Nested property access
98type GetTest4 = Expect<
99 Equal<
100 GetObjectPath<TestData, ["settings", "theme"]>,
101 string
102 >
103>;
104type GetTest5 = Expect<
105 Equal<
106 GetObjectPath<TestData, ["settings", "notifications"]>,
107 boolean
108 >
109>;
110
111// Deep nested access
112type GetTest6 = Expect<
113 Equal<
114 GetObjectPath<TestData, ["nested", "deep", "value"]>,
115 boolean
116 >
117>;
118type GetTest7 = Expect<
119 Equal<
120 GetObjectPath<TestData, ["nested", "deep", "config", "enabled"]>,
121 true
122 >
123>;
124
125// Array access
126type GetTest8 = Expect<
127 Equal<
128 GetObjectPath<TestData, ["tags"]>,
129 string[]
130 >
131>;
132type GetTest9 = Expect<
133 Equal<
134 GetObjectPath<TestData, ["tags", number]>,
135 string
136 >
137>;
138type GetTest10 = Expect<
139 Equal<
140 GetObjectPath<TestData, ["items"]>,
141 Array<{ id: number; label: string }>
142 >
143>;
144type GetTest11 = Expect<
145 Equal<
146 GetObjectPath<TestData, ["items", number]>,
147 { id: number; label: string }
148 >
149>;
150type GetTest12 = Expect<
151 Equal<
152 GetObjectPath<TestData, ["items", number, "id"]>,
153 number
154 >
155>;
156type GetTest13 = Expect<
157 Equal<
158 GetObjectPath<TestData, ["items", number, "label"]>,
159 string
160 >
161>;
162
163// Object access
164type GetTest14 = Expect<
165 Equal<
166 GetObjectPath<TestData, ["settings"]>,
167 { theme: string; notifications: boolean }
168 >
169>;
170
171// Empty path returns the whole object
172type GetTest15 = Expect<Equal<GetObjectPath<TestData, []>, TestData>>;
173
174// ============================================================================
175// Array element type extraction tests
176// ============================================================================
177
178type ArrayElement<T> = T extends readonly (infer U)[] ? U : never;
179
180type ArrayTest1 = Expect<Equal<ArrayElement<string[]>, string>>;
181type ArrayTest2 = Expect<Equal<ArrayElement<number[]>, number>>;
182type ArrayTest3 = Expect<
183 Equal<ArrayElement<Array<{ id: number }>>, { id: number }>
184>;
185type ArrayTest4 = Expect<
186 Equal<
187 ArrayElement<GetObjectPath<TestData, ["items"]>>,
188 { id: number; label: string }
189 >
190>;
191type ArrayTest5 = Expect<
192 Equal<ArrayElement<GetObjectPath<TestData, ["tags"]>>, string>
193>;
194
195// ============================================================================
196// Conditional type tests for helper functions
197// ============================================================================
198
199// Test that we can extract array element types from paths
200type ExtractArrayElement<
201 Data extends object,
202 Path extends AllObjectPaths<Data>,
203> = GetObjectPath<Data, Path> extends readonly (infer T)[] ? T : never;
204
205type ElementTest1 = Expect<
206 Equal<
207 ExtractArrayElement<TestData, ["items"]>,
208 { id: number; label: string }
209 >
210>;
211type ElementTest2 = Expect<
212 Equal<ExtractArrayElement<TestData, ["tags"]>, string>
213>;
214
215// Test that non-array paths return never
216type ElementTest3 = Expect<
217 Equal<ExtractArrayElement<TestData, ["name"]>, never>
218>;
219
220// Test number extraction
221type IsNumber<
222 Data extends object,
223 Path extends AllObjectPaths<Data>,
224> = GetObjectPath<Data, Path> extends number ? true : false;
225
226type NumberTest1 = Expect<Equal<IsNumber<TestData, ["count"]>, true>>;
227type NumberTest2 = Expect<Equal<IsNumber<TestData, ["name"]>, false>>;
228
229// Test boolean extraction
230type IsBoolean<
231 Data extends object,
232 Path extends AllObjectPaths<Data>,
233> = GetObjectPath<Data, Path> extends boolean ? true : false;
234
235type BooleanTest1 = Expect<Equal<IsBoolean<TestData, ["active"]>, true>>;
236type BooleanTest2 = Expect<Equal<IsBoolean<TestData, ["count"]>, false>>;
237
238// Test object extraction
239type IsObject<
240 Data extends object,
241 Path extends AllObjectPaths<Data>,
242> = GetObjectPath<Data, Path> extends object ? true : false;
243
244type ObjectTest1 = Expect<Equal<IsObject<TestData, ["settings"]>, true>>;
245type ObjectTest2 = Expect<Equal<IsObject<TestData, ["items"]>, true>>;
246type ObjectTest3 = Expect<Equal<IsObject<TestData, ["name"]>, false>>;
247
248// ============================================================================
249// Edge cases
250// ============================================================================
251
252// Readonly arrays should work
253interface ReadonlyData {
254 readonly items: readonly { id: number }[];
255}
256
257type ReadonlyTest1 = Expect<
258 Equal<
259 GetObjectPath<ReadonlyData, ["items"]>,
260 readonly { id: number }[]
261 >
262>;
263type ReadonlyTest2 = Expect<
264 Equal<
265 GetObjectPath<ReadonlyData, ["items", number]>,
266 { id: number }
267 >
268>;
269type ReadonlyTest3 = Expect<
270 Equal<
271 GetObjectPath<ReadonlyData, ["items", number, "id"]>,
272 number
273 >
274>;
275
276// Optional properties
277interface OptionalData {
278 required: string;
279 optional?: number;
280 nested?: {
281 value: boolean;
282 };
283}
284
285type OptionalTest1 = Expect<
286 Equal<GetObjectPath<OptionalData, ["required"]>, string>
287>;
288type OptionalTest2 = Expect<
289 Equal<GetObjectPath<OptionalData, ["optional"]>, number | undefined>
290>;
291
292// Union types
293interface UnionData {
294 value: string | number;
295 items: Array<{ type: "a"; a: string } | { type: "b"; b: number }>;
296}
297
298type UnionTest1 = Expect<
299 Equal<GetObjectPath<UnionData, ["value"]>, string | number>
300>;
301
302// ============================================================================
303// Real-world usage simulation
304// ============================================================================
305
306// Simulate the actual helper function signatures
307type ObjSetSignature<
308 Data extends object,
309 Path extends AllObjectPaths<Data>,
310> = (
311 path: Path,
312 value:
313 | Exclude<GetObjectPath<Data, Path>, Function>
314 | ((prev: GetObjectPath<Data, Path>) => GetObjectPath<Data, Path>),
315) => void;
316
317// This should accept string or function
318declare const objSetName: ObjSetSignature<TestData, ["name"]>;
319objSetName(["name"], "test");
320objSetName(["name"], (prev) => prev.toUpperCase());
321
322// This should accept number or function
323declare const objSetCount: ObjSetSignature<TestData, ["count"]>;
324objSetCount(["count"], 42);
325objSetCount(["count"], (n) => n + 1);
326
327// Array push signature
328type ArrayPushSignature<
329 Data extends object,
330 Path extends AllObjectPaths<Data>,
331> = GetObjectPath<Data, Path> extends readonly (infer T)[]
332 ? (path: Path, ...items: T[]) => void
333 : never;
334
335// This should accept individual items, not arrays
336declare const arrayPushItems: ArrayPushSignature<TestData, ["items"]>;
337arrayPushItems(
338 ["items"],
339 { id: 1, label: "first" },
340 { id: 2, label: "second" },
341);
342
343declare const arrayPushTags: ArrayPushSignature<TestData, ["tags"]>;
344arrayPushTags(["tags"], "alpha", "beta", "gamma");
345
346// Array remove signature
347type ArrayRemoveSignature<
348 Data extends object,
349 Path extends AllObjectPaths<Data>,
350> = GetObjectPath<Data, Path> extends readonly (infer T)[]
351 ? (path: Path, predicate: (item: T, index: number) => boolean) => void
352 : never;
353
354declare const arrayRemoveItems: ArrayRemoveSignature<TestData, ["items"]>;
355arrayRemoveItems(["items"], (item) => item.id === 1);
356arrayRemoveItems(["items"], (item, index) => index === 0);
357
358declare const arrayRemoveTags: ArrayRemoveSignature<TestData, ["tags"]>;
359arrayRemoveTags(["tags"], (tag) => tag === "alpha");
360
361// Increment signature
362type IncrementSignature<
363 Data extends object,
364 Path extends AllObjectPaths<Data>,
365> = GetObjectPath<Data, Path> extends number
366 ? (path: Path, amount?: number) => void
367 : never;
368
369declare const increment: IncrementSignature<TestData, ["count"]>;
370increment(["count"]);
371increment(["count"], 5);
372
373// Should not work on non-numbers (type should be never)
374type IncrementNameTest = Expect<
375 Equal<IncrementSignature<TestData, ["name"]>, never>
376>;
377
378// Toggle signature
379type ToggleSignature<
380 Data extends object,
381 Path extends AllObjectPaths<Data>,
382> = GetObjectPath<Data, Path> extends boolean
383 ? (path: Path) => void
384 : never;
385
386declare const toggle: ToggleSignature<TestData, ["active"]>;
387toggle(["active"]);
388
389// Should not work on non-booleans (type should be never)
390type ToggleCountTest = Expect<
391 Equal<ToggleSignature<TestData, ["count"]>, never>
392>;
393
394// ============================================================================
395// Verify no `any` types leaked through
396// ============================================================================
397
398type NoAnyTest1 = Expect<NotAny<GetObjectPath<TestData, ["name"]>>>;
399type NoAnyTest2 = Expect<NotAny<GetObjectPath<TestData, ["items"]>>>;
400type NoAnyTest3 = Expect<NotAny<GetObjectPath<TestData, ["items", number]>>>;
401type NoAnyTest4 = Expect<
402 NotAny<GetObjectPath<TestData, ["settings", "theme"]>>
403>;
404
405export type {
406 ArrayPushSignature,
407 ArrayRemoveSignature,
408 IncrementSignature,
409 ObjSetSignature,
410 ToggleSignature,
411};
test/object-path.test.ts created+339
...@@ -0,0 +1,339 @@
1import { assertEquals } from "@std/assert";
2import { test } from "vitest";
3import { getPath, setPath } from "../src/object-path.ts";
4
5// Test interfaces
6interface TestObject {
7 name: string;
8 age: number;
9 address: {
10 street: string;
11 city: string;
12 zip: number;
13 };
14 hobbies: string[];
15 nested: {
16 deep: {
17 value: boolean;
18 };
19 };
20 items: Array<{
21 id: number;
22 label: string;
23 }>;
24}
25
26test("get - should retrieve top-level property", () => {
27 const obj: TestObject = {
28 name: "John",
29 age: 30,
30 address: { street: "Main St", city: "NYC", zip: 10001 },
31 hobbies: ["reading", "gaming"],
32 nested: { deep: { value: true } },
33 items: [{ id: 1, label: "first" }],
34 };
35
36 const result = getPath(obj, ["name"]);
37 assertEquals(result.exists, true);
38 if (result.exists) {
39 assertEquals(result.value, "John");
40 }
41});
42
43test("get - should retrieve nested property", () => {
44 const obj: TestObject = {
45 name: "John",
46 age: 30,
47 address: { street: "Main St", city: "NYC", zip: 10001 },
48 hobbies: ["reading", "gaming"],
49 nested: { deep: { value: true } },
50 items: [{ id: 1, label: "first" }],
51 };
52
53 const result = getPath(obj, ["address", "city"]);
54 assertEquals(result.exists, true);
55 if (result.exists) {
56 assertEquals(result.value, "NYC");
57 }
58});
59
60test("get - should retrieve deeply nested property", () => {
61 const obj: TestObject = {
62 name: "John",
63 age: 30,
64 address: { street: "Main St", city: "NYC", zip: 10001 },
65 hobbies: ["reading", "gaming"],
66 nested: { deep: { value: true } },
67 items: [{ id: 1, label: "first" }],
68 };
69
70 const result = getPath(obj, ["nested", "deep", "value"]);
71 assertEquals(result.exists, true);
72 if (result.exists) {
73 assertEquals(result.value, true);
74 }
75});
76
77test("get - should retrieve array element", () => {
78 const obj: TestObject = {
79 name: "John",
80 age: 30,
81 address: { street: "Main St", city: "NYC", zip: 10001 },
82 hobbies: ["reading", "gaming"],
83 nested: { deep: { value: true } },
84 items: [{ id: 1, label: "first" }],
85 };
86
87 const result = getPath(obj, ["hobbies", 0]);
88 assertEquals(result.exists, true);
89 if (result.exists) {
90 assertEquals(result.value, "reading");
91 }
92});
93
94test("get - should retrieve property from array element", () => {
95 const obj: TestObject = {
96 name: "John",
97 age: 30,
98 address: { street: "Main St", city: "NYC", zip: 10001 },
99 hobbies: ["reading", "gaming"],
100 nested: { deep: { value: true } },
101 items: [{ id: 1, label: "first" }, { id: 2, label: "second" }],
102 };
103
104 const result = getPath(obj, ["items", 1, "label"]);
105 assertEquals(result.exists, true);
106 if (result.exists) {
107 assertEquals(result.value, "second");
108 }
109});
110
111test("get - should return exists: false for non-existent property", () => {
112 const obj: TestObject = {
113 name: "John",
114 age: 30,
115 address: { street: "Main St", city: "NYC", zip: 10001 },
116 hobbies: ["reading", "gaming"],
117 nested: { deep: { value: true } },
118 items: [{ id: 1, label: "first" }],
119 };
120
121 const result = getPath(obj, ["address", "country"] as any);
122 assertEquals(result.exists, false);
123 assertEquals(result.value, undefined);
124});
125
126test("get - should return exists: false for non-existent array index", () => {
127 const obj: TestObject = {
128 name: "John",
129 age: 30,
130 address: { street: "Main St", city: "NYC", zip: 10001 },
131 hobbies: ["reading", "gaming"],
132 nested: { deep: { value: true } },
133 items: [{ id: 1, label: "first" }],
134 };
135
136 const result = getPath(obj, ["hobbies", 10]);
137 assertEquals(result.exists, false);
138 assertEquals(result.value, undefined);
139});
140
141test("get - should return exists: false when traversing through null", () => {
142 const obj: any = {
143 name: "John",
144 data: null,
145 };
146
147 const result = getPath(obj, ["data", "nested"]);
148 assertEquals(result.exists, false);
149 assertEquals(result.value, undefined);
150});
151
152test("get - should return exists: false when traversing through primitive", () => {
153 const obj: any = {
154 name: "John",
155 count: 42,
156 };
157
158 const result = getPath(obj, ["count", "invalid"]);
159 assertEquals(result.exists, false);
160 assertEquals(result.value, undefined);
161});
162
163test("set - should set top-level property", () => {
164 const obj: TestObject = {
165 name: "John",
166 age: 30,
167 address: { street: "Main St", city: "NYC", zip: 10001 },
168 hobbies: ["reading", "gaming"],
169 nested: { deep: { value: true } },
170 items: [{ id: 1, label: "first" }],
171 };
172
173 const result = setPath(obj, ["name"], "Jane");
174 assertEquals(result.name, "Jane");
175 assertEquals(obj.name, "John"); // Original should be unchanged
176});
177
178test("set - should set nested property", () => {
179 const obj: TestObject = {
180 name: "John",
181 age: 30,
182 address: { street: "Main St", city: "NYC", zip: 10001 },
183 hobbies: ["reading", "gaming"],
184 nested: { deep: { value: true } },
185 items: [{ id: 1, label: "first" }],
186 };
187
188 const result = setPath(obj, ["address", "city"], "LA");
189 assertEquals(result.address.city, "LA");
190 assertEquals(obj.address.city, "NYC"); // Original should be unchanged
191 assertEquals(result.address.street, "Main St"); // Other properties preserved
192});
193
194test("set - should set deeply nested property", () => {
195 const obj: TestObject = {
196 name: "John",
197 age: 30,
198 address: { street: "Main St", city: "NYC", zip: 10001 },
199 hobbies: ["reading", "gaming"],
200 nested: { deep: { value: true } },
201 items: [{ id: 1, label: "first" }],
202 };
203
204 const result = setPath(obj, ["nested", "deep", "value"], false);
205 assertEquals(result.nested.deep.value, false);
206 assertEquals(obj.nested.deep.value, true); // Original should be unchanged
207});
208
209test("set - should set array element", () => {
210 const obj: TestObject = {
211 name: "John",
212 age: 30,
213 address: { street: "Main St", city: "NYC", zip: 10001 },
214 hobbies: ["reading", "gaming"],
215 nested: { deep: { value: true } },
216 items: [{ id: 1, label: "first" }],
217 };
218
219 const result = setPath(obj, ["hobbies", 0], "writing");
220 assertEquals(result.hobbies[0], "writing");
221 assertEquals(obj.hobbies[0], "reading"); // Original should be unchanged
222 assertEquals(result.hobbies[1], "gaming"); // Other elements preserved
223});
224
225test("set - should set property in array element", () => {
226 const obj: TestObject = {
227 name: "John",
228 age: 30,
229 address: { street: "Main St", city: "NYC", zip: 10001 },
230 hobbies: ["reading", "gaming"],
231 nested: { deep: { value: true } },
232 items: [{ id: 1, label: "first" }, { id: 2, label: "second" }],
233 };
234
235 const result = setPath(obj, ["items", 0, "label"], "updated");
236 assertEquals(result.items[0].label, "updated");
237 assertEquals(obj.items[0].label, "first"); // Original should be unchanged
238 assertEquals(result.items[0].id, 1); // Other properties preserved
239});
240
241test("set - should not mutate original object", () => {
242 const obj: TestObject = {
243 name: "John",
244 age: 30,
245 address: { street: "Main St", city: "NYC", zip: 10001 },
246 hobbies: ["reading", "gaming"],
247 nested: { deep: { value: true } },
248 items: [{ id: 1, label: "first" }],
249 };
250
251 const originalJson = JSON.stringify(obj);
252 setPath(obj, ["age"], 31);
253 assertEquals(JSON.stringify(obj), originalJson);
254});
255
256test("set - should preserve unrelated properties", () => {
257 const obj: TestObject = {
258 name: "John",
259 age: 30,
260 address: { street: "Main St", city: "NYC", zip: 10001 },
261 hobbies: ["reading", "gaming"],
262 nested: { deep: { value: true } },
263 items: [{ id: 1, label: "first" }],
264 };
265
266 const result = setPath(obj, ["age"], 31);
267 assertEquals(result.name, "John");
268 assertEquals(result.address, obj.address);
269 assertEquals(result.hobbies, obj.hobbies);
270 assertEquals(result.nested, obj.nested);
271 assertEquals(result.items, obj.items);
272});
273
274test("set - should share references for unchanged branches (structural sharing)", () => {
275 const obj: TestObject = {
276 name: "John",
277 age: 30,
278 address: { street: "Main St", city: "NYC", zip: 10001 },
279 hobbies: ["reading", "gaming"],
280 nested: { deep: { value: true } },
281 items: [{ id: 1, label: "first" }],
282 };
283
284 const result = setPath(obj, ["address", "city"], "LA");
285
286 // Changed path should have new references
287 assertEquals(result === obj, false); // Root is new
288 assertEquals(result.address === obj.address, false); // Address is new
289
290 // Unchanged branches should share references
291 assertEquals(result.hobbies === obj.hobbies, true); // Same reference
292 assertEquals(result.nested === obj.nested, true); // Same reference
293 assertEquals(result.items === obj.items, true); // Same reference
294});
295
296test("set - should handle empty path", () => {
297 const obj: TestObject = {
298 name: "John",
299 age: 30,
300 address: { street: "Main St", city: "NYC", zip: 10001 },
301 hobbies: ["reading", "gaming"],
302 nested: { deep: { value: true } },
303 items: [{ id: 1, label: "first" }],
304 };
305
306 const result = setPath(obj, [] as any, obj);
307 assertEquals(result, obj);
308});
309
310test("get and set - should work together", () => {
311 const obj: TestObject = {
312 name: "John",
313 age: 30,
314 address: { street: "Main St", city: "NYC", zip: 10001 },
315 hobbies: ["reading", "gaming"],
316 nested: { deep: { value: true } },
317 items: [{ id: 1, label: "first" }],
318 };
319
320 const getResult1 = getPath(obj, ["address", "zip"]);
321 assertEquals(getResult1.exists, true);
322 if (getResult1.exists) {
323 assertEquals(getResult1.value, 10001);
324 }
325
326 const updated = setPath(obj, ["address", "zip"], 90210);
327
328 const getResult2 = getPath(updated, ["address", "zip"]);
329 assertEquals(getResult2.exists, true);
330 if (getResult2.exists) {
331 assertEquals(getResult2.value, 90210);
332 }
333
334 const getResult3 = getPath(obj, ["address", "zip"]);
335 assertEquals(getResult3.exists, true);
336 if (getResult3.exists) {
337 assertEquals(getResult3.value, 10001);
338 }
339});
test/queued.test.ts created+967
...@@ -0,0 +1,967 @@
1import { assertEquals, assertRejects } from "@std/assert";
2import { MutationClient } from "../src/client.ts";
3import type { MutationEvent } from "../src/types.ts";
4import { test } from "vitest";
5
6// Helper to create a test mutation client
7function createTestClient() {
8 const errors: unknown[] = [];
9 const client = new MutationClient({
10 context: { userId: "test-user" },
11 getOptimisticHelpers({ onRestore }) {
12 return {
13 setValue(key: string, value: string) {
14 testStore.set(key, value);
15 onRestore(() => testStore.delete(key));
16 },
17 };
18 },
19 reportError(error) {
20 errors.push(error);
21 },
22 });
23
24 return { client, errors };
25}
26
27const testStore = new Map<string, string>();
28
29// Helper to track mutation events
30function createEventTracker<Result>() {
31 const events: Array<MutationEvent<Result>> = [];
32 const callback = (event: MutationEvent<Result>) => {
33 events.push(event);
34 };
35 return { events, callback };
36}
37
38// Helper to wait for async operations
39function delay(ms: number) {
40 return new Promise((resolve) => setTimeout(resolve, ms));
41}
42
43test("QueuedMutation - basic mutation success", async () => {
44 const { client } = createTestClient();
45 let mutateCallCount = 0;
46 let refetchCallCount = 0;
47
48 const mutation = client.defineQueued({
49 async mutate(_, value: string) {
50 mutateCallCount++;
51 await delay(10);
52 return `result-${value}`;
53 },
54 describe: "test mutation",
55 optimistic() {
56 // Empty optimistic update
57 },
58 async refetch() {
59 refetchCallCount++;
60 await delay(5);
61 },
62 });
63
64 const result = await mutation.runAndReturn("test");
65 // Wait for refetch to complete
66 await delay(20);
67
68 assertEquals(result, "result-test");
69 assertEquals(mutateCallCount, 1);
70 assertEquals(refetchCallCount, 1);
71});
72
73test("QueuedMutation - run() catches errors", async () => {
74 const { client, errors } = createTestClient();
75
76 const mutation = client.defineQueued({
77 async mutate(_, _value: string) {
78 throw new Error("mutation failed");
79 },
80 describe: "failing mutation",
81 optimistic() {},
82 async refetch() {},
83 });
84
85 mutation.run("test");
86 await delay(50);
87
88 assertEquals(errors.length, 1);
89 assertEquals((errors[0] as Error).message, "mutation failed");
90});
91
92test("QueuedMutation - runAndReturn() rejects on error", async () => {
93 const { client } = createTestClient();
94
95 const mutation = client.defineQueued({
96 async mutate(_, _value: string) {
97 throw new Error("mutation failed");
98 },
99 describe: "failing mutation",
100 optimistic() {},
101 async refetch() {},
102 });
103
104 await assertRejects(
105 () => mutation.runAndReturn("test"),
106 Error,
107 "mutation failed",
108 );
109});
110
111test("QueuedMutation - optimistic updates are applied immediately", async () => {
112 const { client } = createTestClient();
113 testStore.clear();
114
115 const mutation = client.defineQueued({
116 async mutate(_, _key: string, value: string) {
117 await delay(50);
118 return value;
119 },
120 describe: "set value",
121 optimistic({ args, helpers }) {
122 const [key, value] = args;
123 helpers.setValue(key, value);
124 },
125 async refetch() {},
126 });
127
128 const promise = mutation.runAndReturn("key1", "value1");
129
130 // Optimistic update should be applied synchronously
131 assertEquals(testStore.get("key1"), "value1");
132
133 // Wait for mutation to complete
134 await promise;
135 await delay(10);
136});
137
138test("QueuedMutation - rollback on error", async () => {
139 const { client } = createTestClient();
140 testStore.clear();
141
142 const mutation = client.defineQueued({
143 async mutate(_, _key: string, _value: string) {
144 await delay(10);
145 throw new Error("mutation failed");
146 },
147 describe: "failing mutation",
148 optimistic({ args, helpers }) {
149 const [key, value] = args;
150 helpers.setValue(key, value);
151 },
152 async refetch() {},
153 });
154
155 await assertRejects(() => mutation.runAndReturn("key1", "value1"));
156
157 // Optimistic update should be rolled back
158 assertEquals(testStore.has("key1"), false);
159});
160
161test("QueuedMutation - onSuccess callback is called", async () => {
162 const { client } = createTestClient();
163 const successResults: string[] = [];
164
165 const mutation = client.defineQueued({
166 async mutate(_, value: string) {
167 return `result-${value}`;
168 },
169 describe: "test mutation",
170 optimistic({ onSuccess }) {
171 onSuccess((result) => {
172 successResults.push(result);
173 });
174 },
175 async refetch() {},
176 });
177
178 await mutation.runAndReturn("test");
179
180 assertEquals(successResults, ["result-test"]);
181});
182
183test("QueuedMutation - mutations with same key execute serially", async () => {
184 const { client } = createTestClient();
185 const executionOrder: string[] = [];
186
187 const mutation = client.defineQueued({
188 async mutate(_, id: string) {
189 executionOrder.push(`start-${id}`);
190 await delay(20);
191 executionOrder.push(`end-${id}`);
192 return id;
193 },
194 describe: "test mutation",
195 optimistic() {},
196 async refetch() {},
197 refetchOnSuccess: false,
198 key() {
199 return "same-key";
200 },
201 });
202
203 // Start two mutations with the same key
204 const promise1 = mutation.runAndReturn("1");
205 const promise2 = mutation.runAndReturn("2");
206
207 await Promise.all([promise1, promise2]);
208 await delay(10);
209
210 // They should execute serially, not in parallel
211 assertEquals(executionOrder, ["start-1", "end-1", "start-2", "end-2"]);
212});
213
214test("QueuedMutation - mutations with different keys execute in parallel", async () => {
215 const { client } = createTestClient();
216 const executionOrder: string[] = [];
217
218 const mutation = client.defineQueued({
219 async mutate(_, id: string) {
220 executionOrder.push(`start-${id}`);
221 await delay(20);
222 executionOrder.push(`end-${id}`);
223 return id;
224 },
225 describe: "test mutation",
226 optimistic() {},
227 async refetch() {},
228 key({ args }) {
229 const [id] = args;
230 return id;
231 },
232 });
233
234 // Start two mutations with different keys
235 const promise1 = mutation.runAndReturn("key1");
236 const promise2 = mutation.runAndReturn("key2");
237
238 await Promise.all([promise1, promise2]);
239
240 // They should start in parallel
241 assertEquals(executionOrder.slice(0, 2).sort(), ["start-key1", "start-key2"]);
242});
243
244test("QueuedMutation - key() returns JSON stringified key", () => {
245 const { client } = createTestClient();
246
247 const mutation = client.defineQueued({
248 async mutate(_, id: string) {
249 return id;
250 },
251 describe: "test mutation",
252 optimistic() {},
253 async refetch() {},
254 key({ args }) {
255 const [id] = args;
256 return id;
257 },
258 });
259
260 assertEquals(mutation.key(["test-id"]), JSON.stringify("test-id"));
261});
262
263test("QueuedMutation - key() defaults to 'shared' when no key function", () => {
264 const { client } = createTestClient();
265
266 const mutation = client.defineQueued({
267 async mutate(_, id: string) {
268 return id;
269 },
270 describe: "test mutation",
271 optimistic() {},
272 async refetch() {},
273 });
274
275 assertEquals(mutation.key(["test-id"]), JSON.stringify("shared"));
276});
277
278test("QueuedMutation - key() can return array", () => {
279 const { client } = createTestClient();
280
281 const mutation = client.defineQueued({
282 async mutate(_, _userId: string, _itemId: string) {
283 return "result";
284 },
285 describe: "test mutation",
286 optimistic() {},
287 async refetch() {},
288 key({ args }) {
289 const [userId, itemId] = args;
290 return [userId, itemId];
291 },
292 });
293
294 assertEquals(
295 mutation.key(["user1", "item1"]),
296 JSON.stringify(["user1", "item1"]),
297 );
298});
299
300test("QueuedMutation - describe() with string", () => {
301 const { client } = createTestClient();
302
303 const mutation = client.defineQueued({
304 async mutate(_, value: string) {
305 return value;
306 },
307 describe: "create item",
308 optimistic() {},
309 async refetch() {},
310 });
311
312 assertEquals(mutation.describe("test"), "create item");
313});
314
315test("QueuedMutation - describe() with function", () => {
316 const { client } = createTestClient();
317
318 const mutation = client.defineQueued({
319 async mutate(_, id: string) {
320 return id;
321 },
322 describe({ args }) {
323 const [id] = args;
324 return `delete item ${id}`;
325 },
326 optimistic() {},
327 async refetch() {},
328 });
329
330 assertEquals(mutation.describe("123"), "delete item 123");
331});
332
333test("QueuedMutation - describe() receives context", () => {
334 const { client } = createTestClient();
335
336 const mutation = client.defineQueued({
337 async mutate(_, id: string) {
338 return id;
339 },
340 describe({ userId, args }) {
341 const [id] = args;
342 return `user ${userId} editing item ${id}`;
343 },
344 optimistic() {},
345 async refetch() {},
346 });
347
348 assertEquals(
349 mutation.describe("123"),
350 "user test-user editing item 123",
351 );
352});
353
354test("QueuedMutation - subscribe() tracks mutation events", async () => {
355 const { client } = createTestClient();
356 const tracker = createEventTracker<string>();
357
358 const mutation = client.defineQueued({
359 async mutate(_, value: string) {
360 await delay(10);
361 return `result-${value}`;
362 },
363 describe: "test mutation",
364 optimistic() {},
365 async refetch() {
366 await delay(5);
367 },
368 });
369
370 const key = mutation.key(["test"]);
371 mutation.subscribe(key, tracker.callback);
372
373 await mutation.runAndReturn("test");
374 // Wait for refetch to complete
375 await delay(20);
376
377 // Should have received status updates
378 assertEquals(tracker.events.length >= 2, true);
379 assertEquals(tracker.events.some((e) => e.status === "mutating"), true);
380 assertEquals(tracker.events.some((e) => e.status === "refetching"), true);
381});
382
383test("QueuedMutation - unsubscribe stops receiving events", async () => {
384 const { client } = createTestClient();
385 const tracker = createEventTracker<string>();
386
387 const mutation = client.defineQueued({
388 async mutate(_, value: string) {
389 await delay(10);
390 return value;
391 },
392 describe: "test mutation",
393 optimistic() {},
394 async refetch() {},
395 refetchOnSuccess: false,
396 });
397
398 const key = mutation.key(["test"]);
399 const unsubscribe = mutation.subscribe(key, tracker.callback);
400
401 unsubscribe();
402
403 await mutation.runAndReturn("test");
404 await delay(10);
405
406 // Should not have received any events
407 assertEquals(tracker.events.length, 0);
408});
409
410test("QueuedMutation - refetchOnSuccess can be disabled", async () => {
411 const { client } = createTestClient();
412 let refetchCallCount = 0;
413
414 const mutation = client.defineQueued({
415 async mutate(_, _value: string) {
416 return _value;
417 },
418 describe: "test mutation",
419 optimistic() {},
420 async refetch() {
421 refetchCallCount++;
422 },
423 refetchOnSuccess: false,
424 });
425
426 await mutation.runAndReturn("test");
427
428 assertEquals(refetchCallCount, 0);
429});
430
431test("QueuedMutation - refetch is called on error", async () => {
432 const { client } = createTestClient();
433 let refetchCallCount = 0;
434
435 const mutation = client.defineQueued({
436 async mutate(_, _value: string) {
437 throw new Error("mutation failed");
438 },
439 describe: "failing mutation",
440 optimistic() {},
441 async refetch() {
442 refetchCallCount++;
443 },
444 });
445
446 await assertRejects(() => mutation.runAndReturn("test"));
447
448 assertEquals(refetchCallCount, 1);
449});
450
451test("QueuedMutation - queued mutations are cancelled on error", async () => {
452 const { client } = createTestClient();
453 const executionOrder: string[] = [];
454
455 const mutation = client.defineQueued({
456 async mutate(_, id: string) {
457 executionOrder.push(`start-${id}`);
458 await delay(10);
459 if (id === "1") {
460 throw new Error("first mutation failed");
461 }
462 executionOrder.push(`end-${id}`);
463 return id;
464 },
465 describe: "test mutation",
466 optimistic() {},
467 async refetch() {},
468 key() {
469 return "same-key";
470 },
471 });
472
473 const promise1 = mutation.runAndReturn("1");
474 const promise2 = mutation.runAndReturn("2");
475 const promise3 = mutation.runAndReturn("3");
476
477 await assertRejects(() => promise1, Error, "first mutation failed");
478 await assertRejects(() => promise2, Error, "first mutation failed");
479 await assertRejects(() => promise3, Error, "first mutation failed");
480
481 // Only the first mutation should start
482 assertEquals(executionOrder, ["start-1"]);
483});
484
485test("QueuedMutation - rollbacks are called in reverse order on error", async () => {
486 const { client } = createTestClient();
487 const rollbackOrder: number[] = [];
488
489 const mutation = client.defineQueued({
490 async mutate(_, _value: string) {
491 throw new Error("mutation failed");
492 },
493 describe: "failing mutation",
494 optimistic({ onRestore }) {
495 onRestore(() => rollbackOrder.push(1));
496 onRestore(() => rollbackOrder.push(2));
497 onRestore(() => rollbackOrder.push(3));
498 },
499 async refetch() {},
500 });
501
502 await assertRejects(() => mutation.runAndReturn("test"));
503
504 // Rollbacks should be called in reverse order
505 assertEquals(rollbackOrder, [3, 2, 1]);
506});
507
508test("QueuedMutation - multiple mutations: rollbacks only affect failed mutation", async () => {
509 const { client } = createTestClient();
510 const rollbackOrder: string[] = [];
511
512 const mutation = client.defineQueued({
513 async mutate(_, id: string) {
514 await delay(10);
515 if (id === "fail") {
516 throw new Error("mutation failed");
517 }
518 return id;
519 },
520 describe: "test mutation",
521 optimistic({ args: [id], onRestore }) {
522 onRestore(() => rollbackOrder.push(`rollback-${id}`));
523 },
524 async refetch() {},
525 key() {
526 return "same-key";
527 },
528 });
529
530 // First mutation succeeds
531 await mutation.runAndReturn("success");
532
533 // Second mutation fails
534 await assertRejects(() => mutation.runAndReturn("fail"));
535
536 // Only the failed mutation's rollback should be called
537 // And all rollbacks from queued items
538 assertEquals(rollbackOrder, ["rollback-fail"]);
539});
540
541test("QueuedMutation - onRestore throws error if called after optimistic phase", async () => {
542 const { client } = createTestClient();
543 let capturedOnRestore: ((cb: () => void) => void) | null = null;
544
545 const mutation = client.defineQueued({
546 async mutate(_, _value: string) {
547 return "result";
548 },
549 describe: "test mutation",
550 optimistic({ onRestore }) {
551 capturedOnRestore = onRestore;
552 },
553 async refetch() {},
554 });
555
556 await mutation.runAndReturn("test");
557
558 // Calling onRestore after the optimistic phase should throw
559 let error: Error | null = null;
560 try {
561 capturedOnRestore!(() => {});
562 } catch (e) {
563 error = e as Error;
564 }
565
566 assertEquals(
567 error?.message,
568 "Can only call onRestore from within the optimistic update function.",
569 );
570});
571
572test("QueuedMutation - onSuccess throws error if called after optimistic phase", async () => {
573 const { client } = createTestClient();
574 let capturedOnSuccess: ((cb: (result: string) => void) => void) | null = null;
575
576 const mutation = client.defineQueued({
577 async mutate(_, _value: string) {
578 return "result";
579 },
580 describe: "test mutation",
581 optimistic({ onSuccess }) {
582 capturedOnSuccess = onSuccess;
583 },
584 async refetch() {},
585 });
586
587 await mutation.runAndReturn("test");
588
589 // Calling onSuccess after the optimistic phase should throw
590 let error: Error | null = null;
591 try {
592 capturedOnSuccess!(() => {});
593 } catch (e) {
594 error = e as Error;
595 }
596
597 assertEquals(
598 error?.message,
599 "Can only call onSuccess from within the optimistic update function.",
600 );
601});
602
603test("QueuedMutation - error during optimistic update is rejected immediately", async () => {
604 const { client } = createTestClient();
605
606 const mutation = client.defineQueued({
607 async mutate(_, _value: string) {
608 return "result";
609 },
610 describe: "test mutation",
611 optimistic() {
612 throw new Error("optimistic update failed");
613 },
614 async refetch() {},
615 });
616
617 await assertRejects(
618 () => mutation.runAndReturn("test"),
619 Error,
620 "optimistic update failed",
621 );
622});
623
624test("QueuedMutation - error during optimistic update rolls back registered callbacks", async () => {
625 const { client } = createTestClient();
626 const rollbackOrder: number[] = [];
627
628 const mutation = client.defineQueued({
629 async mutate(_, _value: string) {
630 return "result";
631 },
632 describe: "test mutation",
633 optimistic({ onRestore }) {
634 onRestore(() => rollbackOrder.push(1));
635 onRestore(() => rollbackOrder.push(2));
636 throw new Error("optimistic update failed");
637 },
638 async refetch() {},
639 });
640
641 await assertRejects(() => mutation.runAndReturn("test"));
642
643 // Rollbacks should be called even though optimistic update failed
644 // Note: during optimistic error, rollbacks are executed in the order they were added
645 assertEquals(rollbackOrder, [1, 2]);
646});
647
648test("QueuedMutation - refetch errors are reported but don't fail mutation", async () => {
649 const { client, errors } = createTestClient();
650
651 const mutation = client.defineQueued({
652 async mutate(_, value: string) {
653 return value;
654 },
655 describe: "test mutation",
656 optimistic() {},
657 async refetch() {
658 throw new Error("refetch failed");
659 },
660 });
661
662 // Mutation should still succeed
663 const result = await mutation.runAndReturn("test");
664 assertEquals(result, "test");
665
666 // But refetch error should be reported
667 await delay(20);
668 assertEquals(errors.length, 1);
669 assertEquals((errors[0] as Error).message, "refetch failed");
670});
671
672test("QueuedMutation - optimistic function receives args and helpers", async () => {
673 const { client } = createTestClient();
674 let receivedArgs: unknown[] | undefined;
675 let receivedHelpers: unknown | undefined;
676
677 const mutation = client.defineQueued({
678 async mutate(_, _value: string) {
679 return "result";
680 },
681 describe: "test mutation",
682 optimistic({ args, helpers }) {
683 receivedArgs = args;
684 receivedHelpers = helpers;
685 },
686 async refetch() {},
687 });
688
689 await mutation.runAndReturn("test");
690
691 assertEquals(receivedArgs, ["test"]);
692 assertEquals(typeof receivedHelpers, "object");
693});
694
695test("QueuedMutation - refetch receives context and args", async () => {
696 const { client } = createTestClient();
697 let receivedUserId: string | undefined;
698 let receivedArgs: unknown[] | undefined;
699
700 const mutation = client.defineQueued({
701 async mutate(_, _id: string, value: string) {
702 return value;
703 },
704 describe: "test mutation",
705 optimistic() {},
706 async refetch({ userId, args }) {
707 receivedUserId = userId;
708 receivedArgs = args;
709 },
710 });
711
712 await mutation.runAndReturn("test-id", "test-value");
713
714 assertEquals(receivedUserId, "test-user");
715 assertEquals(receivedArgs, ["test-id", "test-value"]);
716});
717
718test("QueuedMutation - notifies error on mutation failure", async () => {
719 const { client } = createTestClient();
720 const tracker = createEventTracker<string>();
721
722 const mutation = client.defineQueued({
723 async mutate(_, _value: string) {
724 await delay(10);
725 throw new Error("mutation failed");
726 },
727 describe: "failing mutation",
728 optimistic() {},
729 async refetch() {},
730 });
731
732 const key = mutation.key(["test"]);
733 mutation.subscribe(key, tracker.callback);
734
735 await assertRejects(() => mutation.runAndReturn("test"));
736
737 // Should have error event
738 const errorEvents = tracker.events.filter((e) =>
739 e.status === "mutating" && e.error
740 );
741 assertEquals(errorEvents.length > 0, true);
742 assertEquals((errorEvents[0]?.error as Error).message, "mutation failed");
743});
744
745test("QueuedMutation - multiple subscribers receive events", async () => {
746 const { client } = createTestClient();
747 const tracker1 = createEventTracker<string>();
748 const tracker2 = createEventTracker<string>();
749
750 const mutation = client.defineQueued({
751 async mutate(_, value: string) {
752 await delay(5);
753 return value;
754 },
755 describe: "test mutation",
756 optimistic() {},
757 async refetch() {},
758 refetchOnSuccess: false,
759 });
760
761 const key = mutation.key(["test"]);
762 mutation.subscribe(key, tracker1.callback);
763 mutation.subscribe(key, tracker2.callback);
764
765 await mutation.runAndReturn("test");
766 await delay(10);
767
768 // Both subscribers should receive events
769 assertEquals(tracker1.events.length, tracker2.events.length);
770 assertEquals(tracker1.events.length > 0, true);
771});
772
773test("QueuedMutation - onSuccess is called before mutation resolves", async () => {
774 const { client } = createTestClient();
775 const callOrder: string[] = [];
776
777 const mutation = client.defineQueued({
778 async mutate(_, value: string) {
779 return value;
780 },
781 describe: "test mutation",
782 optimistic({ onSuccess }) {
783 onSuccess(() => {
784 callOrder.push("onSuccess");
785 });
786 },
787 async refetch() {},
788 refetchOnSuccess: false,
789 });
790
791 const promise = mutation.runAndReturn("test");
792 promise.then(() => {
793 callOrder.push("then");
794 });
795
796 await promise;
797 await delay(5);
798
799 // onSuccess should be called before the promise resolves
800 assertEquals(callOrder, ["onSuccess", "then"]);
801});
802
803test("QueuedMutation - result is passed to notification on success", async () => {
804 const { client } = createTestClient();
805 const tracker = createEventTracker<string>();
806
807 const mutation = client.defineQueued({
808 async mutate(_, value: string) {
809 await delay(5);
810 return `result-${value}`;
811 },
812 describe: "test mutation",
813 optimistic() {},
814 async refetch() {
815 await delay(5);
816 },
817 });
818
819 const key = mutation.key(["test"]);
820 mutation.subscribe(key, tracker.callback);
821
822 await mutation.runAndReturn("test");
823 await delay(20);
824
825 // Should have refetching event with result
826 const refetchingEvents = tracker.events.filter((e) =>
827 e.status === "refetching"
828 );
829 assertEquals(refetchingEvents.length > 0, true);
830 assertEquals(refetchingEvents[0]?.result, "result-test");
831});
832
833test("QueuedMutation - channel is reused for same key", async () => {
834 const { client } = createTestClient();
835 const events: string[] = [];
836
837 const mutation = client.defineQueued({
838 async mutate(_, value: string) {
839 events.push(`mutate-${value}`);
840 return value;
841 },
842 describe: "test mutation",
843 optimistic() {},
844 async refetch() {},
845 refetchOnSuccess: false,
846 });
847
848 // First mutation
849 await mutation.runAndReturn("first");
850 await delay(5);
851
852 // Second mutation with same key
853 await mutation.runAndReturn("second");
854 await delay(5);
855
856 assertEquals(events, ["mutate-first", "mutate-second"]);
857});
858
859test("QueuedMutation - empty queue after all mutations complete", async () => {
860 const { client } = createTestClient();
861
862 const mutation = client.defineQueued({
863 async mutate(_, value: string) {
864 await delay(5);
865 return value;
866 },
867 describe: "test mutation",
868 optimistic() {},
869 async refetch() {},
870 refetchOnSuccess: false,
871 key() {
872 return "test-key";
873 },
874 });
875
876 // Run multiple mutations
877 await mutation.runAndReturn("1");
878 await mutation.runAndReturn("2");
879 await mutation.runAndReturn("3");
880 await delay(10);
881
882 // All mutations should have completed
883 // (We can't directly check the queue, but we can verify by running another mutation)
884 const start = Date.now();
885 await mutation.runAndReturn("4");
886 const duration = Date.now() - start;
887
888 // Should execute immediately, not be queued (< 10ms if not queued)
889 assertEquals(duration < 15, true);
890});
891
892test("QueuedMutation - multiple onSuccess callbacks are all called", async () => {
893 const { client } = createTestClient();
894 const results: string[] = [];
895
896 const mutation = client.defineQueued({
897 async mutate(_, value: string) {
898 return value;
899 },
900 describe: "test mutation",
901 optimistic({ onSuccess }) {
902 onSuccess((result) => results.push(`first-${result}`));
903 onSuccess((result) => results.push(`second-${result}`));
904 onSuccess((result) => results.push(`third-${result}`));
905 },
906 async refetch() {},
907 refetchOnSuccess: false,
908 });
909
910 await mutation.runAndReturn("test");
911
912 assertEquals(results, ["first-test", "second-test", "third-test"]);
913});
914
915test("QueuedMutation - refetchOnSuccess false skips refetch", async () => {
916 const { client } = createTestClient();
917 let refetchCalled = false;
918
919 const mutation = client.defineQueued({
920 async mutate(_, value: string) {
921 return value;
922 },
923 describe: "test mutation",
924 optimistic() {},
925 async refetch() {
926 refetchCalled = true;
927 },
928 refetchOnSuccess: false,
929 });
930
931 await mutation.runAndReturn("test");
932 await delay(10);
933
934 // Refetch should not have been called
935 assertEquals(refetchCalled, false);
936});
937
938test("QueuedMutation - refetch error after mutation failure is reported", async () => {
939 const { client, errors } = createTestClient();
940
941 const mutation = client.defineQueued({
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});
test/tanstack-query-helpers.test.ts created+809
...@@ -0,0 +1,809 @@
1import { assertEquals } from "@std/assert";
2import { test } from "vitest";
3import { QueryClient, queryOptions } from "@tanstack/react-query";
4import { queryClientOptimisticHelpers } from "../src/tanstack-query.ts";
5
6interface TestData {
7 name: string;
8 count: number;
9 active: boolean;
10 settings: {
11 theme: string;
12 notifications: boolean;
13 };
14 items: Array<{ id: number; label: string }>;
15 tags: string[];
16}
17
18const initialData: TestData = {
19 name: "Test",
20 count: 10,
21 active: true,
22 settings: {
23 theme: "dark",
24 notifications: true,
25 },
26 items: [
27 { id: 1, label: "first" },
28 { id: 2, label: "second" },
29 { id: 3, label: "third" },
30 ],
31 tags: ["alpha", "beta", "gamma"],
32};
33
34function createTestQueryClient() {
35 const client = new QueryClient({
36 defaultOptions: { queries: { retry: false } },
37 });
38
39 const queryTest = queryOptions({
40 queryKey: ["test-data"],
41 queryFn: (): TestData => initialData,
42 });
43
44 client.setQueryData(queryTest.queryKey, structuredClone(initialData));
45
46 return { client, queryTest };
47}
48
49// ============================================================================
50// set() tests
51// ============================================================================
52
53test("set - should set entire query data and create if doesn't exist", () => {
54 const { client } = createTestQueryClient();
55 const restoreFns: Array<() => void> = [];
56
57 const helpers = queryClientOptimisticHelpers(client)({
58 onRestore: (fn) => restoreFns.push(fn),
59 onRefetch: () => {},
60 });
61
62 const queryNew = queryOptions({
63 queryKey: ["new-query"],
64 queryFn: (): TestData => initialData,
65 });
66
67 const newData: TestData = {
68 name: "Created",
69 count: 99,
70 active: false,
71 settings: { theme: "light", notifications: false },
72 items: [],
73 tags: [],
74 };
75
76 helpers.set(queryNew, newData);
77
78 const result = client.getQueryData<TestData>(queryNew.queryKey);
79 assertEquals(result, newData);
80
81 // Restore should remove it since it didn't exist before
82 restoreFns[0]();
83 const restored = client.getQueryData<TestData>(queryNew.queryKey);
84 assertEquals(restored, undefined);
85});
86
87test("set - should update existing query data", () => {
88 const { client, queryTest } = createTestQueryClient();
89 const restoreFns: Array<() => void> = [];
90
91 const helpers = queryClientOptimisticHelpers(client)({
92 onRestore: (fn) => restoreFns.push(fn),
93 onRefetch: () => {},
94 });
95
96 const newData: TestData = {
97 name: "Updated",
98 count: 99,
99 active: false,
100 settings: { theme: "light", notifications: false },
101 items: [],
102 tags: [],
103 };
104
105 helpers.set(queryTest, newData);
106
107 const result = client.getQueryData<TestData>(queryTest.queryKey);
108 assertEquals(result, newData);
109
110 // Restore should revert to original
111 restoreFns[0]();
112 const restored = client.getQueryData<TestData>(queryTest.queryKey);
113 assertEquals(restored?.name, "Test");
114 assertEquals(restored?.count, 10);
115});
116
117test("set - should accept updater function", () => {
118 const { client, queryTest } = createTestQueryClient();
119
120 const helpers = queryClientOptimisticHelpers(client)({
121 onRestore: () => {},
122 onRefetch: () => {},
123 });
124
125 helpers.set(queryTest, (prev) => prev ? { ...prev, name: "Updated" } : prev);
126
127 const result = client.getQueryData<TestData>(queryTest.queryKey);
128 assertEquals(result?.name, "Updated");
129 assertEquals(result?.count, 10); // Other fields unchanged
130});
131
132// ============================================================================
133// updateExisting() tests
134// ============================================================================
135
136test("updateExisting - should update existing query data", () => {
137 const { client, queryTest } = createTestQueryClient();
138 const restoreFns: Array<() => void> = [];
139
140 const helpers = queryClientOptimisticHelpers(client)({
141 onRestore: (fn) => restoreFns.push(fn),
142 onRefetch: () => {},
143 });
144
145 const newData: TestData = {
146 name: "Updated",
147 count: 99,
148 active: false,
149 settings: { theme: "light", notifications: false },
150 items: [],
151 tags: [],
152 };
153
154 helpers.updateExisting(queryTest, newData);
155
156 const result = client.getQueryData<TestData>(queryTest.queryKey);
157 assertEquals(result, newData);
158
159 // Restore should revert to original
160 restoreFns[0]();
161 const restored = client.getQueryData<TestData>(queryTest.queryKey);
162 assertEquals(restored?.name, "Test");
163});
164
165test("updateExisting - should skip if query doesn't exist", () => {
166 const { client } = createTestQueryClient();
167 const restoreFns: Array<() => void> = [];
168
169 const helpers = queryClientOptimisticHelpers(client)({
170 onRestore: (fn) => restoreFns.push(fn),
171 onRefetch: () => {},
172 });
173
174 const queryNonexistent = queryOptions({
175 queryKey: ["nonexistent"],
176 queryFn: (): TestData => initialData,
177 });
178
179 helpers.updateExisting(queryNonexistent, { name: "test" } as any);
180
181 assertEquals(restoreFns.length, 0);
182});
183
184// ============================================================================
185// objSet() tests
186// ============================================================================
187
188test("objSet - should set property at path", () => {
189 const { client, queryTest } = createTestQueryClient();
190 const restoreFns: Array<() => void> = [];
191
192 const helpers = queryClientOptimisticHelpers(client)({
193 onRestore: (fn) => restoreFns.push(fn),
194 onRefetch: () => {},
195 });
196
197 helpers.objSet(queryTest, ["name"], "NewName");
198
199 const result = client.getQueryData<TestData>(queryTest.queryKey);
200 assertEquals(result?.name, "NewName");
201 assertEquals(result?.count, 10);
202
203 // Restore
204 restoreFns[0]();
205 const restored = client.getQueryData<TestData>(queryTest.queryKey);
206 assertEquals(restored?.name, "Test");
207});
208
209test("objSet - should set nested property", () => {
210 const { client, queryTest } = createTestQueryClient();
211 const restoreFns: Array<() => void> = [];
212
213 const helpers = queryClientOptimisticHelpers(client)({
214 onRestore: (fn) => restoreFns.push(fn),
215 onRefetch: () => {},
216 });
217
218 helpers.objSet(queryTest, ["settings", "theme"], "light");
219
220 const result = client.getQueryData<TestData>(queryTest.queryKey);
221 assertEquals(result?.settings.theme, "light");
222 assertEquals(result?.settings.notifications, true);
223
224 // Restore
225 restoreFns[0]();
226 const restored = client.getQueryData<TestData>(queryTest.queryKey);
227 assertEquals(restored?.settings.theme, "dark");
228});
229
230test("objSet - should accept updater function", () => {
231 const { client, queryTest } = createTestQueryClient();
232
233 const helpers = queryClientOptimisticHelpers(client)({
234 onRestore: () => {},
235 onRefetch: () => {},
236 });
237
238 helpers.objSet(queryTest, ["count"], (n) => n + 5);
239
240 const result = client.getQueryData<TestData>(queryTest.queryKey);
241 assertEquals(result?.count, 15);
242});
243
244// ============================================================================
245// objIncrement() tests
246// ============================================================================
247
248test("objIncrement - should increment by 1 by default", () => {
249 const { client, queryTest } = createTestQueryClient();
250 const restoreFns: Array<() => void> = [];
251
252 const helpers = queryClientOptimisticHelpers(client)({
253 onRestore: (fn) => restoreFns.push(fn),
254 onRefetch: () => {},
255 });
256
257 helpers.objIncrement(queryTest, ["count"]);
258
259 const result = client.getQueryData<TestData>(queryTest.queryKey);
260 assertEquals(result?.count, 11);
261
262 // Restore
263 restoreFns[0]();
264 const restored = client.getQueryData<TestData>(queryTest.queryKey);
265 assertEquals(restored?.count, 10);
266});
267
268test("objIncrement - should increment by custom amount", () => {
269 const { client, queryTest } = createTestQueryClient();
270
271 const helpers = queryClientOptimisticHelpers(client)({
272 onRestore: () => {},
273 onRefetch: () => {},
274 });
275
276 helpers.objIncrement(queryTest, ["count"], 5);
277
278 const result = client.getQueryData<TestData>(queryTest.queryKey);
279 assertEquals(result?.count, 15);
280});
281
282test("objIncrement - should skip if value is not a number", () => {
283 const { client, queryTest } = createTestQueryClient();
284 const restoreFns: Array<() => void> = [];
285
286 const helpers = queryClientOptimisticHelpers(client)({
287 onRestore: (fn) => restoreFns.push(fn),
288 onRefetch: () => {},
289 });
290
291 helpers.objIncrement(queryTest, ["name"] as any, 5);
292
293 const result = client.getQueryData<TestData>(queryTest.queryKey);
294 assertEquals(result?.name, "Test"); // Unchanged
295 assertEquals(restoreFns.length, 0); // No restore registered
296});
297
298// ============================================================================
299// objDecrement() tests
300// ============================================================================
301
302test("objDecrement - should decrement by 1 by default", () => {
303 const { client, queryTest } = createTestQueryClient();
304
305 const helpers = queryClientOptimisticHelpers(client)({
306 onRestore: () => {},
307 onRefetch: () => {},
308 });
309
310 helpers.objDecrement(queryTest, ["count"]);
311
312 const result = client.getQueryData<TestData>(queryTest.queryKey);
313 assertEquals(result?.count, 9);
314});
315
316test("objDecrement - should decrement by custom amount", () => {
317 const { client, queryTest } = createTestQueryClient();
318
319 const helpers = queryClientOptimisticHelpers(client)({
320 onRestore: () => {},
321 onRefetch: () => {},
322 });
323
324 helpers.objDecrement(queryTest, ["count"], 3);
325
326 const result = client.getQueryData<TestData>(queryTest.queryKey);
327 assertEquals(result?.count, 7);
328});
329
330// ============================================================================
331// objToggle() tests
332// ============================================================================
333
334test("objToggle - should toggle boolean value", () => {
335 const { client, queryTest } = createTestQueryClient();
336 const restoreFns: Array<() => void> = [];
337
338 const helpers = queryClientOptimisticHelpers(client)({
339 onRestore: (fn) => restoreFns.push(fn),
340 onRefetch: () => {},
341 });
342
343 helpers.objToggle(queryTest, ["active"]);
344
345 const result = client.getQueryData<TestData>(queryTest.queryKey);
346 assertEquals(result?.active, false);
347
348 // Restore
349 restoreFns[0]();
350 const restored = client.getQueryData<TestData>(queryTest.queryKey);
351 assertEquals(restored?.active, true);
352});
353
354test("objToggle - should toggle nested boolean", () => {
355 const { client, queryTest } = createTestQueryClient();
356
357 const helpers = queryClientOptimisticHelpers(client)({
358 onRestore: () => {},
359 onRefetch: () => {},
360 });
361
362 helpers.objToggle(queryTest, ["settings", "notifications"]);
363
364 const result = client.getQueryData<TestData>(queryTest.queryKey);
365 assertEquals(result?.settings.notifications, false);
366});
367
368test("objToggle - should skip if value is not boolean", () => {
369 const { client, queryTest } = createTestQueryClient();
370 const restoreFns: Array<() => void> = [];
371
372 const helpers = queryClientOptimisticHelpers(client)({
373 onRestore: (fn) => restoreFns.push(fn),
374 onRefetch: () => {},
375 });
376
377 helpers.objToggle(queryTest, ["count"] as any);
378
379 const result = client.getQueryData<TestData>(queryTest.queryKey);
380 assertEquals(result?.count, 10); // Unchanged
381 assertEquals(restoreFns.length, 0);
382});
383
384// ============================================================================
385// objSetMany() tests
386// ============================================================================
387
388test("objSetMany - should merge multiple properties", () => {
389 const { client, queryTest } = createTestQueryClient();
390 const restoreFns: Array<() => void> = [];
391
392 const helpers = queryClientOptimisticHelpers(client)({
393 onRestore: (fn) => restoreFns.push(fn),
394 onRefetch: () => {},
395 });
396
397 helpers.objSetMany(queryTest, ["settings"], {
398 theme: "light",
399 });
400
401 const result = client.getQueryData<TestData>(queryTest.queryKey);
402 assertEquals(result?.settings.theme, "light");
403 assertEquals(result?.settings.notifications, true); // Unchanged
404
405 // Restore
406 restoreFns[0]();
407 const restored = client.getQueryData<TestData>(queryTest.queryKey);
408 assertEquals(restored?.settings.theme, "dark");
409});
410
411test("objSetMany - should work at root level", () => {
412 const { client, queryTest } = createTestQueryClient();
413
414 const helpers = queryClientOptimisticHelpers(client)({
415 onRestore: () => {},
416 onRefetch: () => {},
417 });
418
419 helpers.objSetMany(queryTest, [], {
420 name: "Updated",
421 count: 99,
422 });
423
424 const result = client.getQueryData<TestData>(queryTest.queryKey);
425 assertEquals(result?.name, "Updated");
426 assertEquals(result?.count, 99);
427 assertEquals(result?.active, true); // Unchanged
428});
429
430// ============================================================================
431// arrayPush() tests
432// ============================================================================
433
434test("arrayPush - should add items to end of array", () => {
435 const { client, queryTest } = createTestQueryClient();
436 const restoreFns: Array<() => void> = [];
437
438 const helpers = queryClientOptimisticHelpers(client)({
439 onRestore: (fn) => restoreFns.push(fn),
440 onRefetch: () => {},
441 });
442
443 helpers.arrayPush(
444 queryTest,
445 ["items"],
446 { id: 4, label: "fourth" },
447 { id: 5, label: "fifth" },
448 );
449
450 const result = client.getQueryData<TestData>(queryTest.queryKey);
451 assertEquals(result?.items.length, 5);
452 assertEquals(result?.items[3], { id: 4, label: "fourth" });
453 assertEquals(result?.items[4], { id: 5, label: "fifth" });
454
455 // Restore
456 restoreFns[0]();
457 const restored = client.getQueryData<TestData>(queryTest.queryKey);
458 assertEquals(restored?.items.length, 3);
459});
460
461test("arrayPush - should work with simple arrays", () => {
462 const { client, queryTest } = createTestQueryClient();
463
464 const helpers = queryClientOptimisticHelpers(client)({
465 onRestore: () => {},
466 onRefetch: () => {},
467 });
468
469 helpers.arrayPush(queryTest, ["tags"], "delta", "epsilon");
470
471 const result = client.getQueryData<TestData>(queryTest.queryKey);
472 assertEquals(result?.tags, ["alpha", "beta", "gamma", "delta", "epsilon"]);
473});
474
475// ============================================================================
476// arrayUnshift() tests
477// ============================================================================
478
479test("arrayUnshift - should add items to beginning of array", () => {
480 const { client, queryTest } = createTestQueryClient();
481 const restoreFns: Array<() => void> = [];
482
483 const helpers = queryClientOptimisticHelpers(client)({
484 onRestore: (fn) => restoreFns.push(fn),
485 onRefetch: () => {},
486 });
487
488 helpers.arrayUnshift(
489 queryTest,
490 ["items"],
491 { id: 0, label: "zeroth" },
492 );
493
494 const result = client.getQueryData<TestData>(queryTest.queryKey);
495 assertEquals(result?.items.length, 4);
496 assertEquals(result?.items[0], { id: 0, label: "zeroth" });
497 assertEquals(result?.items[1], { id: 1, label: "first" });
498
499 // Restore
500 restoreFns[0]();
501 const restored = client.getQueryData<TestData>(queryTest.queryKey);
502 assertEquals(restored?.items.length, 3);
503 assertEquals(restored?.items[0], { id: 1, label: "first" });
504});
505
506// ============================================================================
507// arrayRemoveItem() tests
508// ============================================================================
509
510test("arrayRemoveItem - should remove items matching predicate", () => {
511 const { client, queryTest } = createTestQueryClient();
512 const restoreFns: Array<() => void> = [];
513
514 const helpers = queryClientOptimisticHelpers(client)({
515 onRestore: (fn) => restoreFns.push(fn),
516 onRefetch: () => {},
517 });
518
519 helpers.arrayRemoveItem(
520 queryTest,
521 ["items"],
522 (item) => item.id === 2,
523 );
524
525 const result = client.getQueryData<TestData>(queryTest.queryKey);
526 assertEquals(result?.items.length, 2);
527 assertEquals(result?.items[0], { id: 1, label: "first" });
528 assertEquals(result?.items[1], { id: 3, label: "third" });
529
530 // Restore
531 restoreFns[0]();
532 const restored = client.getQueryData<TestData>(queryTest.queryKey);
533 assertEquals(restored?.items.length, 3);
534});
535
536test("arrayRemoveItem - should remove multiple items", () => {
537 const { client, queryTest } = createTestQueryClient();
538
539 const helpers = queryClientOptimisticHelpers(client)({
540 onRestore: () => {},
541 onRefetch: () => {},
542 });
543
544 helpers.arrayRemoveItem(
545 queryTest,
546 ["items"],
547 (item) => item.id > 1,
548 );
549
550 const result = client.getQueryData<TestData>(queryTest.queryKey);
551 assertEquals(result?.items.length, 1);
552 assertEquals(result?.items[0], { id: 1, label: "first" });
553});
554
555test("arrayRemoveItem - should work with simple arrays", () => {
556 const { client, queryTest } = createTestQueryClient();
557
558 const helpers = queryClientOptimisticHelpers(client)({
559 onRestore: () => {},
560 onRefetch: () => {},
561 });
562
563 helpers.arrayRemoveItem(
564 queryTest,
565 ["tags"],
566 (tag) => tag === "beta",
567 );
568
569 const result = client.getQueryData<TestData>(queryTest.queryKey);
570 assertEquals(result?.tags, ["alpha", "gamma"]);
571});
572
573// ============================================================================
574// arrayUpdateItem() tests
575// ============================================================================
576
577test("arrayUpdateItem - should update items matching predicate", () => {
578 const { client, queryTest } = createTestQueryClient();
579 const restoreFns: Array<() => void> = [];
580
581 const helpers = queryClientOptimisticHelpers(client)({
582 onRestore: (fn) => restoreFns.push(fn),
583 onRefetch: () => {},
584 });
585
586 helpers.arrayUpdateItem(
587 queryTest,
588 ["items"],
589 (item) => item.id === 2,
590 (item) => ({ ...item, label: "UPDATED" }),
591 );
592
593 const result = client.getQueryData<TestData>(queryTest.queryKey);
594 assertEquals(result?.items[1], { id: 2, label: "UPDATED" });
595 assertEquals(result?.items[0], { id: 1, label: "first" });
596
597 // Restore
598 restoreFns[0]();
599 const restored = client.getQueryData<TestData>(queryTest.queryKey);
600 assertEquals(restored?.items[1], { id: 2, label: "second" });
601});
602
603test("arrayUpdateItem - should update multiple items", () => {
604 const { client, queryTest } = createTestQueryClient();
605
606 const helpers = queryClientOptimisticHelpers(client)({
607 onRestore: () => {},
608 onRefetch: () => {},
609 });
610
611 helpers.arrayUpdateItem(
612 queryTest,
613 ["items"],
614 (item) => item.id > 1,
615 (item) => ({ ...item, label: item.label.toUpperCase() }),
616 );
617
618 const result = client.getQueryData<TestData>(queryTest.queryKey);
619 assertEquals(result?.items[0].label, "first"); // Unchanged
620 assertEquals(result?.items[1].label, "SECOND");
621 assertEquals(result?.items[2].label, "THIRD");
622});
623
624test("arrayUpdateItem - predicate receives index", () => {
625 const { client, queryTest } = createTestQueryClient();
626
627 const helpers = queryClientOptimisticHelpers(client)({
628 onRestore: () => {},
629 onRefetch: () => {},
630 });
631
632 helpers.arrayUpdateItem(
633 queryTest,
634 ["items"],
635 (_item, index) => index === 0,
636 (item) => ({ ...item, label: "FIRST" }),
637 );
638
639 const result = client.getQueryData<TestData>(queryTest.queryKey);
640 assertEquals(result?.items[0].label, "FIRST");
641});
642
643// ============================================================================
644// arrayInsertIndex() tests
645// ============================================================================
646
647test("arrayInsertIndex - should insert at specific index", () => {
648 const { client, queryTest } = createTestQueryClient();
649 const restoreFns: Array<() => void> = [];
650
651 const helpers = queryClientOptimisticHelpers(client)({
652 onRestore: (fn) => restoreFns.push(fn),
653 onRefetch: () => {},
654 });
655
656 helpers.arrayInsertIndex(
657 queryTest,
658 ["items"],
659 1,
660 { id: 99, label: "inserted" },
661 );
662
663 const result = client.getQueryData<TestData>(queryTest.queryKey);
664 assertEquals(result?.items.length, 4);
665 assertEquals(result?.items[0], { id: 1, label: "first" });
666 assertEquals(result?.items[1], { id: 99, label: "inserted" });
667 assertEquals(result?.items[2], { id: 2, label: "second" });
668
669 // Restore
670 restoreFns[0]();
671 const restored = client.getQueryData<TestData>(queryTest.queryKey);
672 assertEquals(restored?.items.length, 3);
673});
674
675test("arrayInsertIndex - should insert at beginning", () => {
676 const { client, queryTest } = createTestQueryClient();
677
678 const helpers = queryClientOptimisticHelpers(client)({
679 onRestore: () => {},
680 onRefetch: () => {},
681 });
682
683 helpers.arrayInsertIndex(
684 queryTest,
685 ["tags"],
686 0,
687 "prefix",
688 );
689
690 const result = client.getQueryData<TestData>(queryTest.queryKey);
691 assertEquals(result?.tags, ["prefix", "alpha", "beta", "gamma"]);
692});
693
694test("arrayInsertIndex - should insert at end", () => {
695 const { client, queryTest } = createTestQueryClient();
696
697 const helpers = queryClientOptimisticHelpers(client)({
698 onRestore: () => {},
699 onRefetch: () => {},
700 });
701
702 helpers.arrayInsertIndex(
703 queryTest,
704 ["tags"],
705 3,
706 "suffix",
707 );
708
709 const result = client.getQueryData<TestData>(queryTest.queryKey);
710 assertEquals(result?.tags, ["alpha", "beta", "gamma", "suffix"]);
711});
712
713test("arrayInsertIndex - should insert multiple items", () => {
714 const { client, queryTest } = createTestQueryClient();
715
716 const helpers = queryClientOptimisticHelpers(client)({
717 onRestore: () => {},
718 onRefetch: () => {},
719 });
720
721 helpers.arrayInsertIndex(
722 queryTest,
723 ["tags"],
724 1,
725 "one",
726 "two",
727 );
728
729 const result = client.getQueryData<TestData>(queryTest.queryKey);
730 assertEquals(result?.tags, ["alpha", "one", "two", "beta", "gamma"]);
731});
732
733// ============================================================================
734// removeQuery() tests
735// ============================================================================
736
737test("removeQuery - should remove query from cache", () => {
738 const { client, queryTest } = createTestQueryClient();
739 const restoreFns: Array<() => void> = [];
740
741 const helpers = queryClientOptimisticHelpers(client)({
742 onRestore: (fn) => restoreFns.push(fn),
743 onRefetch: () => {},
744 });
745
746 helpers.removeQuery(queryTest);
747
748 const result = client.getQueryData<TestData>(queryTest.queryKey);
749 assertEquals(result, undefined);
750
751 // Restore should bring it back
752 restoreFns[0]();
753 const restored = client.getQueryData<TestData>(queryTest.queryKey);
754 assertEquals(restored?.name, "Test");
755});
756
757test("removeQuery - should skip if query doesn't exist", () => {
758 const { client } = createTestQueryClient();
759 const restoreFns: Array<() => void> = [];
760
761 const helpers = queryClientOptimisticHelpers(client)({
762 onRestore: (fn) => restoreFns.push(fn),
763 onRefetch: () => {},
764 });
765
766 const queryNonexistent = queryOptions({
767 queryKey: ["nonexistent"],
768 queryFn: (): TestData => initialData,
769 });
770
771 helpers.removeQuery(queryNonexistent);
772
773 assertEquals(restoreFns.length, 0);
774});
775
776// ============================================================================
777// Integration tests
778// ============================================================================
779
780test("integration - multiple operations work together", () => {
781 const { client, queryTest } = createTestQueryClient();
782 const restoreFns: Array<() => void> = [];
783
784 const helpers = queryClientOptimisticHelpers(client)({
785 onRestore: (fn) => restoreFns.push(fn),
786 onRefetch: () => {},
787 });
788
789 // Perform multiple operations
790 helpers.objIncrement(queryTest, ["count"], 5);
791 helpers.arrayPush(queryTest, ["tags"], "delta");
792 helpers.objToggle(queryTest, ["active"]);
793 helpers.arrayRemoveItem(queryTest, ["items"], (item) => item.id === 2);
794
795 const result = client.getQueryData<TestData>(queryTest.queryKey);
796 assertEquals(result?.count, 15);
797 assertEquals(result?.tags, ["alpha", "beta", "gamma", "delta"]);
798 assertEquals(result?.active, false);
799 assertEquals(result?.items.length, 2);
800
801 // Restore in reverse order
802 restoreFns.reverse().forEach((fn) => fn());
803
804 const restored = client.getQueryData<TestData>(queryTest.queryKey);
805 assertEquals(restored?.count, 10);
806 assertEquals(restored?.tags, ["alpha", "beta", "gamma"]);
807 assertEquals(restored?.active, true);
808 assertEquals(restored?.items.length, 3);
809});
tsconfig.json+16-14
...@@ -1,23 +1,25 @@...@@ -1,23 +1,25 @@
1{1{
2 "compilerOptions": {2 "compilerOptions": {
3 "target": "ESNext",3 "target": "ESNext",
4 "lib": ["DOM", "ESNext"],
5 "module": "NodeNext",4 "module": "NodeNext",
6 "moduleResolution": "NodeNext",5 "lib": ["ESNext", "DOM"],
6 "strict": true,
7 "esModuleInterop": true,
7 "skipLibCheck": true,8 "skipLibCheck": true,
8 "verbatimModuleSyntax": true,9 "forceConsistentCasingInFileNames": true,
9 "resolveJsonModule": true,10 "declaration": true,
10 "allowImportingTsExtensions": true,11 "declarationMap": true,
12 "sourceMap": true,
13 "moduleResolution": "nodenext",
14 "moduleDetection": "force",
11 "noEmit": true,15 "noEmit": true,
16 "allowImportingTsExtensions": true,
12 "jsx": "react-jsx",17 "jsx": "react-jsx",
1318 "types": ["react"],
14 // strictness19 "paths": {
15 "strict": true,20 "@clo/react-mutation": ["./src/index.ts"]
16 "noUnusedLocals": true,21 }
17 "noUnusedParameters": true,
18 "noFallthroughCasesInSwitch": true,
19 "noUncheckedIndexedAccess": true,
20 "allowUnreachableCode": false
21 },22 },
22 "include": ["src", "test"]23 "include": ["src/**/*", "test/**/*"],
24 "exclude": ["node_modules"]
23}25}
vitest.config.ts+4-7
...@@ -2,15 +2,12 @@ import { defineConfig } from "vitest/config";...@@ -2,15 +2,12 @@ import { defineConfig } from "vitest/config";
22
3export default defineConfig({3export default defineConfig({
4 test: {4 test: {
5 globals: true,
6 environment: "node",
7 include: ["test/**/*.test.ts"],
5 coverage: {8 coverage: {
6 enabled: true,
7 provider: "v8",9 provider: "v8",
8 reporter: [10 reporter: ["text", "json", "html", "json-summary"],
9 process.argv.includes("--ui") ? "html" : "html-spa",
10 "json-summary",
11 "json",
12 ],
13 reportsDirectory: "./coverage",
14 },11 },
15 },12 },
16});13});