├── .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 |
--------------------------------------------------------------------------------