├── .npmrc ├── src ├── vite-env.d.ts └── turbo-query.ts ├── .prettierrc.json ├── .gitignore ├── tests ├── tsconfig.json └── turbo-query.ts ├── vite.config.ts ├── tsconfig.json ├── README.md ├── .eslintrc.cjs ├── LICENSE └── package.json /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSameLine": true, 4 | "bracketSpacing": true, 5 | "printWidth": 100, 6 | "useTabs": false, 7 | "tabWidth": 2, 8 | "trailingComma": "es5", 9 | "singleQuote": true, 10 | "semi": false, 11 | "quoteProps": "consistent" 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | coverage 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["."], 3 | "compilerOptions": { 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "sourceMap": true, 9 | "resolveJsonModule": true, 10 | "esModuleInterop": true, 11 | "declaration": true, 12 | "emitDeclarationOnly": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true, 16 | "isolatedModules": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ['./tests/**/*.ts'], 6 | update: false, 7 | reporters: 'verbose', 8 | }, 9 | build: { 10 | sourcemap: true, 11 | lib: { 12 | entry: 'src/turbo-query.ts', 13 | name: 'TurboQuery', 14 | fileName: 'turbo-query', 15 | }, 16 | rollupOptions: { 17 | external: [], 18 | output: { 19 | globals: {}, 20 | }, 21 | }, 22 | }, 23 | }) 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "exclude": ["dist"], 4 | "compilerOptions": { 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleResolution": "Node", 8 | "strict": true, 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "esModuleInterop": true, 12 | "declaration": true, 13 | "emitDeclarationOnly": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noImplicitReturns": true, 17 | "isolatedModules": true, 18 | "noImplicitAny": true, 19 | "outDir": "./dist", 20 | "rootDir": "./src" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Turbo Query 2 | 3 | > Lightweight asynchronous data management 4 | 5 | Turbo Query is a library created to manage asynchronous data on your code. This is generally used when you have to fetch data asynchronously for a given resource. Turbo Query allows you to manage this data by using an internal cache. It is very similar to how vercel's swr work. Turbo Query is also build to support arbitrary cache and event system, making it possible to create your own implementations. 6 | 7 | ## Documentation 8 | 9 | Find out about the project and discover the features at the [Documentation](https://erik.cat/blog/turbo-query-docs/) 10 | 11 | ## Installation 12 | 13 | ``` 14 | npm i turbo-query 15 | ``` 16 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "overrides": [ 11 | { 12 | "env": { 13 | "node": true 14 | }, 15 | "files": [ 16 | ".eslintrc.{js,cjs}" 17 | ], 18 | "parserOptions": { 19 | "sourceType": "script" 20 | } 21 | } 22 | ], 23 | "parser": "@typescript-eslint/parser", 24 | "parserOptions": { 25 | "ecmaVersion": "latest", 26 | "sourceType": "module" 27 | }, 28 | "plugins": [ 29 | "@typescript-eslint" 30 | ], 31 | "rules": { 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Erik C. Forés 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "turbo-query", 3 | "version": "2.2.0", 4 | "license": "MIT", 5 | "description": "Lightweight, isomorphic and framework agnostic asynchronous data management for modern UIs", 6 | "author": { 7 | "name": "Erik C. Forés", 8 | "email": "soc@erik.cat", 9 | "url": "https://erik.cat" 10 | }, 11 | "engines": { 12 | "node": ">=18.0.0" 13 | }, 14 | "type": "module", 15 | "types": "./dist/turbo-query.d.ts", 16 | "main": "./dist/turbo-query.umd.cjs", 17 | "module": "./dist/turbo-query.js", 18 | "exports": { 19 | "./package.json": "./package.json", 20 | ".": { 21 | "import": "./dist/turbo-query.js", 22 | "require": "./dist/turbo-query.umd.cjs" 23 | } 24 | }, 25 | "files": [ 26 | "dist", 27 | "package.json" 28 | ], 29 | "scripts": { 30 | "build": "vite build && tsc --skipLibCheck", 31 | "dev": "vitest", 32 | "test": "vitest run", 33 | "test:ui": "vitest --ui --coverage", 34 | "test:cover": "vitest run --coverage", 35 | "prepack": "npm run build" 36 | }, 37 | "devDependencies": { 38 | "@typescript-eslint/eslint-plugin": "^7.5.0", 39 | "@typescript-eslint/parser": "^7.5.0", 40 | "@vitest/coverage-v8": "^1.4.0", 41 | "@vitest/ui": "^1.4.0", 42 | "eslint": "^8.57.0", 43 | "typescript": "^5.4.4", 44 | "typescript-eslint": "^7.5.0", 45 | "vite": "^5.2.8", 46 | "vitest": "^1.4.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/turbo-query.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Determines the shape of the TurboCache instance. 3 | */ 4 | export interface TurboCache { 5 | /** 6 | * Gets an item from the cache. 7 | */ 8 | readonly get: (key: string) => T | undefined 9 | 10 | /** 11 | * Sets an item to the cache. 12 | */ 13 | readonly set: (key: string, value: T) => void 14 | 15 | /** 16 | * Removes a key-value pair from the cache. 17 | */ 18 | readonly delete: (key: string) => void 19 | 20 | /** 21 | * Returns the current cached keys. 22 | */ 23 | readonly keys: () => IterableIterator 24 | } 25 | 26 | /** 27 | * Determines how we store items in the items cache. 28 | */ 29 | interface ItemsCacheItem { 30 | /** 31 | * Stores the cache item. 32 | */ 33 | readonly item: T 34 | 35 | /** 36 | * Determines the expiration date of the item. 37 | */ 38 | readonly expiresAt: Date 39 | } 40 | 41 | /** 42 | * Determines how we store items in the resolvers cache. 43 | */ 44 | interface ResolversCacheItem { 45 | /** 46 | * The resolvable item. 47 | */ 48 | readonly item: Promise 49 | 50 | /** 51 | * The abort controller for the request. 52 | */ 53 | readonly controller: AbortController 54 | } 55 | 56 | /** 57 | * Represents the available configuration options 58 | * for a turbo query instance. 59 | */ 60 | export interface TurboQueryConfiguration extends TurboQueryOptions { 61 | /** 62 | * Determines the resolved items cache to use. 63 | */ 64 | readonly itemsCache?: TurboCache> 65 | 66 | /** 67 | * Determines the resolvers cache to use. 68 | */ 69 | readonly resolversCache?: TurboCache> 70 | 71 | /** 72 | * Stores the event system. 73 | */ 74 | readonly events?: EventTarget 75 | } 76 | 77 | export interface TurboFetcherAdditional { 78 | /** 79 | * An abort signal to cancel pending queries. 80 | */ 81 | readonly signal: AbortSignal 82 | } 83 | 84 | /** 85 | * The available options for turbo query. 86 | */ 87 | export interface TurboQueryOptions { 88 | /** 89 | * Determines the item deduplication interval. 90 | * This determines how many milliseconds an item 91 | * is considered valid. 92 | */ 93 | readonly expiration?: (item: T) => number 94 | 95 | /** 96 | * Determines the fetcher function to use. 97 | */ 98 | readonly fetcher?: (key: string, additional: TurboFetcherAdditional) => Promise 99 | 100 | /** 101 | * Determines if we can return a stale item. 102 | * If `true`, it will return the previous stale item 103 | * stored in the cache if it has expired. It will attempt 104 | * to revalidate it in the background. If `false`, the returned 105 | * promise will be the revalidation promise. 106 | */ 107 | readonly stale?: boolean 108 | 109 | /** 110 | * Removes the stored item if there is an error in the request. 111 | * By default, we don't remove the item upon failure, only the resolver 112 | * is removed from the cache. 113 | */ 114 | readonly removeOnError?: boolean 115 | 116 | /** 117 | * Determines if the result should be a fresh fetched 118 | * instance regardless of any cached value or its expiration time. 119 | */ 120 | readonly fresh?: boolean 121 | } 122 | 123 | /** 124 | * Determines the cache type. 125 | */ 126 | export type TurboCacheType = 'resolvers' | 'items' 127 | 128 | /** 129 | * The turbo mutation function type. 130 | */ 131 | export type TurboMutateFunction = (previous?: T, expiresAt?: Date) => T 132 | 133 | /** 134 | * The available mutation values. 135 | */ 136 | export type TurboMutateValue = T | TurboMutateFunction 137 | 138 | /** 139 | * The unsubscriber function. 140 | */ 141 | export type Unsubscriber = () => void 142 | 143 | /** 144 | * The caches available on the turbo query. 145 | */ 146 | export interface TurboCaches { 147 | /** 148 | * A cache that contains the resolved items alongside 149 | * their expiration time. 150 | */ 151 | readonly items: TurboCache> 152 | 153 | /** 154 | * A cache that contains the resolvers alongside 155 | * their abort controllers. 156 | */ 157 | readonly resolvers: TurboCache> 158 | } 159 | 160 | /** 161 | * Represents the methods a turbo query 162 | * should implement. 163 | */ 164 | export interface TurboQuery { 165 | /** 166 | * Configures the current instance of turbo query. 167 | */ 168 | readonly configure: (options?: Partial) => void 169 | 170 | /** 171 | * Fetches the key information using a fetcher. 172 | * The returned promise contains the result item. 173 | */ 174 | readonly query: (key: string, options?: TurboQueryOptions) => Promise 175 | 176 | /** 177 | * Subscribes to a given event on a key. The event handler 178 | * does have a payload parameter that will contain relevant 179 | * information depending on the event type. 180 | */ 181 | readonly subscribe: (key: string, event: TurboQueryEvent, listener: EventListener) => Unsubscriber 182 | 183 | /** 184 | * Mutates the key with a given optimistic value. 185 | * The mutated value is considered expired and will be 186 | * replaced immediatly if a refetch happens. 187 | */ 188 | readonly mutate: (key: string, item: TurboMutateValue) => void 189 | 190 | /** 191 | * Aborts the active resolvers on each key 192 | * by calling `.abort()` on the `AbortController`. 193 | * The fetcher is responsible for using the 194 | * `AbortSignal` to cancel the job. 195 | */ 196 | readonly abort: (key?: string | string[], reason?: unknown) => void 197 | 198 | /** 199 | * Forgets the given keys from the cache. 200 | * Removes items from both, the cache and resolvers. 201 | */ 202 | readonly forget: (keys?: string | string[]) => void 203 | 204 | /** 205 | * Hydrates the given keys on the cache 206 | * with the given value and expiration time. 207 | */ 208 | readonly hydrate: (keys: string | string[], item: T, expiresAt?: Date) => void 209 | 210 | /** 211 | * Returns the given keys for the given cache. 212 | */ 213 | readonly keys: (cache?: TurboCacheType) => string[] 214 | 215 | /** 216 | * Returns the expiration date of a given key item. 217 | * If the item is not in the cache, it will return `undefined`. 218 | */ 219 | readonly expiration: (key: string) => Date | undefined 220 | 221 | /** 222 | * Returns the current snapshot of the given key. 223 | * If the item is not in the items cache, it will return `undefined`. 224 | */ 225 | readonly snapshot: (key: string) => T | undefined 226 | 227 | /** 228 | * Returns the current cache instances. 229 | */ 230 | readonly caches: () => TurboCaches 231 | 232 | /** 233 | * Returns the event system. 234 | */ 235 | readonly events: () => EventTarget 236 | } 237 | 238 | /** 239 | * Available events on turbo query. 240 | */ 241 | export type TurboQueryEvent = 242 | | 'refetching' 243 | | 'resolved' 244 | | 'mutated' 245 | | 'aborted' 246 | | 'forgotten' 247 | | 'hydrated' 248 | | 'error' 249 | 250 | /** 251 | * Stores the default fetcher function. 252 | */ 253 | export function defaultFetcher( 254 | fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise 255 | ) { 256 | return async function (key: string, { signal }: TurboFetcherAdditional) { 257 | const response = await fetch(key, { signal }) 258 | 259 | if (!response.ok) { 260 | throw new Error('Unable to fetch the data: ' + response.statusText) 261 | } 262 | 263 | return await response.json() 264 | } 265 | } 266 | 267 | /** 268 | * Creates a new turbo query instance. 269 | */ 270 | export function createTurboQuery(instanceOptions?: TurboQueryConfiguration): TurboQuery { 271 | /** 272 | * Stores the default expiration function. 273 | */ 274 | function defaultExpiration() { 275 | return 2000 276 | } 277 | 278 | /** 279 | * Stores the items cache. 280 | */ 281 | let itemsCache = instanceOptions?.itemsCache ?? new Map>() 282 | 283 | /** 284 | * Stores the resolvers cache. 285 | */ 286 | let resolversCache = 287 | instanceOptions?.resolversCache ?? new Map>() 288 | 289 | /** 290 | * Event manager. 291 | */ 292 | let events = instanceOptions?.events ?? new EventTarget() 293 | 294 | /** 295 | * Stores the expiration time of an item. 296 | */ 297 | let instanceExpiration = instanceOptions?.expiration ?? defaultExpiration 298 | 299 | /** 300 | * Determines the fetcher function to use. 301 | */ 302 | let instanceFetcher = instanceOptions?.fetcher ?? defaultFetcher(fetch) 303 | 304 | /** 305 | * Determines if we can return a stale item. 306 | * If `true`, it will return the previous stale item 307 | * stored in the cache if it has expired. It will attempt 308 | * to revalidate it in the background. If `false`, the returned 309 | * promise will be the revalidation promise. 310 | */ 311 | let instanceStale = instanceOptions?.stale ?? true 312 | 313 | /** 314 | * Removes the stored item if there is an error in the request. 315 | * By default, we don't remove the item upon failure, only the resolver 316 | * is removed from the cache. 317 | */ 318 | let instanceRemoveOnError = instanceOptions?.removeOnError ?? false 319 | 320 | /** 321 | * Determines if the result should be a fresh fetched 322 | * instance regardless of any cached value or its expiration time. 323 | */ 324 | let instanceFresh = instanceOptions?.fresh ?? false 325 | 326 | /** 327 | * Configures the current instance of turbo query. 328 | */ 329 | function configure(options?: TurboQueryConfiguration): void { 330 | itemsCache = options?.itemsCache ?? itemsCache 331 | resolversCache = options?.resolversCache ?? resolversCache 332 | events = options?.events ?? events 333 | instanceExpiration = options?.expiration ?? instanceExpiration 334 | instanceFetcher = options?.fetcher ?? instanceFetcher 335 | instanceStale = options?.stale ?? instanceStale 336 | instanceRemoveOnError = options?.removeOnError ?? instanceRemoveOnError 337 | instanceFresh = options?.fresh ?? instanceFresh 338 | } 339 | 340 | /** 341 | * Subscribes to a given event on a key. The event handler 342 | * does have a payload parameter that will contain relevant 343 | * information depending on the event type. 344 | * If there's a pending resolver for that key, the `refetching` 345 | * event is fired immediatly. 346 | */ 347 | function subscribe( 348 | key: string, 349 | event: TurboQueryEvent, 350 | listener: EventListener 351 | ): () => void { 352 | events.addEventListener(`${event}:${key}`, listener) 353 | const value = resolversCache.get(key) 354 | 355 | // For the refetching event, we want to immediatly return if there's 356 | // a pending resolver. 357 | if (event === 'refetching' && value !== undefined) { 358 | listener(new CustomEvent(`${event}:${key}`, { detail: value.item as Promise })) 359 | } 360 | 361 | return function () { 362 | events.removeEventListener(`${event}:${key}`, listener) 363 | } 364 | } 365 | 366 | /** 367 | * Mutates the key with a given optimistic value. 368 | * The mutated value is considered expired and will be 369 | * replaced immediatly if a refetch happens when no expiresAt 370 | * is given. Otherwise the expiration time is used. 371 | */ 372 | function mutate(key: string, item: TurboMutateValue, expiresAt?: Date): void { 373 | if (typeof item === 'function') { 374 | const fn = item as TurboMutateFunction 375 | const value = itemsCache.get(key) 376 | 377 | item = fn(value?.item as T, value?.expiresAt) 378 | } 379 | 380 | itemsCache.set(key, { item, expiresAt: expiresAt ?? new Date() }) 381 | events.dispatchEvent(new CustomEvent(`mutated:${key}`, { detail: item })) 382 | } 383 | 384 | /** 385 | * Returns the current snapshot of the given key. 386 | * If the item is not in the items cache, it will return `undefined`. 387 | */ 388 | function snapshot(key: string): T | undefined { 389 | return itemsCache.get(key)?.item as T 390 | } 391 | 392 | /** 393 | * Determines if the given key is currently resolving. 394 | */ 395 | function keys(type: TurboCacheType = 'items'): string[] { 396 | return Array.from(type === 'items' ? itemsCache.keys() : resolversCache.keys()) 397 | } 398 | 399 | /** 400 | * Aborts the active resolvers on each key 401 | * by calling `.abort()` on the `AbortController`. 402 | * The fetcher is responsible for using the 403 | * `AbortSignal` to cancel the job. 404 | * If no keys are provided, all resolvers are aborted. 405 | */ 406 | function abort(cacheKeys?: string | string[], reason?: unknown): void { 407 | const resolverKeys = 408 | typeof cacheKeys === 'string' ? [cacheKeys] : cacheKeys ?? keys('resolvers') 409 | for (const key of resolverKeys) { 410 | const resolver = resolversCache.get(key) 411 | 412 | if (resolver !== undefined) { 413 | resolver.controller.abort(reason) 414 | resolversCache.delete(key) 415 | 416 | //! Should it be reason instead of resolver.item ??? 417 | events.dispatchEvent(new CustomEvent(`aborted:${key}`, { detail: resolver.item })) 418 | } 419 | } 420 | } 421 | 422 | /** 423 | * Forgets the given keys from the items cache. 424 | * Does not remove any resolvers. 425 | * If no keys are provided the items cache is cleared. 426 | */ 427 | function forget(cacheKeys?: string | string[]): void { 428 | const itemKeys = typeof cacheKeys === 'string' ? [cacheKeys] : cacheKeys ?? keys('items') 429 | for (const key of itemKeys) { 430 | const item = itemsCache.get(key) 431 | 432 | if (item !== undefined) { 433 | itemsCache.delete(key) 434 | events.dispatchEvent(new CustomEvent(`forgotten:${key}`, { detail: item.item })) 435 | } 436 | } 437 | } 438 | 439 | /** 440 | * Hydrates the given keys on the cache 441 | * with the given value. Hydrate should only 442 | * be used when you want to populate the cache. 443 | * Please use mutate() in most cases unless you 444 | * know what you are doing. 445 | */ 446 | function hydrate(keys: string | string[], item: T, expiresAt?: Date): void { 447 | for (const key of typeof keys === 'string' ? [keys] : keys) { 448 | itemsCache.set(key, { item, expiresAt: expiresAt ?? new Date() }) 449 | events.dispatchEvent(new CustomEvent(`hydrated:${key}`, { detail: item })) 450 | } 451 | } 452 | 453 | /** 454 | * Returns the expiration date of a given key item. 455 | * If the item is not in the cache, it will return `undefined`. 456 | */ 457 | function expiration(key: string): Date | undefined { 458 | return itemsCache.get(key)?.expiresAt 459 | } 460 | 461 | /** 462 | * Fetches the key information using a fetcher. 463 | * The returned promise contains the result item. 464 | */ 465 | async function query(key: string, options?: TurboQueryOptions): Promise { 466 | /** 467 | * Stores the expiration time of an item. 468 | */ 469 | const expiration = options?.expiration ?? instanceExpiration 470 | 471 | /** 472 | * Determines the fetcher function to use. 473 | */ 474 | const fetcher = options?.fetcher ?? instanceFetcher 475 | 476 | /** 477 | * Determines if we can return a sale item 478 | * If true, it will return the previous stale item 479 | * stored in the cache if it has expired. It will attempt 480 | * to revalidate it in the background. If false, the returned 481 | * promise will be the revalidation promise. 482 | */ 483 | const stale = options?.stale ?? instanceStale 484 | 485 | /** 486 | * Removes the stored item if there is an error in the request. 487 | * By default, we don't remove the item upon failure, only the resolver 488 | * is removed from the cache. 489 | */ 490 | const removeOnError = options?.removeOnError ?? instanceRemoveOnError 491 | 492 | /** 493 | * Determines if the result should be a fresh fetched 494 | * instance regardless of any cached value or its expiration time. 495 | */ 496 | const fresh = options?.fresh ?? instanceOptions?.fresh 497 | 498 | // Force fetching of the data. 499 | async function refetch(key: string): Promise { 500 | try { 501 | // Check if there's a pending resolver for that data. 502 | const pending = resolversCache.get(key) 503 | 504 | if (pending !== undefined) { 505 | return await (pending.item as Promise) 506 | } 507 | 508 | // Create the abort controller that will be 509 | // called when a query is aborted. 510 | const controller = new AbortController() 511 | 512 | // Initiate the fetching request. 513 | const result: Promise = fetcher(key, { signal: controller.signal }) 514 | 515 | // Adds the resolver to the cache. 516 | resolversCache.set(key, { item: result, controller }) 517 | events.dispatchEvent(new CustomEvent(`refetching:${key}`, { detail: result })) 518 | 519 | // Awaits the fetching to get the result item. 520 | const item = await result 521 | 522 | // Removes the resolver from the cache. 523 | resolversCache.delete(key) 524 | 525 | // Create the expiration time for the item. 526 | const expiresAt = new Date() 527 | expiresAt.setMilliseconds(expiresAt.getMilliseconds() + expiration(item)) 528 | 529 | // Set the item to the cache. 530 | itemsCache.set(key, { item, expiresAt }) 531 | events.dispatchEvent(new CustomEvent(`resolved:${key}`, { detail: item })) 532 | 533 | // Return back the item. 534 | return item 535 | } catch (error) { 536 | // Remove the resolver. 537 | resolversCache.delete(key) 538 | 539 | // Check if the item should be removed as well. 540 | if (removeOnError) { 541 | itemsCache.delete(key) 542 | } 543 | 544 | // Notify of the error. 545 | events.dispatchEvent(new CustomEvent(`error:${key}`, { detail: error })) 546 | 547 | // Throw back the error. 548 | throw error 549 | } 550 | } 551 | 552 | // We want to force a fresh item ignoring any current cached 553 | // value or its expiration time. 554 | if (fresh) { 555 | return await refetch(key) 556 | } 557 | 558 | // Check if there's an item in the cache for the given key. 559 | const cached = itemsCache.get(key) 560 | 561 | if (cached !== undefined) { 562 | // We must check if that item has actually expired. 563 | // to trigger a revalidation if needed. 564 | const hasExpired = cached.expiresAt <= new Date() 565 | 566 | // The item has expired and the fetch is able 567 | // to return a stale item while revalidating 568 | // in the background. 569 | if (hasExpired && stale) { 570 | // We have to silence the error to avoid unhandled promises. 571 | // Refer to the error event if you need full controll of errors. 572 | refetch(key).catch(() => {}) 573 | 574 | return cached.item as T 575 | } 576 | 577 | // The item has expired but we dont allow stale 578 | // responses so we need to wait for the revalidation. 579 | if (hasExpired) { 580 | return await refetch(key) 581 | } 582 | 583 | // The item has not yet expired, so we can return it and 584 | // assume it's valid since it's not yet considered stale. 585 | return cached.item as T 586 | } 587 | 588 | // The item is not found in the items cache. 589 | // We need to perform a revalidation of the item. 590 | return await refetch(key) 591 | } 592 | 593 | /** 594 | * Returns the current cache instances. 595 | */ 596 | function caches(): TurboCaches { 597 | return { items: itemsCache, resolvers: resolversCache } 598 | } 599 | 600 | /** 601 | * Returns the event system. 602 | */ 603 | function localEvents() { 604 | return events 605 | } 606 | 607 | return { 608 | query, 609 | subscribe, 610 | mutate, 611 | configure, 612 | abort, 613 | forget, 614 | keys, 615 | expiration, 616 | hydrate, 617 | snapshot, 618 | caches, 619 | events: localEvents, 620 | } 621 | } 622 | -------------------------------------------------------------------------------- /tests/turbo-query.ts: -------------------------------------------------------------------------------- 1 | import { expect, it, vi } from 'vitest' 2 | import { createTurboQuery, defaultFetcher, TurboFetcherAdditional } from '../src/turbo-query' 3 | 4 | it.concurrent('can create a turbo query', async ({ expect }) => { 5 | const turboquery = createTurboQuery() 6 | 7 | expect(turboquery).not.toBeNull() 8 | }) 9 | 10 | it.concurrent('can query resources', async ({ expect }) => { 11 | async function fetcher() { 12 | return 'example' 13 | } 14 | 15 | const { query } = createTurboQuery({ fetcher }) 16 | 17 | const resource = await query('example-key') 18 | 19 | expect(resource).toBe('example') 20 | }) 21 | 22 | it.concurrent('can fetch expired resources while returning stale', async ({ expect }) => { 23 | async function fetcher() { 24 | return 'example' 25 | } 26 | 27 | const { query } = createTurboQuery({ fetcher, expiration: () => 100 }) 28 | 29 | await query('example-key') 30 | await new Promise((r) => setTimeout(r, 100)) 31 | const resource = await query('example-key') 32 | 33 | expect(resource).toBe('example') 34 | }) 35 | 36 | it.concurrent('can fetch expired resources while not returning stale', async ({ expect }) => { 37 | async function fetcher() { 38 | return 'example' 39 | } 40 | 41 | const { query } = createTurboQuery({ fetcher, expiration: () => 100 }) 42 | 43 | await query('example-key') 44 | await new Promise((r) => setTimeout(r, 100)) 45 | const resource = await query('example-key', { stale: false }) 46 | 47 | expect(resource).toBe('example') 48 | }) 49 | 50 | it.concurrent('returns the same promise when resources are resolving', async ({ expect }) => { 51 | let times = 0 52 | async function fetcher() { 53 | await new Promise((r) => setTimeout(r, 200)) 54 | times++ 55 | return 'example' 56 | } 57 | 58 | const { query } = createTurboQuery({ fetcher }) 59 | 60 | query('example-key') 61 | await query('example-key') 62 | 63 | expect(times).toBe(1) 64 | }) 65 | 66 | it.concurrent('does respect dedupe interval of resources', async ({ expect }) => { 67 | let times = 0 68 | async function fetcher() { 69 | times++ 70 | return 'example' 71 | } 72 | 73 | const { query } = createTurboQuery({ fetcher, expiration: () => 0 }) 74 | 75 | await query('example-key') 76 | await new Promise((r) => setTimeout(r, 100)) 77 | await query('example-key') 78 | 79 | expect(times).toBe(2) 80 | }) 81 | 82 | it.concurrent('does respect dedupe interval of resources 2', async ({ expect }) => { 83 | let times = 0 84 | async function fetcher() { 85 | times++ 86 | return 'example' 87 | } 88 | 89 | const { query } = createTurboQuery({ fetcher, expiration: () => 100 }) 90 | 91 | await query('example-key') 92 | await query('example-key') 93 | 94 | expect(times).toBe(1) 95 | }) 96 | 97 | it.concurrent('can subscribe to refetchings on resources', async ({ expect }) => { 98 | async function fetcher() { 99 | return 'example' 100 | } 101 | 102 | const { query, subscribe } = createTurboQuery({ fetcher, expiration: () => 0 }) 103 | 104 | let result: string | undefined = undefined 105 | const unsubscribe = subscribe( 106 | 'example-key', 107 | 'refetching', 108 | async function (event: CustomEventInit>) { 109 | result = await event.detail 110 | } 111 | ) 112 | await query('example-key', { fetcher }) 113 | unsubscribe() 114 | 115 | expect(result).toBe('example') 116 | }) 117 | 118 | it.concurrent('can subscribe to refetchings on pending resources', async ({ expect }) => { 119 | async function fetcher() { 120 | return 'example' 121 | } 122 | 123 | const { query, subscribe } = createTurboQuery({ fetcher, expiration: () => 0 }) 124 | const r = query('example-key', { fetcher }) 125 | 126 | let result: string | undefined = undefined 127 | const unsubscribe = subscribe( 128 | 'example-key', 129 | 'refetching', 130 | async function (event: CustomEventInit>) { 131 | result = await event.detail 132 | } 133 | ) 134 | await r 135 | unsubscribe() 136 | 137 | expect(result).toBe('example') 138 | }) 139 | 140 | it.concurrent('can subscribe to resolutions on resources', async ({ expect }) => { 141 | async function fetcher() { 142 | return 'example' 143 | } 144 | 145 | const { query, subscribe } = createTurboQuery({ fetcher, expiration: () => 0 }) 146 | 147 | let result: string | undefined = undefined 148 | const unsubscribe = subscribe( 149 | 'example-key', 150 | 'resolved', 151 | function (event: CustomEventInit) { 152 | result = event.detail 153 | } 154 | ) 155 | 156 | await query('example-key', { fetcher }) 157 | unsubscribe() 158 | expect(result).toBe('example') 159 | }) 160 | 161 | it.concurrent('can subscribe to mutations on resources', async ({ expect }) => { 162 | async function fetcher() { 163 | return 'example' 164 | } 165 | 166 | const { query, subscribe, mutate } = createTurboQuery({ fetcher }) 167 | 168 | const current = await query('example-key', { fetcher }) 169 | 170 | expect(current).toBe('example') 171 | 172 | let result: string | undefined = undefined 173 | const unsubscribe = subscribe( 174 | 'example-key', 175 | 'mutated', 176 | function (event: CustomEventInit) { 177 | result = event.detail 178 | } 179 | ) 180 | 181 | mutate('example-key', 'mutated-example') 182 | unsubscribe() 183 | 184 | expect(result).toBe('mutated-example') 185 | }) 186 | 187 | it.concurrent('can mutate non-existing cache keys when using a fn', async ({ expect }) => { 188 | async function fetcher() { 189 | return 'example' 190 | } 191 | 192 | const { query, mutate } = createTurboQuery({ fetcher }) 193 | 194 | const current = await query('example-key', { fetcher }) 195 | 196 | expect(current).toBe('example') 197 | 198 | mutate('example-key-2', () => 'mutated-example') 199 | 200 | const result = await query('example-key-2', { fetcher }) 201 | 202 | expect(result).toBe('mutated-example') 203 | }) 204 | 205 | it.concurrent('can subscribe to mutations on resources 2', async ({ expect }) => { 206 | async function fetcher() { 207 | return 1 208 | } 209 | 210 | const { query, subscribe, mutate } = createTurboQuery({ fetcher }) 211 | 212 | const current = await query('example-key', { fetcher }) 213 | 214 | expect(current).toBe(1) 215 | 216 | let result: number | undefined = undefined 217 | const unsubscribe = subscribe( 218 | 'example-key', 219 | 'mutated', 220 | async function (event: CustomEventInit) { 221 | result = event.detail 222 | } 223 | ) 224 | 225 | mutate('example-key', (old) => (old ?? 0) + 1) 226 | unsubscribe() 227 | 228 | expect(result).toBe(2) 229 | }) 230 | 231 | it.concurrent('can subscribe to aborts on resources', async ({ expect }) => { 232 | function fetcher(_key: string, { signal }: TurboFetcherAdditional) { 233 | return new Promise(function (resolve, reject) { 234 | signal.addEventListener('abort', function () { 235 | reject('aborted') 236 | }) 237 | new Promise((r) => setTimeout(r, 200)).then(function () { 238 | resolve('example') 239 | }) 240 | }) 241 | } 242 | 243 | const { query, subscribe, abort } = createTurboQuery({ fetcher, expiration: () => 0 }) 244 | 245 | let result: Promise | undefined = undefined 246 | const unsubscribe = subscribe( 247 | 'example-key', 248 | 'aborted', 249 | function (event: CustomEventInit>) { 250 | result = event.detail 251 | } 252 | ) 253 | 254 | const r = query('example-key', { fetcher }) 255 | abort() 256 | unsubscribe() 257 | 258 | await expect(() => r).rejects.toThrowError('aborted') 259 | await expect(() => result).rejects.toThrowError('aborted') 260 | }) 261 | 262 | it.concurrent('can subscribe to forgets on resources', async ({ expect }) => { 263 | async function fetcher() { 264 | return 'example' 265 | } 266 | 267 | const { query, subscribe, forget, keys } = createTurboQuery({ fetcher }) 268 | 269 | const current = await query('example-key', { fetcher }) 270 | 271 | expect(current).toBe('example') 272 | 273 | let result: string | undefined = undefined 274 | const unsubscribe = subscribe( 275 | 'example-key', 276 | 'forgotten', 277 | function (event: CustomEventInit) { 278 | result = event.detail 279 | } 280 | ) 281 | 282 | forget('example-key') 283 | unsubscribe() 284 | 285 | expect(result).toBe('example') 286 | expect(keys('items')).toHaveLength(0) 287 | }) 288 | 289 | it.concurrent('can subscribe to hydrates on resources', async ({ expect }) => { 290 | async function fetcher() { 291 | return 'example' 292 | } 293 | 294 | const { query, subscribe, hydrate, keys } = createTurboQuery({ fetcher }) 295 | 296 | const current = await query('example-key', { fetcher }) 297 | 298 | expect(current).toBe('example') 299 | 300 | let result: string | undefined = undefined 301 | const unsubscribe = subscribe( 302 | 'example-key', 303 | 'hydrated', 304 | async function (event: CustomEventInit) { 305 | result = event.detail 306 | } 307 | ) 308 | 309 | hydrate('example-key', 'hydrated-example') 310 | unsubscribe() 311 | 312 | expect(result).toBe('hydrated-example') 313 | expect(keys('items')).toHaveLength(1) 314 | }) 315 | 316 | it.concurrent('can reconfigure turbo query', async ({ expect }) => { 317 | async function fetcher() { 318 | return 'example' 319 | } 320 | 321 | const { query, configure } = createTurboQuery({ fetcher }) 322 | 323 | configure({ 324 | itemsCache: new Map(), 325 | resolversCache: new Map(), 326 | events: new EventTarget(), 327 | expiration() { 328 | return 5000 329 | }, 330 | stale: false, 331 | async fetcher() { 332 | return 'different' 333 | }, 334 | removeOnError: true, 335 | fresh: true, 336 | }) 337 | 338 | const result = await query('some-key') 339 | 340 | expect(result).toBe('different') 341 | }) 342 | 343 | it.concurrent('can reconfigure turbo query 2', async ({ expect }) => { 344 | async function fetcher() { 345 | return 'example' 346 | } 347 | 348 | const { query, configure } = createTurboQuery({ fetcher }) 349 | 350 | configure() 351 | 352 | const result = await query('some-key') 353 | 354 | expect(result).toBe('example') 355 | }) 356 | 357 | it.concurrent('can abort turbo query', async ({ expect }) => { 358 | function fetcher(_key: string, { signal }: TurboFetcherAdditional) { 359 | return new Promise(function (resolve, reject) { 360 | signal.addEventListener('abort', function () { 361 | reject('aborted') 362 | }) 363 | new Promise((r) => setTimeout(r, 200)).then(function () { 364 | resolve('example') 365 | }) 366 | }) 367 | } 368 | 369 | const { query, abort } = createTurboQuery({ fetcher }) 370 | const result = query('example-key') 371 | abort('example-key') 372 | 373 | await expect(() => result).rejects.toThrowError('aborted') 374 | }) 375 | 376 | it.concurrent('can abort turbo query 2', async ({ expect }) => { 377 | function fetcher(_key: string, { signal }: TurboFetcherAdditional) { 378 | return new Promise(function (resolve, reject) { 379 | signal.addEventListener('abort', function () { 380 | reject('aborted') 381 | }) 382 | new Promise((r) => setTimeout(r, 200)).then(function () { 383 | resolve('example') 384 | }) 385 | }) 386 | } 387 | 388 | const { query, abort } = createTurboQuery({ fetcher }) 389 | const result = query('example-key') 390 | abort(['example-key']) 391 | 392 | await expect(() => result).rejects.toThrowError('aborted') 393 | }) 394 | 395 | it.concurrent('can abort turbo query 3', async ({ expect }) => { 396 | function fetcher(_key: string, { signal }: TurboFetcherAdditional) { 397 | return new Promise(function (resolve, reject) { 398 | signal.addEventListener('abort', function () { 399 | reject('aborted') 400 | }) 401 | new Promise((r) => setTimeout(r, 200)).then(function () { 402 | resolve('example') 403 | }) 404 | }) 405 | } 406 | 407 | const { query, abort } = createTurboQuery({ fetcher }) 408 | const result = query('example-key') 409 | abort() 410 | 411 | await expect(() => result).rejects.toThrowError('aborted') 412 | }) 413 | 414 | it.concurrent('can get the item keys of a turbo query', async ({ expect }) => { 415 | async function fetcher() { 416 | return 'example' 417 | } 418 | 419 | const { query, keys } = createTurboQuery({ fetcher }) 420 | await query('foo') 421 | await query('bar') 422 | const items = keys('items') 423 | 424 | expect(items).toHaveLength(2) 425 | expect(items).toContain('foo') 426 | expect(items).toContain('bar') 427 | }) 428 | 429 | it.concurrent('can get the resolvers keys of a turbo query', async ({ expect }) => { 430 | async function fetcher() { 431 | await new Promise((r) => setTimeout(r, 250)) 432 | return 'example' 433 | } 434 | 435 | const { query, keys } = createTurboQuery({ fetcher }) 436 | query('foo') 437 | query('bar') 438 | const resolvers = keys('resolvers') 439 | 440 | expect(resolvers).toHaveLength(2) 441 | expect(resolvers).toContain('foo') 442 | expect(resolvers).toContain('bar') 443 | }) 444 | 445 | it.concurrent('can forget a turbo query key', async ({ expect }) => { 446 | async function fetcher() { 447 | return 'example' 448 | } 449 | 450 | const { query, forget, keys } = createTurboQuery({ fetcher }) 451 | await query('example-key') 452 | 453 | expect(keys('items')).toContain('example-key') 454 | forget('example-key') 455 | expect(keys('items')).toHaveLength(0) 456 | }) 457 | 458 | it.concurrent('can forget a turbo query key 2', async ({ expect }) => { 459 | async function fetcher() { 460 | return 'example' 461 | } 462 | 463 | const { query, forget, keys } = createTurboQuery({ fetcher }) 464 | await query('example-key') 465 | 466 | expect(keys('items')).toContain('example-key') 467 | forget(['example-key']) 468 | expect(keys('items')).toHaveLength(0) 469 | }) 470 | 471 | it.concurrent('can forget a turbo query key 3', async ({ expect }) => { 472 | async function fetcher() { 473 | return 'example' 474 | } 475 | 476 | const { query, forget, keys } = createTurboQuery({ fetcher }) 477 | await query('example-key') 478 | 479 | expect(keys('items')).toContain('example-key') 480 | forget() 481 | expect(keys('items')).toHaveLength(0) 482 | }) 483 | 484 | it.concurrent('removes resolver when query fails', async ({ expect }) => { 485 | async function fetcher(): Promise { 486 | throw new Error('foo') 487 | } 488 | 489 | async function fetcher2() { 490 | return 'example' 491 | } 492 | 493 | const { query } = createTurboQuery({ expiration: () => 0 }) 494 | 495 | await expect(query('example-key', { fetcher })).rejects.toThrowError('foo') 496 | await expect(query('example-key', { fetcher: fetcher2 })).resolves.toBe('example') 497 | }) 498 | 499 | it.concurrent('removes items if specified when query fails', async ({ expect }) => { 500 | async function fetcher(): Promise { 501 | throw new Error('foo') 502 | } 503 | 504 | async function fetcher2() { 505 | return 'example' 506 | } 507 | 508 | const { query, keys } = createTurboQuery({ expiration: () => 0 }) 509 | 510 | await query('example-key', { fetcher: fetcher2 }) 511 | expect(keys('items')).toContain('example-key') 512 | await expect( 513 | query('example-key', { fetcher, stale: false, removeOnError: true }) 514 | ).rejects.toThrowError('foo') 515 | expect(keys('items')).not.toContain('example-key') 516 | }) 517 | 518 | it.concurrent('can subscribe to errors', async ({ expect }) => { 519 | async function fetcher(): Promise { 520 | throw new Error('foo') 521 | } 522 | 523 | const { query, subscribe } = createTurboQuery({ fetcher, expiration: () => 0 }) 524 | 525 | let err: Error | undefined 526 | subscribe('example-key', 'error', function (event: CustomEventInit) { 527 | err = event.detail 528 | }) 529 | 530 | await expect(query('example-key')).rejects.toThrowError('foo') 531 | expect(err).toBeDefined() 532 | expect(err?.message).toBe('foo') 533 | }) 534 | 535 | it.concurrent('can give a fresh instance if needed', async ({ expect }) => { 536 | let times = 0 537 | async function fetcher() { 538 | times++ 539 | return 'example' 540 | } 541 | 542 | const { query } = createTurboQuery({ fetcher, expiration: () => 1000 }) 543 | 544 | await query('example-key') 545 | expect(times).toBe(1) 546 | await query('example-key') 547 | expect(times).toBe(1) 548 | await query('example-key', { fresh: true }) 549 | expect(times).toBe(2) 550 | }) 551 | 552 | it.concurrent('uses stale data while resolving', async ({ expect }) => { 553 | function fetcher(slow?: boolean) { 554 | return async function () { 555 | if (slow) await new Promise((r) => setTimeout(r, 100)) 556 | return `example-${slow ? 'slow' : 'fast'}` 557 | } 558 | } 559 | 560 | const { query } = createTurboQuery({ expiration: () => 0 }) 561 | 562 | const data = await query('example-key', { fetcher: fetcher(false) }) 563 | expect(data).toBe('example-fast') 564 | 565 | const data2 = await query('example-key', { fetcher: fetcher(true) }) 566 | expect(data2).toBe('example-fast') 567 | 568 | const data3 = await query('example-key', { fetcher: fetcher(true) }) 569 | expect(data3).toBe('example-fast') 570 | 571 | await new Promise((r) => setTimeout(r, 100)) 572 | 573 | const data4 = await query('example-key', { fetcher: fetcher(true) }) 574 | expect(data4).toBe('example-slow') 575 | }) 576 | 577 | it.concurrent('can get expiration date of items', async ({ expect }) => { 578 | async function fetcher() { 579 | return 'foo' 580 | } 581 | 582 | const { query, expiration } = createTurboQuery({ fetcher }) 583 | 584 | await query('example-key') 585 | 586 | expect(expiration('bad-key')).toBeUndefined() 587 | expect(expiration('example-key')).toBeInstanceOf(Date) 588 | }) 589 | 590 | it.concurrent('can hydrate keys', async ({ expect }) => { 591 | async function fetcher() { 592 | return 'foo' 593 | } 594 | 595 | const { query, hydrate } = createTurboQuery({ fetcher, expiration: () => 1000 }) 596 | 597 | const expiresAt = new Date() 598 | expiresAt.setMilliseconds(expiresAt.getMilliseconds() + 1000) 599 | 600 | hydrate('example-key', 'bar', expiresAt) 601 | const result = await query('example-key') 602 | const result2 = await query('example-key') 603 | 604 | expect(result).toBe('bar') 605 | expect(result2).toBe('bar') 606 | }) 607 | 608 | it.concurrent('can hydrate keys without expiration', async ({ expect }) => { 609 | async function fetcher() { 610 | return 'foo' 611 | } 612 | 613 | const { query, hydrate } = createTurboQuery({ fetcher, expiration: () => 1000 }) 614 | 615 | hydrate('example-key', 'bar') 616 | // Stale hydrated result (because no expiration given) 617 | const result = await query('example-key') 618 | const result2 = await query('example-key') 619 | 620 | expect(result).toBe('bar') 621 | expect(result2).toBe('foo') 622 | }) 623 | 624 | it.concurrent('can hydrate multiple keys', async ({ expect }) => { 625 | async function fetcher() { 626 | return 'foo' 627 | } 628 | 629 | const { query, hydrate } = createTurboQuery({ fetcher, expiration: () => 1000 }) 630 | 631 | const expiresAt = new Date() 632 | expiresAt.setMilliseconds(expiresAt.getMilliseconds() + 1000) 633 | 634 | hydrate(['example-key', 'example-key2'], 'bar', expiresAt) 635 | const result = await query('example-key') 636 | const result2 = await query('example-key2') 637 | const result3 = await query('example-key') 638 | const result4 = await query('example-key2') 639 | 640 | expect(result).toBe('bar') 641 | expect(result2).toBe('bar') 642 | expect(result3).toBe('bar') 643 | expect(result4).toBe('bar') 644 | }) 645 | 646 | it.concurrent('can use the default fetcher', async () => { 647 | const mockedFetch = vi.fn(fetch) 648 | 649 | mockedFetch.mockReturnValueOnce( 650 | new Promise((resolve) => resolve(new Response(JSON.stringify({ data: 'example' })))) 651 | ) 652 | 653 | const fetcher = defaultFetcher(mockedFetch) 654 | 655 | const { query } = createTurboQuery({ fetcher }) 656 | 657 | await expect(query<{ data: string }>('example')).resolves.toEqual({ data: 'example' }) 658 | }) 659 | 660 | it.concurrent('can use the default fetcher when fails', async () => { 661 | const mockedFetch = vi.fn(fetch) 662 | 663 | mockedFetch.mockReturnValueOnce( 664 | new Promise((resolve) => resolve(new Response(undefined, { status: 500 }))) 665 | ) 666 | 667 | const fetcher = defaultFetcher(mockedFetch) 668 | 669 | const { query } = createTurboQuery({ fetcher }) 670 | 671 | await expect(query<{ data: string }>('example')).rejects.toThrowError() 672 | }) 673 | --------------------------------------------------------------------------------