| author | |
| committer | |
| log | fd7e4484d26ac5e095cb39bfb2bfc17fe6527d43 |
| tree | b675cb4f43ce26262714e7fcf4ed6bfe2bcf7ed3 |
| parent | af2a34170fc51b625182c42987277eec81558c1e |
| signature |
29 files changed, 6263 insertions(+), 625 deletions(-)
.gitignore+1| ... | @@ -1,2 +1,3 @@ | ... | @@ -1,2 +1,3 @@ |
| 1 | node_modules | 1 | node_modules |
| 2 | coverage | 2 | coverage |
| 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 @@ | ||
| 1 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; | ||
| 2 | import { | ||
| 3 | createMutationButton, | ||
| 4 | MutationClient, | ||
| 5 | queryClientOptimisticHelpers, | ||
| 6 | useMutation, | ||
| 7 | } from "@clo/react-mutation"; | ||
| 8 | import { queryOptions as queryOptions } from "@tanstack/react-query"; | ||
| 9 | import { useSuspenseQuery } from "@tanstack/react-query"; | ||
| 10 | import { QueryKeyAndFn } from "../../src/tanstack-query.ts"; | ||
| 11 | |||
| 12 | const queryClient = new QueryClient(); | ||
| 13 | const 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 | ||
| 27 | let backendValue = 0; | ||
| 28 | const 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 | // }); | ||
| 52 | const 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 | |||
| 74 | function 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 | |||
| 91 | const MutationButton = createMutationButton(CustomButton); | ||
| 92 | |||
| 93 | function 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 | |||
| 127 | function App() { | ||
| 128 | return ( | ||
| 129 | <QueryClientProvider client={queryClient}> | ||
| 130 | <div className="app"> | ||
| 131 | <Counter /> | ||
| 132 | </div> | ||
| 133 | </QueryClientProvider> | ||
| 134 | ); | ||
| 135 | } | ||
| 136 | |||
| 137 | export 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 | |||
| 21 | body { | ||
| 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 | |||
| 43 | h1 { | ||
| 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 | |||
| 149 | button { | ||
| 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 | |||
| 181 | button:disabled { | ||
| 182 | opacity: 0.6; | ||
| 183 | cursor: not-allowed; | ||
| 184 | } | ||
| 185 | |||
| 186 | button:focus, | ||
| 187 | button: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 @@ | ||
| 1 | import { StrictMode } from "react"; | ||
| 2 | import { createRoot } from "react-dom/client"; | ||
| 3 | import App from "./App.tsx"; | ||
| 4 | import "./index.css"; | ||
| 5 | |||
| 6 | createRoot(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 @@ | ||
| 1 | import { defineConfig } from "vite"; | ||
| 2 | import react from "@vitejs/plugin-react"; | ||
| 3 | import path from "node:path"; | ||
| 4 | |||
| 5 | // https://vite.dev/config/ | ||
| 6 | export 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 | |||
| 5 | At work, we found React Query, with a few helper functions, to be extremely | ||
| 6 | useful for fetching and synchronizing dynamic state in the browser. However, | ||
| 7 | their mutation story falls apart, is confusing, and misses a few obvious | ||
| 8 | features. Additionally, coworkers using AI agents continue to propagate bad | ||
| 9 | patterns and verbose code that is hard to review. | ||
| 10 | |||
| 11 | The 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 | |||
| 22 | This library declares two kinds of mutations. Each kind has different behavior | ||
| 23 | around 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 | |||
| 35 | React Mutation starts with a `MutationClient`, which shares global state for an application. | ||
| 36 | |||
| 37 | ```ts | ||
| 38 | const queryClient = new QueryClient(); | ||
| 39 | export 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 | |||
| 64 | A queued mutation is defined with `mutations.defineQueued`. These are useful for creating | ||
| 65 | |||
| 66 | ```tsx | ||
| 67 | const queryCounter = queryOptions({ | ||
| 68 | queryKey: ["items"], | ||
| 69 | queryFn: (): Promise<string[]> => fetch(), | ||
| 70 | }); | ||
| 71 | const queryItem = (id: string) => queryOptions({ | ||
| 72 | queryKey: ["items", id], | ||
| 73 | queryFn: (): Promise<object> => fetch(), | ||
| 74 | }); | ||
| 75 | |||
| 76 | const 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 | |||
| 98 | export 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 @@ |
| 1 | export function defineBatchMutation<Args extends unknown[], Result, Optimistic>( | 1 | import type { MutationClient, MutationClientFromConfig } from "./client.ts"; |
| 2 | options: BatchMutationOptions<Args, Result, Optimistic>, | 2 | import type { MutationClientConfig } from "./client.ts"; |
| 3 | ): Mutation<Args, Result> {} | 3 | import type { Mutation, MutationEvent } from "./types.ts"; |
| 4 | |||
| 5 | export 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 | |||
| 59 | export 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 | |||
| 67 | export 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 | |||
| 80 | interface 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 | |||
| 103 | export 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 @@ |
| 1 | import { type MutationOptions, QueuedMutation } from "./queued"; | 1 | import { BatchMutation, type BatchMutationOptions } from "./batch.ts"; |
| 2 | import type { Mutation } from "./types"; | 2 | import { type MutationOptions, QueuedMutation } from "./queued.ts"; |
| 3 | import type { Mutation } from "./types.ts"; | ||
| 4 | |||
| 5 | export interface MutationClientConfig { | ||
| 6 | context: {}; | ||
| 7 | optimisticHelpers: {}; | ||
| 8 | } | ||
| 9 | |||
| 10 | export type MutationClientFromConfig<Config extends MutationClientConfig> = | ||
| 11 | MutationClient<Config["context"], Config["optimisticHelpers"]>; | ||
| 12 | |||
| 13 | const defaultDeepEquals = (a: unknown, b: unknown): boolean => | ||
| 14 | JSON.stringify(a) === JSON.stringify(b); | ||
| 3 | 15 | ||
| 4 | export interface MutationClientOptions< | 16 | export 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 | |||
| 33 | export interface OptimisticEvents { | ||
| 34 | onRestore: (cb: () => void) => void; | ||
| 35 | onRefetch: (cb: () => Promise<void>) => void; | ||
| 12 | } | 36 | } |
| 13 | 37 | ||
| 14 | export class MutationClient< | 38 | export 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 | } | ||
| 32 | 46 | ||
| 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 | } |
| 36 | 53 | ||
| 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 | } |
| 50 | 72 | ||
| 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 @@ | ||
| 1 | export { type MutationOptions, type OptimisticContext } from "./queued.ts"; | ||
| 2 | export { | ||
| 3 | MutationClient, | ||
| 4 | type MutationClientFromConfig, | ||
| 5 | type MutationClientOptions, | ||
| 6 | } from "./client.ts"; | ||
| 7 | export { | ||
| 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"; | ||
| 17 | export { queryClientOptimisticHelpers } from "./tanstack-query.ts"; | ||
src/object-path.ts created+77| ... | @@ -0,0 +1,77 @@ | ||
| 1 | import { assert } from "@std/assert"; | ||
| 2 | |||
| 3 | export 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 | : []; | ||
| 11 | export 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 */ | ||
| 16 | export 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 | |||
| 40 | export 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 @@ |
| 1 | import type { MutationClient } from "./client.ts"; | 1 | import type { MutationClient, MutationClientFromConfig } from "./client.ts"; |
| 2 | import type { GlobalMutationConfig, MutationClientConfig } from "./register.ts"; | 2 | import type { MutationClientConfig } from "./client.ts"; |
| 3 | import type { Mutation, MutationEvent } from "./types.ts"; | 3 | import type { Mutation, MutationEvent } from "./types.ts"; |
| 4 | 4 | ||
| 5 | /** | 5 | /** |
| ... | @@ -11,7 +11,7 @@ import type { Mutation, MutationEvent } from "./types.ts"; | ... | @@ -11,7 +11,7 @@ import type { Mutation, MutationEvent } from "./types.ts"; |
| 11 | export interface MutationOptions< | 11 | export 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 the | 20 | * 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 true | 41 | * @default true |
| ... | @@ -52,58 +52,99 @@ export interface MutationOptions< | ... | @@ -52,58 +52,99 @@ export interface MutationOptions< |
| 52 | export type OptimisticContext< | 52 | export 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 | }; |
| 64 | 64 | ||
| 65 | interface 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 | |||
| 73 | interface 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 | |||
| 65 | export class QueuedMutation< | 81 | export 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(); | ||
| 80 | 89 | ||
| 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 | } |
| 88 | 97 | ||
| 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 | } |
| 94 | 103 | ||
| 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 | } |
| 108 | 149 | ||
| 109 | describe(...args: Args): string { | 150 | describe(...args: Args): string { |
| ... | @@ -122,36 +163,156 @@ export class QueuedMutation< | ... | @@ -122,36 +163,156 @@ export class QueuedMutation< |
| 122 | 163 | ||
| 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); |
| 127 | 168 | ||
| 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 | }; | ||
| 134 | 189 | ||
| 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)); | ||
| 142 | 251 | ||
| 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 @@ |
| 1 | import { | ||
| 2 | defineMutation, | ||
| 3 | type GlobalMutationContext, | ||
| 4 | Mutation, | ||
| 5 | type OptimisticContext, | ||
| 6 | } from "./types.ts"; | ||
| 7 | import { | 1 | import { |
| 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"; |
| 10 | import type { Mutation } from "./types.ts"; | ||
| 17 | 11 | ||
| 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 | */ | ||
| 18 | export function useMutation< | 16 | export 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; | 32 | export type UseMutationResult<Args extends unknown[], Result> = |
| 33 | & UseMutationResultBase<Args> | ||
| 34 | & ( | ||
| 35 | | UseMutationSuccess<Result> | ||
| 36 | | UseMutationError | ||
| 37 | | UseMutationIdle | ||
| 38 | ); | ||
| 39 | |||
| 40 | export interface UseMutationResultBase<Args extends unknown[]> { | ||
| 41 | run: (...args: Args) => void; | ||
| 42 | clear: () => void; | ||
| 43 | } | ||
| 44 | |||
| 45 | export 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 | } | ||
| 60 | export 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 | } | ||
| 75 | export 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 | } |
| 38 | 90 | ||
| 39 | class Observer<A extends unknown[], R> { | 91 | type 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 | }; | ||
| 42 | 103 | ||
| 43 | constructor(setRerender: (n: number) => void) { | 104 | function 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 | |||
| 117 | class 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 | } |
| 46 | 126 | ||
| 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() { |
| 70 | 243 | self.watched.add("isOptimisticData"); | |
| 71 | close() {} | 244 | return self.state.isOptimisticData; |
| 245 | }, | ||
| 246 | } as UseMutationResult<Args, Result>))(this); | ||
| 72 | } | 247 | } |
| 73 | 248 | ||
| 74 | interface BaseButtonProps { | 249 | interface BaseButtonProps { |
| ... | @@ -86,7 +261,9 @@ interface MutationButtonComponent<Props> { | ... | @@ -86,7 +261,9 @@ interface MutationButtonComponent<Props> { |
| 86 | } | 261 | } |
| 87 | 262 | ||
| 88 | export interface MutationButtonProps<Args extends unknown[], Result> { | 263 | export 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 wrapped | 274 | * Wraps a custom button component with logic to execute a mutation. The wrapped |
| 98 | * component must accept `onClick` and an `isPending` property. When the inner | 275 | * 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 | */ |
| 101 | export function createMutationButton<Props>( | 280 | export 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 work | 286 | // 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>( |
| 119 | 299 | ||
| 120 | type Identity<T> = T; | 300 | type Identity<T> = T; |
| 121 | type Flatten<T> = Identity<{ [K in keyof T]: T[K] }>; | 301 | type Flatten<T> = Identity<{ [K in keyof T]: T[K] }>; |
| 302 | type ResolveMutationButtonFc<Props, Args extends unknown[], Result> = FC< | ||
| 303 | & Omit<Props, keyof MutationButtonProps<Args, Result>> | ||
| 304 | & BaseButtonProps | ||
| 305 | >; | ||
| 122 | 306 | ||
| 123 | function GenericMutationButton< | 307 | function 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>>; |
| 133 | 317 | ||
| 134 | const state = useMutation(mutation); | 318 | const localHook = useMutation("subscribe" in mutation ? mutation : null); |
| 319 | const state = "subscribe" in mutation ? localHook : mutation; | ||
| 135 | 320 | ||
| 136 | return ( | 321 | return ( |
| 137 | <Component | 322 | <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 | |||
| 150 | function 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 | } |
| 156 | const DefaultMutationButton = createMutationButton(BaseButton); | ||
| 157 | |||
| 158 | const 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 | |||
| 169 | const y = ( | ||
| 170 | <DefaultMutationButton | ||
| 171 | mutation={def} | ||
| 172 | args={["meow"]} | ||
| 173 | a={false} | ||
| 174 | /> | ||
| 175 | ); |
src/register.ts deleted-9| ... | @@ -1,9 +0,0 @@ | ||
| 1 | export interface MutationClientConfig { | ||
| 2 | context: {}; | ||
| 3 | optimisticHelpers: {}; | ||
| 4 | } | ||
| 5 | |||
| 6 | export interface GlobalMutationConfig { | ||
| 7 | context: {}; | ||
| 8 | optimisticHelpers: {}; | ||
| 9 | } | ||
src/tanstack-query.ts created+403| ... | @@ -0,0 +1,403 @@ | ||
| 1 | import { | ||
| 2 | QueryClient, | ||
| 3 | QueryFunction, | ||
| 4 | QueryFunctionContext, | ||
| 5 | QueryKey, | ||
| 6 | QueryOptions, | ||
| 7 | SkipToken, | ||
| 8 | Updater, | ||
| 9 | UseQueryOptions, | ||
| 10 | } from "@tanstack/react-query"; | ||
| 11 | import { | ||
| 12 | type AllObjectPaths, | ||
| 13 | type GetObjectPath, | ||
| 14 | getPath as getPath, | ||
| 15 | setPath as setPath, | ||
| 16 | } from "./object-path.ts"; | ||
| 17 | import { OptimisticEvents } from "./client.ts"; | ||
| 18 | |||
| 19 | export type QueryKeyAndFn<T = unknown> = { | ||
| 20 | queryKey: QueryKey; | ||
| 21 | queryFn?: QueryFunction<T, any, never> | undefined; | ||
| 22 | }; | ||
| 23 | |||
| 24 | export 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 @@ |
| 1 | export interface Mutation<Args extends unknown[], Result> { | 1 | export 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>; |
| 7 | 6 | ||
| 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 | } |
| 15 | 16 | ||
| 16 | export interface MutationEvent<Result> { | 17 | export 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 @@ | ||
| 1 | import { assertEquals, assertRejects } from "@std/assert"; | ||
| 2 | import { MutationClient } from "../src/client.ts"; | ||
| 3 | import type { MutationEvent } from "../src/types.ts"; | ||
| 4 | import { test } from "vitest"; | ||
| 5 | import { UIEventHandler } from "react"; | ||
| 6 | |||
| 7 | // Shared test store for optimistic updates | ||
| 8 | const testStore = new Map<string, number>(); | ||
| 9 | |||
| 10 | // Helper to create a test mutation client | ||
| 11 | function 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 | ||
| 44 | function 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 | ||
| 53 | function delay(ms: number) { | ||
| 54 | return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| 55 | } | ||
| 56 | |||
| 57 | // ============================================================================ | ||
| 58 | // Basic functionality tests | ||
| 59 | // ============================================================================ | ||
| 60 | |||
| 61 | test("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 | |||
| 97 | test("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 | |||
| 124 | test("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 | |||
| 155 | test("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 | |||
| 198 | test("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 | |||
| 246 | test("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 | |||
| 291 | test("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 | |||
| 321 | test("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 | |||
| 374 | test("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 | |||
| 417 | test("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 | |||
| 457 | test("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 | |||
| 507 | test("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 | |||
| 561 | test("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 | |||
| 591 | test("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 | |||
| 629 | test("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 | |||
| 649 | test("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 | |||
| 672 | test("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 | |||
| 713 | test("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 | |||
| 733 | test("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 | |||
| 757 | test("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 | |||
| 793 | test("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 | |||
| 835 | test("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 | |||
| 864 | test("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 | |||
| 896 | test("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 | |||
| 928 | test("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 | |||
| 959 | test("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 | |||
| 988 | test("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 @@ | ||
| 1 | import { QueryClient, type QueryOptions } from "@tanstack/react-query"; | ||
| 2 | import { MutationClient } from "../src/client.ts"; | ||
| 3 | import { | ||
| 4 | type ButtonHTMLAttributes, | ||
| 5 | type DetailedHTMLProps, | ||
| 6 | useState, | ||
| 7 | } from "react"; | ||
| 8 | import { createMutationButton, useMutation } from "../src/react.tsx"; | ||
| 9 | |||
| 10 | const client = new QueryClient(); | ||
| 11 | const 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 | |||
| 39 | function queryChat(id: string): QueryOptions<{ title: string }> { | ||
| 40 | throw new Error(); | ||
| 41 | } | ||
| 42 | |||
| 43 | const MutationButton = createMutationButton(( | ||
| 44 | props: | ||
| 45 | & DetailedHTMLProps< | ||
| 46 | ButtonHTMLAttributes<HTMLButtonElement>, | ||
| 47 | HTMLButtonElement | ||
| 48 | > | ||
| 49 | & { isPending: true }, | ||
| 50 | ) => <button {...props} disabled={props.disabled || props.isPending} />); | ||
| 51 | |||
| 52 | const 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 | |||
| 72 | function 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 | |||
| 6 | import type { | ||
| 7 | AllObjectPaths, | ||
| 8 | GetObjectPath, | ||
| 9 | } from "../src/object-path.ts"; | ||
| 10 | |||
| 11 | // Type testing utilities | ||
| 12 | type Expect<T extends true> = T; | ||
| 13 | type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y | ||
| 14 | ? 1 | ||
| 15 | : 2 ? true | ||
| 16 | : false; | ||
| 17 | type NotEqual<X, Y> = Equal<X, Y> extends true ? false : true; | ||
| 18 | type IsAny<T> = 0 extends 1 & T ? true : false; | ||
| 19 | type NotAny<T> = IsAny<T> extends true ? false : true; | ||
| 20 | |||
| 21 | // Test interface | ||
| 22 | interface 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 | ||
| 47 | type TestPath1 = Expect< | ||
| 48 | Equal<["name"], Extract<AllObjectPaths<TestData>, ["name"]>> | ||
| 49 | >; | ||
| 50 | type TestPath2 = Expect< | ||
| 51 | Equal<["count"], Extract<AllObjectPaths<TestData>, ["count"]>> | ||
| 52 | >; | ||
| 53 | |||
| 54 | // Should allow nested paths | ||
| 55 | type TestPath3 = Expect< | ||
| 56 | Equal< | ||
| 57 | ["settings", "theme"], | ||
| 58 | Extract<AllObjectPaths<TestData>, ["settings", "theme"]> | ||
| 59 | > | ||
| 60 | >; | ||
| 61 | |||
| 62 | // Should allow deep nested paths | ||
| 63 | type TestPath4 = Expect< | ||
| 64 | Equal< | ||
| 65 | ["nested", "deep", "value"], | ||
| 66 | Extract<AllObjectPaths<TestData>, ["nested", "deep", "value"]> | ||
| 67 | > | ||
| 68 | >; | ||
| 69 | |||
| 70 | // Should allow array index access | ||
| 71 | type TestPath5 = Expect< | ||
| 72 | Equal<[number], Extract<AllObjectPaths<string[]>, [number]>> | ||
| 73 | >; | ||
| 74 | |||
| 75 | // Should allow array element property access | ||
| 76 | type 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 | ||
| 84 | type TestPath7 = Expect< | ||
| 85 | Equal<[], Extract<AllObjectPaths<TestData>, []>> | ||
| 86 | >; | ||
| 87 | |||
| 88 | // ============================================================================ | ||
| 89 | // GetObjectPath tests | ||
| 90 | // ============================================================================ | ||
| 91 | |||
| 92 | // Top-level property access | ||
| 93 | type GetTest1 = Expect<Equal<GetObjectPath<TestData, ["name"]>, string>>; | ||
| 94 | type GetTest2 = Expect<Equal<GetObjectPath<TestData, ["count"]>, number>>; | ||
| 95 | type GetTest3 = Expect<Equal<GetObjectPath<TestData, ["active"]>, boolean>>; | ||
| 96 | |||
| 97 | // Nested property access | ||
| 98 | type GetTest4 = Expect< | ||
| 99 | Equal< | ||
| 100 | GetObjectPath<TestData, ["settings", "theme"]>, | ||
| 101 | string | ||
| 102 | > | ||
| 103 | >; | ||
| 104 | type GetTest5 = Expect< | ||
| 105 | Equal< | ||
| 106 | GetObjectPath<TestData, ["settings", "notifications"]>, | ||
| 107 | boolean | ||
| 108 | > | ||
| 109 | >; | ||
| 110 | |||
| 111 | // Deep nested access | ||
| 112 | type GetTest6 = Expect< | ||
| 113 | Equal< | ||
| 114 | GetObjectPath<TestData, ["nested", "deep", "value"]>, | ||
| 115 | boolean | ||
| 116 | > | ||
| 117 | >; | ||
| 118 | type GetTest7 = Expect< | ||
| 119 | Equal< | ||
| 120 | GetObjectPath<TestData, ["nested", "deep", "config", "enabled"]>, | ||
| 121 | true | ||
| 122 | > | ||
| 123 | >; | ||
| 124 | |||
| 125 | // Array access | ||
| 126 | type GetTest8 = Expect< | ||
| 127 | Equal< | ||
| 128 | GetObjectPath<TestData, ["tags"]>, | ||
| 129 | string[] | ||
| 130 | > | ||
| 131 | >; | ||
| 132 | type GetTest9 = Expect< | ||
| 133 | Equal< | ||
| 134 | GetObjectPath<TestData, ["tags", number]>, | ||
| 135 | string | ||
| 136 | > | ||
| 137 | >; | ||
| 138 | type GetTest10 = Expect< | ||
| 139 | Equal< | ||
| 140 | GetObjectPath<TestData, ["items"]>, | ||
| 141 | Array<{ id: number; label: string }> | ||
| 142 | > | ||
| 143 | >; | ||
| 144 | type GetTest11 = Expect< | ||
| 145 | Equal< | ||
| 146 | GetObjectPath<TestData, ["items", number]>, | ||
| 147 | { id: number; label: string } | ||
| 148 | > | ||
| 149 | >; | ||
| 150 | type GetTest12 = Expect< | ||
| 151 | Equal< | ||
| 152 | GetObjectPath<TestData, ["items", number, "id"]>, | ||
| 153 | number | ||
| 154 | > | ||
| 155 | >; | ||
| 156 | type GetTest13 = Expect< | ||
| 157 | Equal< | ||
| 158 | GetObjectPath<TestData, ["items", number, "label"]>, | ||
| 159 | string | ||
| 160 | > | ||
| 161 | >; | ||
| 162 | |||
| 163 | // Object access | ||
| 164 | type GetTest14 = Expect< | ||
| 165 | Equal< | ||
| 166 | GetObjectPath<TestData, ["settings"]>, | ||
| 167 | { theme: string; notifications: boolean } | ||
| 168 | > | ||
| 169 | >; | ||
| 170 | |||
| 171 | // Empty path returns the whole object | ||
| 172 | type GetTest15 = Expect<Equal<GetObjectPath<TestData, []>, TestData>>; | ||
| 173 | |||
| 174 | // ============================================================================ | ||
| 175 | // Array element type extraction tests | ||
| 176 | // ============================================================================ | ||
| 177 | |||
| 178 | type ArrayElement<T> = T extends readonly (infer U)[] ? U : never; | ||
| 179 | |||
| 180 | type ArrayTest1 = Expect<Equal<ArrayElement<string[]>, string>>; | ||
| 181 | type ArrayTest2 = Expect<Equal<ArrayElement<number[]>, number>>; | ||
| 182 | type ArrayTest3 = Expect< | ||
| 183 | Equal<ArrayElement<Array<{ id: number }>>, { id: number }> | ||
| 184 | >; | ||
| 185 | type ArrayTest4 = Expect< | ||
| 186 | Equal< | ||
| 187 | ArrayElement<GetObjectPath<TestData, ["items"]>>, | ||
| 188 | { id: number; label: string } | ||
| 189 | > | ||
| 190 | >; | ||
| 191 | type 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 | ||
| 200 | type ExtractArrayElement< | ||
| 201 | Data extends object, | ||
| 202 | Path extends AllObjectPaths<Data>, | ||
| 203 | > = GetObjectPath<Data, Path> extends readonly (infer T)[] ? T : never; | ||
| 204 | |||
| 205 | type ElementTest1 = Expect< | ||
| 206 | Equal< | ||
| 207 | ExtractArrayElement<TestData, ["items"]>, | ||
| 208 | { id: number; label: string } | ||
| 209 | > | ||
| 210 | >; | ||
| 211 | type ElementTest2 = Expect< | ||
| 212 | Equal<ExtractArrayElement<TestData, ["tags"]>, string> | ||
| 213 | >; | ||
| 214 | |||
| 215 | // Test that non-array paths return never | ||
| 216 | type ElementTest3 = Expect< | ||
| 217 | Equal<ExtractArrayElement<TestData, ["name"]>, never> | ||
| 218 | >; | ||
| 219 | |||
| 220 | // Test number extraction | ||
| 221 | type IsNumber< | ||
| 222 | Data extends object, | ||
| 223 | Path extends AllObjectPaths<Data>, | ||
| 224 | > = GetObjectPath<Data, Path> extends number ? true : false; | ||
| 225 | |||
| 226 | type NumberTest1 = Expect<Equal<IsNumber<TestData, ["count"]>, true>>; | ||
| 227 | type NumberTest2 = Expect<Equal<IsNumber<TestData, ["name"]>, false>>; | ||
| 228 | |||
| 229 | // Test boolean extraction | ||
| 230 | type IsBoolean< | ||
| 231 | Data extends object, | ||
| 232 | Path extends AllObjectPaths<Data>, | ||
| 233 | > = GetObjectPath<Data, Path> extends boolean ? true : false; | ||
| 234 | |||
| 235 | type BooleanTest1 = Expect<Equal<IsBoolean<TestData, ["active"]>, true>>; | ||
| 236 | type BooleanTest2 = Expect<Equal<IsBoolean<TestData, ["count"]>, false>>; | ||
| 237 | |||
| 238 | // Test object extraction | ||
| 239 | type IsObject< | ||
| 240 | Data extends object, | ||
| 241 | Path extends AllObjectPaths<Data>, | ||
| 242 | > = GetObjectPath<Data, Path> extends object ? true : false; | ||
| 243 | |||
| 244 | type ObjectTest1 = Expect<Equal<IsObject<TestData, ["settings"]>, true>>; | ||
| 245 | type ObjectTest2 = Expect<Equal<IsObject<TestData, ["items"]>, true>>; | ||
| 246 | type ObjectTest3 = Expect<Equal<IsObject<TestData, ["name"]>, false>>; | ||
| 247 | |||
| 248 | // ============================================================================ | ||
| 249 | // Edge cases | ||
| 250 | // ============================================================================ | ||
| 251 | |||
| 252 | // Readonly arrays should work | ||
| 253 | interface ReadonlyData { | ||
| 254 | readonly items: readonly { id: number }[]; | ||
| 255 | } | ||
| 256 | |||
| 257 | type ReadonlyTest1 = Expect< | ||
| 258 | Equal< | ||
| 259 | GetObjectPath<ReadonlyData, ["items"]>, | ||
| 260 | readonly { id: number }[] | ||
| 261 | > | ||
| 262 | >; | ||
| 263 | type ReadonlyTest2 = Expect< | ||
| 264 | Equal< | ||
| 265 | GetObjectPath<ReadonlyData, ["items", number]>, | ||
| 266 | { id: number } | ||
| 267 | > | ||
| 268 | >; | ||
| 269 | type ReadonlyTest3 = Expect< | ||
| 270 | Equal< | ||
| 271 | GetObjectPath<ReadonlyData, ["items", number, "id"]>, | ||
| 272 | number | ||
| 273 | > | ||
| 274 | >; | ||
| 275 | |||
| 276 | // Optional properties | ||
| 277 | interface OptionalData { | ||
| 278 | required: string; | ||
| 279 | optional?: number; | ||
| 280 | nested?: { | ||
| 281 | value: boolean; | ||
| 282 | }; | ||
| 283 | } | ||
| 284 | |||
| 285 | type OptionalTest1 = Expect< | ||
| 286 | Equal<GetObjectPath<OptionalData, ["required"]>, string> | ||
| 287 | >; | ||
| 288 | type OptionalTest2 = Expect< | ||
| 289 | Equal<GetObjectPath<OptionalData, ["optional"]>, number | undefined> | ||
| 290 | >; | ||
| 291 | |||
| 292 | // Union types | ||
| 293 | interface UnionData { | ||
| 294 | value: string | number; | ||
| 295 | items: Array<{ type: "a"; a: string } | { type: "b"; b: number }>; | ||
| 296 | } | ||
| 297 | |||
| 298 | type 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 | ||
| 307 | type 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 | ||
| 318 | declare const objSetName: ObjSetSignature<TestData, ["name"]>; | ||
| 319 | objSetName(["name"], "test"); | ||
| 320 | objSetName(["name"], (prev) => prev.toUpperCase()); | ||
| 321 | |||
| 322 | // This should accept number or function | ||
| 323 | declare const objSetCount: ObjSetSignature<TestData, ["count"]>; | ||
| 324 | objSetCount(["count"], 42); | ||
| 325 | objSetCount(["count"], (n) => n + 1); | ||
| 326 | |||
| 327 | // Array push signature | ||
| 328 | type 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 | ||
| 336 | declare const arrayPushItems: ArrayPushSignature<TestData, ["items"]>; | ||
| 337 | arrayPushItems( | ||
| 338 | ["items"], | ||
| 339 | { id: 1, label: "first" }, | ||
| 340 | { id: 2, label: "second" }, | ||
| 341 | ); | ||
| 342 | |||
| 343 | declare const arrayPushTags: ArrayPushSignature<TestData, ["tags"]>; | ||
| 344 | arrayPushTags(["tags"], "alpha", "beta", "gamma"); | ||
| 345 | |||
| 346 | // Array remove signature | ||
| 347 | type 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 | |||
| 354 | declare const arrayRemoveItems: ArrayRemoveSignature<TestData, ["items"]>; | ||
| 355 | arrayRemoveItems(["items"], (item) => item.id === 1); | ||
| 356 | arrayRemoveItems(["items"], (item, index) => index === 0); | ||
| 357 | |||
| 358 | declare const arrayRemoveTags: ArrayRemoveSignature<TestData, ["tags"]>; | ||
| 359 | arrayRemoveTags(["tags"], (tag) => tag === "alpha"); | ||
| 360 | |||
| 361 | // Increment signature | ||
| 362 | type 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 | |||
| 369 | declare const increment: IncrementSignature<TestData, ["count"]>; | ||
| 370 | increment(["count"]); | ||
| 371 | increment(["count"], 5); | ||
| 372 | |||
| 373 | // Should not work on non-numbers (type should be never) | ||
| 374 | type IncrementNameTest = Expect< | ||
| 375 | Equal<IncrementSignature<TestData, ["name"]>, never> | ||
| 376 | >; | ||
| 377 | |||
| 378 | // Toggle signature | ||
| 379 | type ToggleSignature< | ||
| 380 | Data extends object, | ||
| 381 | Path extends AllObjectPaths<Data>, | ||
| 382 | > = GetObjectPath<Data, Path> extends boolean | ||
| 383 | ? (path: Path) => void | ||
| 384 | : never; | ||
| 385 | |||
| 386 | declare const toggle: ToggleSignature<TestData, ["active"]>; | ||
| 387 | toggle(["active"]); | ||
| 388 | |||
| 389 | // Should not work on non-booleans (type should be never) | ||
| 390 | type ToggleCountTest = Expect< | ||
| 391 | Equal<ToggleSignature<TestData, ["count"]>, never> | ||
| 392 | >; | ||
| 393 | |||
| 394 | // ============================================================================ | ||
| 395 | // Verify no `any` types leaked through | ||
| 396 | // ============================================================================ | ||
| 397 | |||
| 398 | type NoAnyTest1 = Expect<NotAny<GetObjectPath<TestData, ["name"]>>>; | ||
| 399 | type NoAnyTest2 = Expect<NotAny<GetObjectPath<TestData, ["items"]>>>; | ||
| 400 | type NoAnyTest3 = Expect<NotAny<GetObjectPath<TestData, ["items", number]>>>; | ||
| 401 | type NoAnyTest4 = Expect< | ||
| 402 | NotAny<GetObjectPath<TestData, ["settings", "theme"]>> | ||
| 403 | >; | ||
| 404 | |||
| 405 | export type { | ||
| 406 | ArrayPushSignature, | ||
| 407 | ArrayRemoveSignature, | ||
| 408 | IncrementSignature, | ||
| 409 | ObjSetSignature, | ||
| 410 | ToggleSignature, | ||
| 411 | }; | ||
test/object-path.test.ts created+339| ... | @@ -0,0 +1,339 @@ | ||
| 1 | import { assertEquals } from "@std/assert"; | ||
| 2 | import { test } from "vitest"; | ||
| 3 | import { getPath, setPath } from "../src/object-path.ts"; | ||
| 4 | |||
| 5 | // Test interfaces | ||
| 6 | interface 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 | |||
| 26 | test("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 | |||
| 43 | test("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 | |||
| 60 | test("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 | |||
| 77 | test("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 | |||
| 94 | test("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 | |||
| 111 | test("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 | |||
| 126 | test("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 | |||
| 141 | test("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 | |||
| 152 | test("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 | |||
| 163 | test("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 | |||
| 178 | test("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 | |||
| 194 | test("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 | |||
| 209 | test("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 | |||
| 225 | test("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 | |||
| 241 | test("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 | |||
| 256 | test("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 | |||
| 274 | test("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 | |||
| 296 | test("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 | |||
| 310 | test("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 @@ | ||
| 1 | import { assertEquals, assertRejects } from "@std/assert"; | ||
| 2 | import { MutationClient } from "../src/client.ts"; | ||
| 3 | import type { MutationEvent } from "../src/types.ts"; | ||
| 4 | import { test } from "vitest"; | ||
| 5 | |||
| 6 | // Helper to create a test mutation client | ||
| 7 | function createTestClient() { | ||
| 8 | const errors: unknown[] = []; | ||
| 9 | const client = new MutationClient({ | ||
| 10 | context: { userId: "test-user" }, | ||
| 11 | getOptimisticHelpers({ onRestore }) { | ||
| 12 | return { | ||
| 13 | setValue(key: string, value: string) { | ||
| 14 | testStore.set(key, value); | ||
| 15 | onRestore(() => testStore.delete(key)); | ||
| 16 | }, | ||
| 17 | }; | ||
| 18 | }, | ||
| 19 | reportError(error) { | ||
| 20 | errors.push(error); | ||
| 21 | }, | ||
| 22 | }); | ||
| 23 | |||
| 24 | return { client, errors }; | ||
| 25 | } | ||
| 26 | |||
| 27 | const testStore = new Map<string, string>(); | ||
| 28 | |||
| 29 | // Helper to track mutation events | ||
| 30 | function createEventTracker<Result>() { | ||
| 31 | const events: Array<MutationEvent<Result>> = []; | ||
| 32 | const callback = (event: MutationEvent<Result>) => { | ||
| 33 | events.push(event); | ||
| 34 | }; | ||
| 35 | return { events, callback }; | ||
| 36 | } | ||
| 37 | |||
| 38 | // Helper to wait for async operations | ||
| 39 | function delay(ms: number) { | ||
| 40 | return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| 41 | } | ||
| 42 | |||
| 43 | test("QueuedMutation - basic mutation success", async () => { | ||
| 44 | const { client } = createTestClient(); | ||
| 45 | let mutateCallCount = 0; | ||
| 46 | let refetchCallCount = 0; | ||
| 47 | |||
| 48 | const mutation = client.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 | |||
| 73 | test("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 | |||
| 92 | test("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 | |||
| 111 | test("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 | |||
| 138 | test("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 | |||
| 161 | test("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 | |||
| 183 | test("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 | |||
| 214 | test("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 | |||
| 244 | test("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 | |||
| 263 | test("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 | |||
| 278 | test("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 | |||
| 300 | test("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 | |||
| 315 | test("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 | |||
| 333 | test("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 | |||
| 354 | test("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 | |||
| 383 | test("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 | |||
| 410 | test("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 | |||
| 431 | test("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 | |||
| 451 | test("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 | |||
| 485 | test("QueuedMutation - rollbacks are called in reverse order on error", async () => { | ||
| 486 | const { client } = createTestClient(); | ||
| 487 | const rollbackOrder: number[] = []; | ||
| 488 | |||
| 489 | const mutation = client.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 | |||
| 508 | test("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 | |||
| 541 | test("QueuedMutation - onRestore throws error if called after optimistic phase", async () => { | ||
| 542 | const { client } = createTestClient(); | ||
| 543 | let capturedOnRestore: ((cb: () => void) => void) | null = null; | ||
| 544 | |||
| 545 | const mutation = client.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 | |||
| 572 | test("QueuedMutation - onSuccess throws error if called after optimistic phase", async () => { | ||
| 573 | const { client } = createTestClient(); | ||
| 574 | let capturedOnSuccess: ((cb: (result: string) => void) => void) | null = null; | ||
| 575 | |||
| 576 | const mutation = client.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 | |||
| 603 | test("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 | |||
| 624 | test("QueuedMutation - error during optimistic update rolls back registered callbacks", async () => { | ||
| 625 | const { client } = createTestClient(); | ||
| 626 | const rollbackOrder: number[] = []; | ||
| 627 | |||
| 628 | const mutation = client.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 | |||
| 648 | test("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 | |||
| 672 | test("QueuedMutation - optimistic function receives args and helpers", async () => { | ||
| 673 | const { client } = createTestClient(); | ||
| 674 | let receivedArgs: unknown[] | undefined; | ||
| 675 | let receivedHelpers: unknown | undefined; | ||
| 676 | |||
| 677 | const mutation = client.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 | |||
| 695 | test("QueuedMutation - refetch receives context and args", async () => { | ||
| 696 | const { client } = createTestClient(); | ||
| 697 | let receivedUserId: string | undefined; | ||
| 698 | let receivedArgs: unknown[] | undefined; | ||
| 699 | |||
| 700 | const mutation = client.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 | |||
| 718 | test("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 | |||
| 745 | test("QueuedMutation - multiple subscribers receive events", async () => { | ||
| 746 | const { client } = createTestClient(); | ||
| 747 | const tracker1 = createEventTracker<string>(); | ||
| 748 | const tracker2 = createEventTracker<string>(); | ||
| 749 | |||
| 750 | const mutation = client.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 | |||
| 773 | test("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 | |||
| 803 | test("QueuedMutation - result is passed to notification on success", async () => { | ||
| 804 | const { client } = createTestClient(); | ||
| 805 | const tracker = createEventTracker<string>(); | ||
| 806 | |||
| 807 | const mutation = client.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 | |||
| 833 | test("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 | |||
| 859 | test("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 | |||
| 892 | test("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 | |||
| 915 | test("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 | |||
| 938 | test("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 @@ | ||
| 1 | import { assertEquals } from "@std/assert"; | ||
| 2 | import { test } from "vitest"; | ||
| 3 | import { QueryClient, queryOptions } from "@tanstack/react-query"; | ||
| 4 | import { queryClientOptimisticHelpers } from "../src/tanstack-query.ts"; | ||
| 5 | |||
| 6 | interface 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 | |||
| 18 | const 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 | |||
| 34 | function 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 | |||
| 53 | test("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 | |||
| 87 | test("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 | |||
| 117 | test("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 | |||
| 136 | test("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 | |||
| 165 | test("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 | |||
| 188 | test("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 | |||
| 209 | test("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 | |||
| 230 | test("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 | |||
| 248 | test("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 | |||
| 268 | test("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 | |||
| 282 | test("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 | |||
| 302 | test("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 | |||
| 316 | test("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 | |||
| 334 | test("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 | |||
| 354 | test("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 | |||
| 368 | test("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 | |||
| 388 | test("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 | |||
| 411 | test("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 | |||
| 434 | test("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 | |||
| 461 | test("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 | |||
| 479 | test("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 | |||
| 510 | test("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 | |||
| 536 | test("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 | |||
| 555 | test("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 | |||
| 577 | test("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 | |||
| 603 | test("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 | |||
| 624 | test("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 | |||
| 647 | test("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 | |||
| 675 | test("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 | |||
| 694 | test("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 | |||
| 713 | test("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 | |||
| 737 | test("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 | |||
| 757 | test("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 | |||
| 780 | test("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", |
| 13 | 18 | "types": ["react"], | |
| 14 | // strictness | 19 | "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"; |
| 2 | 2 | ||
| 3 | export default defineConfig({ | 3 | export 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 | }); |