@raf-query/core 0.0.18 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -1
- package/README.md +101 -32
- package/dist/_virtual/_rolldown/runtime.mjs +13 -0
- package/dist/cache.d.mts +140 -0
- package/dist/cache.mjs +176 -0
- package/dist/entry.d.mts +113 -0
- package/dist/entry.mjs +421 -0
- package/dist/hydration.d.mts +37 -13
- package/dist/hydration.mjs +78 -17
- package/dist/index.d.mts +8 -7
- package/dist/index.mjs +6 -6
- package/dist/infinite.d.mts +5 -0
- package/dist/infinite.mjs +59 -0
- package/dist/listen.mjs +19 -0
- package/dist/observer.d.mts +59 -40
- package/dist/observer.mjs +215 -133
- package/dist/options.d.mts +50 -14
- package/dist/options.mjs +3 -0
- package/dist/platform.d.mts +27 -0
- package/dist/platform.mjs +44 -0
- package/dist/query.d.mts +80 -51
- package/dist/query.mjs +111 -421
- package/dist/request.mjs +47 -0
- package/dist/retry.mjs +25 -0
- package/dist/thenable.d.mts +29 -0
- package/dist/thenable.mjs +3 -3
- package/package.json +1 -1
- package/dist/all-keyed.d.mts +0 -18
- package/dist/all-keyed.mjs +0 -19
- package/dist/client.d.mts +0 -123
- package/dist/client.mjs +0 -266
- package/dist/online.mjs +0 -16
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.0](https://github.com/KATT/raf-query/compare/0.0.19...0.1.0) (2026-10-12)
|
|
4
|
+
|
|
5
|
+
### ⚠ BREAKING CHANGES
|
|
6
|
+
|
|
7
|
+
* Effect-native engine; core exposes only Effect APIs ([#23](https://github.com/KATT/raf-query/issues/23))
|
|
8
|
+
* **core:** make QueryObserver an Effect-style data type ([#29](https://github.com/KATT/raf-query/issues/29))
|
|
9
|
+
|
|
10
|
+
* **core:** make QueryObserver an Effect-style data type ([#29](https://github.com/KATT/raf-query/issues/29)) ([50fbb45](https://github.com/KATT/raf-query/commit/50fbb45bf3edcaa83673e2177894a76bdf76560b))
|
|
11
|
+
* Effect-native engine; core exposes only Effect APIs ([#23](https://github.com/KATT/raf-query/issues/23)) ([8e0bb09](https://github.com/KATT/raf-query/commit/8e0bb09a63d644ede053f443406a521ff2613a6f))
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **core:** stream SSR dehydration as Effect APIs; integrations become thin edges ([#30](https://github.com/KATT/raf-query/issues/30)) ([6b2b570](https://github.com/KATT/raf-query/commit/6b2b5703f5463c0f3805c233cf4ead90df9efb76)), references [#29](https://github.com/KATT/raf-query/issues/29)
|
|
16
|
+
* typed services for Effect queries; QueryClient layer and dispose() ([#31](https://github.com/KATT/raf-query/issues/31)) ([6aeaa3d](https://github.com/KATT/raf-query/commit/6aeaa3d356f6483654729620ac26762508eb8fd8))
|
|
17
|
+
|
|
18
|
+
## [0.0.19](https://github.com/KATT/raf-query/compare/0.0.18...0.0.19) (2026-10-12)
|
|
19
|
+
|
|
20
|
+
### Features
|
|
21
|
+
|
|
22
|
+
- **core:** type the cache's promises as Thenable ([#21](https://github.com/KATT/raf-query/issues/21)) ([c0541b0](https://github.com/KATT/raf-query/commit/c0541b0c14e687f25d47f25fe457e49b135d8c06))
|
|
23
|
+
|
|
3
24
|
## [0.0.18](https://github.com/KATT/raf-query/compare/0.0.17...0.0.18) (2026-10-12)
|
|
4
25
|
|
|
5
26
|
### Bug Fixes
|
|
6
27
|
|
|
7
|
-
|
|
28
|
+
- **core:** allow undefined query data ([#22](https://github.com/KATT/raf-query/issues/22)) ([9b85e57](https://github.com/KATT/raf-query/commit/9b85e57bf2fed4413cdbc1b49f925cc60dad695f)), references [#20](https://github.com/KATT/raf-query/issues/20)
|
|
8
29
|
|
|
9
30
|
## [0.0.17](https://github.com/KATT/raf-query/compare/0.0.16...0.0.17) (2026-10-12)
|
|
10
31
|
|
package/README.md
CHANGED
|
@@ -6,16 +6,18 @@ Queries start in route loaders and React Server Components, travel to components
|
|
|
6
6
|
|
|
7
7
|
The cache's mechanics (dedupe, staleness, retries, infinite paging, SSR hydration) follow [TanStack Query](https://tanstack.com/query); the public API is built around loaders, RSC, and `use()` instead of `useQuery`-in-render.
|
|
8
8
|
|
|
9
|
+
The cache is written in [Effect](https://effect.website), but using raf-query doesn't take any Effect: `queryFn`s return promises, and `QueryClient`, the hooks and the integrations speak promises. Apps that do use Effect get the same cache as Effects, with typed errors and services ([Effect](#effect)).
|
|
10
|
+
|
|
9
11
|
## Status
|
|
10
12
|
|
|
11
13
|
Early. Both packages are on npm at `0.0.x`, and the API can still change between patch releases. [`examples/`](examples/) has the same issue tracker built on TanStack Start and on Next.js App Router.
|
|
12
14
|
|
|
13
15
|
## Packages
|
|
14
16
|
|
|
15
|
-
| Package | Contents
|
|
16
|
-
| ------------------ |
|
|
17
|
-
| `@raf-query/core` | Framework-agnostic: `
|
|
18
|
-
| `@raf-query/react` |
|
|
17
|
+
| Package | Contents |
|
|
18
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
19
|
+
| `@raf-query/core` | Framework-agnostic and Effect-only: `QueryCache`, `queryOptions` / `infiniteQueryOptions`, handles, dehydrate / hydrate |
|
|
20
|
+
| `@raf-query/react` | `QueryClient` (the cache with Promise methods), hooks, provider, `allKeyed`. Re-exports `@raf-query/core`, so apps import only this package |
|
|
19
21
|
|
|
20
22
|
`@raf-query/react` subpath entries for framework integrations and tooling:
|
|
21
23
|
|
|
@@ -363,7 +365,7 @@ function EditTitle(props: { post: Post }) {
|
|
|
363
365
|
| `qc.setData(factory, input, updater)` | Write data directly |
|
|
364
366
|
| `qc.getData(factory, input)` | Read cached data, or `undefined` |
|
|
365
367
|
|
|
366
|
-
Data can be `undefined` when the `queryFn`'s type allows it (`Promise<User | undefined>`); it's cached and reused like any other value, unlike in TanStack Query, where an `undefined` result is an error. So `getData` and an updater's `previous` return `undefined` both with no data and with `undefined` data; `qc.getQuery(factory, input)
|
|
368
|
+
Data can be `undefined` when the `queryFn`'s type allows it (`Promise<User | undefined>`); it's cached and reused like any other value, unlike in TanStack Query, where an `undefined` result is an error. So `getData` and an updater's `previous` return `undefined` both with no data and with `undefined` data; `Query.getUnsafe(qc.getQuery(factory, input)).hasData` tells them apart.
|
|
367
369
|
|
|
368
370
|
## SSR and hydration
|
|
369
371
|
|
|
@@ -392,7 +394,7 @@ export function getRouter() {
|
|
|
392
394
|
}
|
|
393
395
|
```
|
|
394
396
|
|
|
395
|
-
`setupRouterIntegration` streams queries during SSR, clears the request's cache when the response finishes, and turns `redirect()` thrown from a `queryFn` into a router navigation. `getRouter` runs per request on the server, so each request gets its own client. Options: `dehydrateOptions` (`filter`, `keepErrors`, `serializeData`), `hydrateOptions` (`deserializeData`), and `handleRedirects: false` to handle redirects yourself. By default a server error crosses to the browser as a
|
|
397
|
+
`setupRouterIntegration` streams queries during SSR, clears the request's cache when the response finishes, and turns `redirect()` thrown from a `queryFn` into a router navigation. `getRouter` runs per request on the server, so each request gets its own client. Options: `dehydrateOptions` (`filter`, `keepErrors`, `serializeData`), `hydrateOptions` (`deserializeData`), and `handleRedirects: false` to handle redirects yourself. By default a server error crosses to the browser as a `RedactedError` naming the query, so its message doesn't leak into the page.
|
|
396
398
|
|
|
397
399
|
### Next.js App Router
|
|
398
400
|
|
|
@@ -458,13 +460,19 @@ A factory that imports server-only code (a database client) can't be imported by
|
|
|
458
460
|
|
|
459
461
|
## Effect
|
|
460
462
|
|
|
461
|
-
raf-query
|
|
463
|
+
raf-query's cache is written in [Effect](https://effect.website): `QueryClient` is a Promise wrapper around a `QueryCache` service from `@raf-query/core`, available as `qc.effect`. Effect code uses the cache directly, or builds its own with `QueryCache.make(config)` (scoped: closing the scope interrupts every request and timer) or `QueryCache.layer(config)`:
|
|
462
464
|
|
|
463
465
|
- A `queryFn` can return an `Effect`. Cancelling the request interrupts it.
|
|
466
|
+
- An Effect `queryFn`'s error type is the query's: `qc.effect.fetch(issueQuery, input)` fails with `IssueNotFound | Query.CancelledError`. `ErrorOf<TFactory>` names it; it's `unknown` for promises. A `queryFn` that throws instead of returning is a defect, and isn't retried.
|
|
464
467
|
- `retry` accepts a `Schedule` as well as a count or predicate.
|
|
465
|
-
- `qc.effect` has
|
|
468
|
+
- `qc.effect` has every verb as an Effect: `prefetch`, `ensure`, `fetch`, `invalidate`, `reset`, `remove`, `setData`, `getData` (an `Option`), `dehydrate`, `hydrate`. `watch` is a `Stream` of an entry's state, and `events` a `Stream` of cache events.
|
|
469
|
+
- SSR streaming is Effect too, and the integrations are thin wrappers over it. `dehydrateStream(options)` (scoped) returns the cache's `initial` state and a `Stream` of `updates`: entries that start or settle later, each sent once, batched per microtask. The stream ends when the scope closes. `hydrateStream(stream)` hydrates each state as it arrives. `Stream.toReadableStream` / `Stream.fromReadableStream` carry it over a web stream. The `sent` option (a `Set` of hashes) makes separate `dehydrate` calls of one request skip entries already sent.
|
|
470
|
+
- Entries are a data type with module functions, as in Effect's own modules: `Query.fetch(query)`, `Query.refetch(query)`, `Query.invalidate(query)`, `Query.changes(query)`, and `Query.getUnsafe(query)` / `Query.subscribeUnsafe(query, listener)` for synchronous reads.
|
|
471
|
+
- `QueryObserver` is a data type too: one reader's view of a query, as `useQuery` sees it (the controls and the promise, with `whilePending`, `refetchInterval` and the focus / reconnect options). `QueryObserver.make(query, options)` creates one, and `QueryObserver.changes(observer)` streams its results; while it runs, the query counts as read and polls on `refetchInterval`.
|
|
472
|
+
- Time comes from the `Clock` and the page from the `Platform` service (focus, visibility, `navigator.onLine`; the default reads the globals). Provide `TestClock` or another `Platform` to test staleness, gc and offline retries without a browser.
|
|
466
473
|
|
|
467
474
|
```ts
|
|
475
|
+
import { QueryCache, queryOptions } from "@raf-query/core";
|
|
468
476
|
import { Effect, Schedule, Stream } from "effect";
|
|
469
477
|
|
|
470
478
|
export function issueQuery(input: { id: number }) {
|
|
@@ -476,15 +484,69 @@ export function issueQuery(input: { id: number }) {
|
|
|
476
484
|
}
|
|
477
485
|
|
|
478
486
|
const program = Effect.gen(function* () {
|
|
479
|
-
const
|
|
480
|
-
yield*
|
|
487
|
+
const cache = yield* QueryCache.QueryCache;
|
|
488
|
+
const issue = yield* cache.fetch(issueQuery, { id: 1 });
|
|
489
|
+
yield* cache.watch(issueQuery, { id: 1 }).pipe(
|
|
481
490
|
Stream.map((state) => state.data?.title),
|
|
482
491
|
Stream.runForEach((title) => Effect.log(title)),
|
|
483
492
|
);
|
|
484
493
|
});
|
|
494
|
+
|
|
495
|
+
Effect.runFork(program.pipe(Effect.provide(QueryCache.layer())));
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
`watch` counts as a reader while it runs, so invalidation and window focus refetch the entry. Outside React, `cache.mount` (`Effect<void, never, Scope>`) refetches read, stale entries on window focus and reconnect while its scope is open.
|
|
499
|
+
|
|
500
|
+
### Services
|
|
501
|
+
|
|
502
|
+
A `queryFn`'s Effect can require services. They're part of the factory's type, `ServicesOf<TFactory>`, and every verb that can start the query requires them:
|
|
503
|
+
|
|
504
|
+
```ts
|
|
505
|
+
import { QueryCache, queryOptions } from "@raf-query/core";
|
|
506
|
+
import { Context, Effect, Layer } from "effect";
|
|
507
|
+
|
|
508
|
+
class IssueApi extends Context.Service<
|
|
509
|
+
IssueApi,
|
|
510
|
+
{ readonly get: (id: number) => Effect.Effect<Issue, IssueNotFound> }
|
|
511
|
+
>()("app/IssueApi") {}
|
|
512
|
+
|
|
513
|
+
const IssueApiLive = Layer.succeed(IssueApi, { get: IssueRepo.get });
|
|
514
|
+
|
|
515
|
+
export function issueQuery(input: { id: number }) {
|
|
516
|
+
return queryOptions({
|
|
517
|
+
queryKey: ["issue", input.id],
|
|
518
|
+
queryFn: () =>
|
|
519
|
+
Effect.gen(function* () {
|
|
520
|
+
const api = yield* IssueApi;
|
|
521
|
+
return yield* api.get(input.id);
|
|
522
|
+
}),
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Effect<Issue, IssueNotFound | Query.CancelledError, QueryCache | IssueApi>
|
|
527
|
+
const issue = QueryCache.QueryCache.use((cache) =>
|
|
528
|
+
cache.fetch(issueQuery, { id: 1 }),
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
Effect.runFork(
|
|
532
|
+
issue.pipe(Effect.provide(QueryCache.layer()), Effect.provide(IssueApiLive)),
|
|
533
|
+
);
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
- On `QueryCache`, `ensure`, `fetch`, `prefetch`, `getQuery` and `watch` require the factory's services, and so does `Query.fetch`. A request runs with the services the cache was built with, under those of the latest of these calls for its entry. Requests that start without a caller (a render's read, `refetch`, polling, focus and reconnect, `invalidate`) reuse the entry's last services.
|
|
537
|
+
- The caller's `Scope` isn't passed on: a request outlives the call that started it. Scope a `queryFn`'s resources with `Effect.scoped`.
|
|
538
|
+
- Each request runs in a `raf-query.request` span with a `raf_query.hash` attribute, under the latest caller's span if it had one. Defects are logged with `Effect.logError`; typed failures are the query's error.
|
|
539
|
+
|
|
540
|
+
`QueryClient` gets its services from a `layer`. It's built into the client's scope with the cache, so it must build synchronously. The client's type is `QueryClient<TServices>`, and its verbs accept only factories whose services the layer provides; any other factory is a type error. `dispose()` interrupts every request and runs the layer's finalizers:
|
|
541
|
+
|
|
542
|
+
```ts
|
|
543
|
+
const queryClient = new QueryClient({ layer: IssueApiLive }); // QueryClient<IssueApi>
|
|
544
|
+
const handle = queryClient.prefetch(issueQuery, { id: 1 });
|
|
545
|
+
|
|
546
|
+
await queryClient.dispose();
|
|
485
547
|
```
|
|
486
548
|
|
|
487
|
-
`
|
|
549
|
+
`QueryClientProvider` takes a client of any services. A hook can't know which ones, so `useQueryClient()` returns a `QueryClient<unknown>`, whose verbs accept any factory, and `useQuery` reads any factory: a request then fails with a defect if the client's layer lacks a service it needs.
|
|
488
550
|
|
|
489
551
|
## Devtools
|
|
490
552
|
|
|
@@ -533,17 +595,18 @@ Both render nothing on the server, since the server's cache isn't the browser's.
|
|
|
533
595
|
|
|
534
596
|
| Member | Use |
|
|
535
597
|
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
536
|
-
| `new QueryClient(config?)` | `config.defaults` (query options below), `config.onError`, `config.isServer`
|
|
598
|
+
| `new QueryClient(config?)` | `config.defaults` (query options below), `config.onError`, `config.isServer`, `config.layer` ([services](#services)) |
|
|
537
599
|
| `prefetch`, `ensure`, `fetch` | [Start queries](#starting-queries) |
|
|
538
600
|
| `invalidate`, `setData`, `getData` | [Write and read the cache](#mutations) |
|
|
539
601
|
| `reset(factory, input)` / `reset(filter)` | Drop data and errors; read queries suspend and refetch. Resolves when they settle |
|
|
540
602
|
| `remove(factory, input)` / `remove(filter)` | Remove entries and abort their requests |
|
|
541
|
-
| `effect` | [Effect
|
|
603
|
+
| `effect` | The [Effect](#effect) `QueryCache` behind the client |
|
|
542
604
|
| `subscribe(listener)` | Cache events: `added`, `updated`, `observers` (a reader subscribed or left), `removed`, and `failed` (with the `error`) |
|
|
543
605
|
| `getAll()`, `getQuery(factory, input?)` | Cache entries, for devtools and integrations |
|
|
544
606
|
| `clear()` | Drop every entry and abort in-flight requests |
|
|
545
607
|
| `mount()` | Start window-focus and reconnect refetching. The providers call it |
|
|
546
608
|
| `isServer` | Detected from `window`. Server clients don't retry, never gc, skip stale timers, and never revalidate cached data |
|
|
609
|
+
| `dispose()` | Interrupt every request and release the layer's services. Resolves when its finalizers have run |
|
|
547
610
|
|
|
548
611
|
Query options, accepted by `queryOptions`, `infiniteQueryOptions` and `config.defaults`:
|
|
549
612
|
|
|
@@ -562,28 +625,34 @@ In the browser, retries pause while `navigator.onLine` is `false`: after the ret
|
|
|
562
625
|
|
|
563
626
|
`@raf-query/core`:
|
|
564
627
|
|
|
565
|
-
| Export
|
|
566
|
-
|
|
|
567
|
-
| `
|
|
568
|
-
| `
|
|
569
|
-
| `
|
|
570
|
-
| `
|
|
571
|
-
| `
|
|
572
|
-
| `hashKey(key)`, `matchesKey(key, prefix)`
|
|
573
|
-
| `QueryObserver`
|
|
574
|
-
| `CancelledError`
|
|
575
|
-
| `
|
|
576
|
-
| `
|
|
577
|
-
| `
|
|
628
|
+
| Export | Use |
|
|
629
|
+
| ------------------------------------------------------------ | -------------------------------------------------------------------------- |
|
|
630
|
+
| `QueryCache` | The cache service (`qc.effect`): `make`, `layer`, and the verbs as Effects |
|
|
631
|
+
| `Query` | Cache entries: `Query.fetch`, `Query.changes`, `Query.getUnsafe`, … |
|
|
632
|
+
| `Platform` | The page the cache watches (focus, network); swap it in tests |
|
|
633
|
+
| `queryOptions(options)` | Typed options for a query factory |
|
|
634
|
+
| `infiniteQueryOptions(options)` | Typed options for a paged query factory |
|
|
635
|
+
| `hashKey(key)`, `matchesKey(key, prefix)` | Key helpers |
|
|
636
|
+
| `QueryObserver` | One reader's view of a query, with tracked results; for bindings |
|
|
637
|
+
| `Query.CancelledError` | Failure of a request whose entry was removed or cleared before it settled |
|
|
638
|
+
| `RedactedError` | What a streamed pending query rejects with when its server request failed |
|
|
639
|
+
| `QueryHandle<TFactory>` | Handle type for props |
|
|
640
|
+
| `EnsuredQueryHandle<TFactory>` | Handle type for props fed by `ensure` |
|
|
641
|
+
| `Thenable<T>` | Promise with `status` / `value` / `reason`, read by `use()` |
|
|
642
|
+
| `DataOf<TFactory>`, `InputOf<TFactory>`, `ErrorOf<TFactory>` | A factory's data, input and error types |
|
|
643
|
+
| `ServicesOf<TFactory>` | The services a factory's `queryFn` Effect needs; `never` for promises |
|
|
578
644
|
|
|
579
645
|
`@raf-query/react` (plus all of the above):
|
|
580
646
|
|
|
581
|
-
| Export
|
|
582
|
-
|
|
|
583
|
-
| `
|
|
584
|
-
| `
|
|
585
|
-
| `
|
|
586
|
-
| `
|
|
647
|
+
| Export | Use |
|
|
648
|
+
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
649
|
+
| `QueryClient` | The cache with Promise methods ([above](#api-reference)) |
|
|
650
|
+
| `allKeyed(record)` | Await a record of promises, keeping keys |
|
|
651
|
+
| `dehydrate(client)` / `hydrate(client, state)` | Low-level cache transfer used by the integrations |
|
|
652
|
+
| `QueryClientProvider` | Provides a client to the tree |
|
|
653
|
+
| `useQueryClient()` | Read the client |
|
|
654
|
+
| `useQuery(factory, handle, options?)` | `[Thenable<Data>, controls]`; for paged factories `Data` is `{ pages, pageParams }` and controls include paging |
|
|
655
|
+
| `useWaterfall(factory, input)` | Start a query during render and return its handle |
|
|
587
656
|
|
|
588
657
|
## Development
|
|
589
658
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region \0rolldown/runtime.js
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __exportAll = (all, no_symbols) => {
|
|
4
|
+
let target = {};
|
|
5
|
+
for (var name in all) __defProp(target, name, {
|
|
6
|
+
get: all[name],
|
|
7
|
+
enumerable: true
|
|
8
|
+
});
|
|
9
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
10
|
+
return target;
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
export { __exportAll };
|
package/dist/cache.d.mts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { QueryKey } from "./key.mjs";
|
|
2
|
+
import { DataOf, EnsuredQueryHandle, ErrorOf, InputArgs, InputOf, LoadOptions, QueryBehavior, QueryFactory, QueryHandle, ServicesOf } from "./options.mjs";
|
|
3
|
+
import { DehydrateOptions, DehydratedState, DehydratedStream, HydrateOptions } from "./hydration.mjs";
|
|
4
|
+
import { CancelledError, Of, Query, State, query_d_exports } from "./query.mjs";
|
|
5
|
+
import { Context, Effect, Layer, Option, Scope, Stream } from "effect";
|
|
6
|
+
declare namespace cache_d_exports {
|
|
7
|
+
export { Config, Event, Filter, LoadArgs, QueryCache, Target, TypeId, Updater, isQueryCache, layer, make };
|
|
8
|
+
}
|
|
9
|
+
export declare const TypeId = "~@raf-query/core/QueryCache";
|
|
10
|
+
export type TypeId = typeof TypeId;
|
|
11
|
+
export interface Config {
|
|
12
|
+
/** Defaults for every query; a factory's own options win. */
|
|
13
|
+
readonly defaults?: QueryBehavior;
|
|
14
|
+
/**
|
|
15
|
+
* Whether this cache runs on the server. Default: the {@link Platform}'s.
|
|
16
|
+
* Server caches don't retry, never garbage-collect, skip stale timers, and
|
|
17
|
+
* reuse cached data in `prefetch` / `ensure` / `fetch` instead of
|
|
18
|
+
* revalidating it, unless `invalidate` marked it.
|
|
19
|
+
*/
|
|
20
|
+
readonly isServer?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** Emitted on {@link QueryCache.events} for every cache change. */
|
|
23
|
+
export type Event = {
|
|
24
|
+
readonly query: Query;
|
|
25
|
+
/** `observers`: a reader subscribed or unsubscribed. */
|
|
26
|
+
readonly type: "added" | "observers" | "removed" | "updated";
|
|
27
|
+
} | {
|
|
28
|
+
/** A request failed (after retries), with this error. */
|
|
29
|
+
readonly error: unknown;
|
|
30
|
+
readonly query: Query;
|
|
31
|
+
readonly type: "failed";
|
|
32
|
+
};
|
|
33
|
+
/** Matches cache entries by key prefix, or by exact key with `exact`. */
|
|
34
|
+
export interface Filter {
|
|
35
|
+
readonly exact?: boolean;
|
|
36
|
+
readonly queryKey: QueryKey;
|
|
37
|
+
}
|
|
38
|
+
/** `ensure` / `fetch` arguments: the input, then load options for paged factories. */
|
|
39
|
+
export type LoadArgs<TFactory extends QueryFactory> = InputArgs<TFactory> | [input: InputOf<TFactory>, options: LoadOptions<TFactory>];
|
|
40
|
+
/** The entries `invalidate`, `reset` and `remove` act on: one factory call, or a filter. */
|
|
41
|
+
export type Target<TFactory extends QueryFactory> = [factory: TFactory, ...args: InputArgs<TFactory>] | [filter: Filter];
|
|
42
|
+
/**
|
|
43
|
+
* A new value, or a function of the current one. `previous` is `undefined`
|
|
44
|
+
* both with no data and with `undefined` data.
|
|
45
|
+
*/
|
|
46
|
+
export type Updater<TData> = TData | ((previous: TData | undefined) => TData);
|
|
47
|
+
export interface QueryCache {
|
|
48
|
+
/** Every entry. */
|
|
49
|
+
readonly all: Effect.Effect<ReadonlyArray<Query>>;
|
|
50
|
+
/** Drop every entry and interrupt their requests. */
|
|
51
|
+
readonly clear: Effect.Effect<void>;
|
|
52
|
+
/** Snapshot the cache for transfer to another cache (server → browser). */
|
|
53
|
+
dehydrate(options?: DehydrateOptions): Effect.Effect<DehydratedState>;
|
|
54
|
+
/**
|
|
55
|
+
* Snapshot the cache, then stream entries as they start or settle, each
|
|
56
|
+
* entry once. Entries that change in the same tick share a batch, which
|
|
57
|
+
* is sent a microtask later. `updates` ends when the scope closes, after
|
|
58
|
+
* sending what changed before; `hydrateStream` is the receiving end.
|
|
59
|
+
*/
|
|
60
|
+
dehydrateStream(options?: DehydrateOptions): Effect.Effect<DehydratedStream, never, Scope.Scope>;
|
|
61
|
+
/**
|
|
62
|
+
* Start a query and wait for it on a cache miss. Fails if that request
|
|
63
|
+
* fails. Stale cached data succeeds immediately and revalidates in the
|
|
64
|
+
* browser. The handle is marked `ensured`, so `useQuery` returns its data
|
|
65
|
+
* directly.
|
|
66
|
+
*/
|
|
67
|
+
ensure<TFactory extends QueryFactory>(factory: TFactory, ...args: LoadArgs<TFactory>): Effect.Effect<EnsuredQueryHandle<TFactory>, ErrorOf<TFactory> | CancelledError, ServicesOf<TFactory>>;
|
|
68
|
+
/** Cache events from the moment the stream starts. */
|
|
69
|
+
readonly events: Stream.Stream<Event>;
|
|
70
|
+
/** Like `ensure`, but succeeds with the data. */
|
|
71
|
+
fetch<TFactory extends QueryFactory>(factory: TFactory, ...args: LoadArgs<TFactory>): Effect.Effect<DataOf<TFactory>, ErrorOf<TFactory> | CancelledError, ServicesOf<TFactory>>;
|
|
72
|
+
/**
|
|
73
|
+
* Cached data for a factory call. `Some(undefined)` when the data itself
|
|
74
|
+
* is `undefined`.
|
|
75
|
+
*/
|
|
76
|
+
getData<TFactory extends QueryFactory>(factory: TFactory, ...args: InputArgs<TFactory>): Effect.Effect<Option.Option<DataOf<TFactory>>>;
|
|
77
|
+
/**
|
|
78
|
+
* The cache entry for a factory call, created if missing. Doesn't fetch,
|
|
79
|
+
* but its requests run with this fiber's services from now on. For
|
|
80
|
+
* bindings; apps use the verbs.
|
|
81
|
+
*/
|
|
82
|
+
getQuery<TFactory extends QueryFactory>(factory: TFactory, ...args: InputArgs<TFactory>): Effect.Effect<Of<TFactory>, never, ServicesOf<TFactory>>;
|
|
83
|
+
/**
|
|
84
|
+
* `getQuery`, synchronously, for renders. Leaves the entry's services as
|
|
85
|
+
* they are: the cache's, or the latest verb caller's.
|
|
86
|
+
*/
|
|
87
|
+
getQueryUnsafe<TFactory extends QueryFactory>(factory: TFactory, ...args: InputArgs<TFactory>): Of<TFactory>;
|
|
88
|
+
/**
|
|
89
|
+
* Write dehydrated entries. Existing entries only take newer data; pending
|
|
90
|
+
* entries resume the sender's request.
|
|
91
|
+
*/
|
|
92
|
+
hydrate(state: DehydratedState, options?: HydrateOptions): Effect.Effect<void>;
|
|
93
|
+
/** `hydrate` every state of a stream as it arrives (`dehydrateStream`'s `updates`). */
|
|
94
|
+
hydrateStream<E, R>(states: Stream.Stream<DehydratedState, E, R>, options?: HydrateOptions): Effect.Effect<void, E, R>;
|
|
95
|
+
/**
|
|
96
|
+
* Mark queries stale and refetch the ones being read. Succeeds once those
|
|
97
|
+
* refetches settle, so an action stays pending until fresh data is on
|
|
98
|
+
* screen.
|
|
99
|
+
*/
|
|
100
|
+
invalidate<TFactory extends QueryFactory>(...target: Target<TFactory>): Effect.Effect<void>;
|
|
101
|
+
readonly isServer: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Refetch read, stale queries on window focus and reconnect while the
|
|
104
|
+
* scope is open. Mounts share one platform subscription. Does nothing on
|
|
105
|
+
* the server.
|
|
106
|
+
*/
|
|
107
|
+
readonly mount: Effect.Effect<void, never, Scope.Scope>;
|
|
108
|
+
/**
|
|
109
|
+
* Start a query without waiting and return its handle. Fresh data is
|
|
110
|
+
* reused; stale data refetches in the background (in the browser only, see
|
|
111
|
+
* {@link Config.isServer}). Errors surface where the data is read, never
|
|
112
|
+
* here.
|
|
113
|
+
*/
|
|
114
|
+
prefetch<TFactory extends QueryFactory>(factory: TFactory, ...args: InputArgs<TFactory>): Effect.Effect<QueryHandle<TFactory>, never, ServicesOf<TFactory>>;
|
|
115
|
+
/**
|
|
116
|
+
* Remove queries from the cache and interrupt their requests. Readers keep
|
|
117
|
+
* the removed entry until they render again.
|
|
118
|
+
*/
|
|
119
|
+
remove<TFactory extends QueryFactory>(...target: Target<TFactory>): Effect.Effect<void>;
|
|
120
|
+
/**
|
|
121
|
+
* Drop queries' data and errors, as if they were never fetched. Read ones
|
|
122
|
+
* suspend and refetch; succeeds once those refetches settle.
|
|
123
|
+
*/
|
|
124
|
+
reset<TFactory extends QueryFactory>(...target: Target<TFactory>): Effect.Effect<void>;
|
|
125
|
+
/** Write data for a factory call. Readers re-render without suspending. */
|
|
126
|
+
setData<TFactory extends QueryFactory>(factory: TFactory, input: InputOf<TFactory>, updater: Updater<DataOf<TFactory>>): Effect.Effect<void>;
|
|
127
|
+
/** Listen to cache events synchronously, as they happen. */
|
|
128
|
+
subscribeUnsafe(listener: (event: Event) => void): () => void;
|
|
129
|
+
readonly [TypeId]: TypeId;
|
|
130
|
+
/** A factory call's `Query.changes`. */
|
|
131
|
+
watch<TFactory extends QueryFactory>(factory: TFactory, ...args: InputArgs<TFactory>): Stream.Stream<State<DataOf<TFactory>>, never, ServicesOf<TFactory>>;
|
|
132
|
+
}
|
|
133
|
+
export declare const QueryCache: Context.Service<QueryCache, QueryCache>;
|
|
134
|
+
export declare function isQueryCache(value: unknown): value is QueryCache;
|
|
135
|
+
/** Build a cache in the current scope, with the current `Clock` and {@link Platform}. */
|
|
136
|
+
export declare const make: (config?: Config) => Effect.Effect<QueryCache, never, Scope.Scope>;
|
|
137
|
+
/** A layer that provides a {@link QueryCache} for the layer's lifetime. */
|
|
138
|
+
export declare const layer: (config?: Config) => Layer.Layer<QueryCache>;
|
|
139
|
+
//#endregion
|
|
140
|
+
export { cache_d_exports };
|
package/dist/cache.mjs
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { __exportAll } from "./_virtual/_rolldown/runtime.mjs";
|
|
2
|
+
import { Platform } from "./platform.mjs";
|
|
3
|
+
import { unsafeCast } from "./cast.mjs";
|
|
4
|
+
import { listen } from "./listen.mjs";
|
|
5
|
+
import { hashKey, matchesKey } from "./key.mjs";
|
|
6
|
+
import { changes, emit, fetch, invalidate, isObserved, isStaleUnsafe, make as make$1, onPlatformEvent, provide, remove, reset, setData, setOptions, start } from "./entry.mjs";
|
|
7
|
+
import { dehydrateEntries, dehydrateStream, hydrateEntries } from "./hydration.mjs";
|
|
8
|
+
import { optionsFor } from "./options.mjs";
|
|
9
|
+
import { Clock, Context, Effect, Layer, Option, Predicate, RcRef, Stream } from "effect";
|
|
10
|
+
//#region src/cache.ts
|
|
11
|
+
/**
|
|
12
|
+
* The query cache, as an Effect service. `make` builds one in the current
|
|
13
|
+
* scope; closing the scope interrupts every request and timer it started.
|
|
14
|
+
* Everything that waits, or changes the cache, is an Effect; `getQueryUnsafe`
|
|
15
|
+
* and `subscribeUnsafe` are for renders and external stores.
|
|
16
|
+
*
|
|
17
|
+
* Requests run with the services the cache was built with, under those of
|
|
18
|
+
* the latest verb that called for the entry. So the verbs that can start a
|
|
19
|
+
* request (`ensure`, `fetch`, `prefetch`, `getQuery`, `watch`) require the
|
|
20
|
+
* factory's {@link ServicesOf}; requests a render, a refetch or `invalidate`
|
|
21
|
+
* starts reuse the entry's last services.
|
|
22
|
+
*/
|
|
23
|
+
var cache_exports = /* @__PURE__ */ __exportAll({
|
|
24
|
+
QueryCache: () => QueryCache,
|
|
25
|
+
TypeId: () => TypeId,
|
|
26
|
+
isQueryCache: () => isQueryCache,
|
|
27
|
+
layer: () => layer,
|
|
28
|
+
make: () => make
|
|
29
|
+
});
|
|
30
|
+
const TypeId = "~@raf-query/core/QueryCache";
|
|
31
|
+
const QueryCache = Context.Service(TypeId);
|
|
32
|
+
function isQueryCache(value) {
|
|
33
|
+
return Predicate.hasProperty(value, TypeId);
|
|
34
|
+
}
|
|
35
|
+
function isUpdateFunction(updater) {
|
|
36
|
+
return Predicate.isFunction(updater);
|
|
37
|
+
}
|
|
38
|
+
function typed(entry) {
|
|
39
|
+
return unsafeCast(entry);
|
|
40
|
+
}
|
|
41
|
+
function handleOf(entry) {
|
|
42
|
+
return unsafeCast(entry.handle);
|
|
43
|
+
}
|
|
44
|
+
function dataOf(entry) {
|
|
45
|
+
return unsafeCast(entry.state.data);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Background refetch of cached data a verb reuses. A server cache renders
|
|
49
|
+
* once, so a refetch there could only duplicate a request whose result is
|
|
50
|
+
* never shown; it refetches only what `invalidate` marked.
|
|
51
|
+
*/
|
|
52
|
+
function revalidate(entry) {
|
|
53
|
+
const state = entry.state;
|
|
54
|
+
const wanted = entry.cache.isServer ? state.isInvalidated : isStaleUnsafe(entry);
|
|
55
|
+
if (!state.isFetching && wanted) start(entry);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Waits for an entry's data on a cache miss. Cached data returns at once,
|
|
59
|
+
* and revalidates in the background (see `revalidate`).
|
|
60
|
+
*/
|
|
61
|
+
function load(entry, pages) {
|
|
62
|
+
if (!entry.state.hasData) return unsafeCast(fetch(entry, { pages }));
|
|
63
|
+
revalidate(entry);
|
|
64
|
+
return Effect.succeed(dataOf(entry));
|
|
65
|
+
}
|
|
66
|
+
/** Settles every effect, started together. */
|
|
67
|
+
function settleAll(effects) {
|
|
68
|
+
return Effect.all(effects, {
|
|
69
|
+
concurrency: "unbounded",
|
|
70
|
+
discard: true
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/** Build a cache in the current scope, with the current `Clock` and {@link Platform}. */
|
|
74
|
+
const make = Effect.fnUntraced(function* (config = {}) {
|
|
75
|
+
const platform = yield* Platform;
|
|
76
|
+
const state = {
|
|
77
|
+
clock: yield* Clock.Clock,
|
|
78
|
+
defaults: config.defaults ?? {},
|
|
79
|
+
entries: /* @__PURE__ */ new Map(),
|
|
80
|
+
isServer: config.isServer ?? platform.isServer,
|
|
81
|
+
listeners: /* @__PURE__ */ new Set(),
|
|
82
|
+
platform,
|
|
83
|
+
scope: yield* Effect.scope,
|
|
84
|
+
services: yield* Effect.context()
|
|
85
|
+
};
|
|
86
|
+
const entryFor = (key) => {
|
|
87
|
+
const hash = hashKey(key);
|
|
88
|
+
const existing = state.entries.get(hash);
|
|
89
|
+
if (existing) return existing;
|
|
90
|
+
const entry = make$1(state, key, hash);
|
|
91
|
+
state.entries.set(hash, entry);
|
|
92
|
+
emit(state, {
|
|
93
|
+
query: entry,
|
|
94
|
+
type: "added"
|
|
95
|
+
});
|
|
96
|
+
return entry;
|
|
97
|
+
};
|
|
98
|
+
const build = (factory, input) => {
|
|
99
|
+
const options = optionsFor(factory, input);
|
|
100
|
+
const entry = entryFor(options.queryKey);
|
|
101
|
+
setOptions(entry, options, input);
|
|
102
|
+
return entry;
|
|
103
|
+
};
|
|
104
|
+
/** `build`, for a verb: the entry's requests get the caller's services. */
|
|
105
|
+
const buildFor = (factory, input) => Effect.contextWith((caller) => {
|
|
106
|
+
const entry = build(factory, input);
|
|
107
|
+
provide(entry, caller);
|
|
108
|
+
return Effect.succeed(entry);
|
|
109
|
+
});
|
|
110
|
+
const match = (target) => {
|
|
111
|
+
const [first, input] = unsafeCast(target);
|
|
112
|
+
if (Predicate.isFunction(first)) {
|
|
113
|
+
const entry = state.entries.get(hashKey(optionsFor(first, input).queryKey));
|
|
114
|
+
return entry ? [entry] : [];
|
|
115
|
+
}
|
|
116
|
+
const hash = hashKey(first.queryKey);
|
|
117
|
+
return [...state.entries.values()].filter((entry) => first.exact ? entry.hash === hash : matchesKey(entry.key, first.queryKey));
|
|
118
|
+
};
|
|
119
|
+
const subscription = yield* RcRef.make({ acquire: Effect.acquireRelease(Effect.sync(() => platform.subscribeUnsafe((event) => {
|
|
120
|
+
for (const entry of state.entries.values()) if (isObserved(entry)) onPlatformEvent(entry, event);
|
|
121
|
+
})), (unsubscribe) => Effect.sync(unsubscribe)) });
|
|
122
|
+
const subscribeUnsafe = (listener) => {
|
|
123
|
+
state.listeners.add(listener);
|
|
124
|
+
return () => {
|
|
125
|
+
state.listeners.delete(listener);
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
const getQueryUnsafe = (factory, ...args) => typed(build(factory, args[0]));
|
|
129
|
+
return {
|
|
130
|
+
all: Effect.sync(() => [...state.entries.values()]),
|
|
131
|
+
clear: Effect.sync(() => {
|
|
132
|
+
for (const entry of state.entries.values()) remove(entry);
|
|
133
|
+
}),
|
|
134
|
+
dehydrate: (options = {}) => Effect.sync(() => dehydrateEntries(state.entries.values(), options)),
|
|
135
|
+
dehydrateStream: (options = {}) => dehydrateStream(() => state.entries.values(), subscribeUnsafe, options),
|
|
136
|
+
ensure: (factory, ...args) => Effect.flatMap(buildFor(factory, args[0]), (entry) => load(entry, args[1]?.pages).pipe(Effect.as({
|
|
137
|
+
...handleOf(entry),
|
|
138
|
+
ensured: true
|
|
139
|
+
}))),
|
|
140
|
+
events: listen(subscribeUnsafe),
|
|
141
|
+
fetch: (factory, ...args) => Effect.flatMap(buildFor(factory, args[0]), (entry) => load(entry, args[1]?.pages)),
|
|
142
|
+
getData: (factory, ...args) => Effect.sync(() => {
|
|
143
|
+
const options = optionsFor(factory, args[0]);
|
|
144
|
+
const entry = state.entries.get(hashKey(options.queryKey));
|
|
145
|
+
return entry?.state.hasData ? Option.some(dataOf(entry)) : Option.none();
|
|
146
|
+
}),
|
|
147
|
+
getQuery: (factory, ...args) => Effect.map(buildFor(factory, args[0]), typed),
|
|
148
|
+
getQueryUnsafe,
|
|
149
|
+
hydrate: (dehydrated, options = {}) => Effect.sync(() => hydrateEntries(entryFor, dehydrated, options)),
|
|
150
|
+
hydrateStream: (states, options = {}) => Stream.runForEach(states, (dehydrated) => Effect.sync(() => hydrateEntries(entryFor, dehydrated, options))),
|
|
151
|
+
invalidate: (...target) => Effect.suspend(() => settleAll(match(target).map(invalidate))),
|
|
152
|
+
isServer: state.isServer,
|
|
153
|
+
mount: state.isServer ? Effect.void : Effect.asVoid(RcRef.get(subscription)),
|
|
154
|
+
prefetch: (factory, ...args) => Effect.map(buildFor(factory, args[0]), (entry) => {
|
|
155
|
+
if (entry.state.hasData) revalidate(entry);
|
|
156
|
+
else start(entry);
|
|
157
|
+
return handleOf(entry);
|
|
158
|
+
}),
|
|
159
|
+
remove: (...target) => Effect.sync(() => {
|
|
160
|
+
for (const entry of match(target)) remove(entry);
|
|
161
|
+
}),
|
|
162
|
+
reset: (...target) => Effect.suspend(() => settleAll(match(target).map(reset))),
|
|
163
|
+
setData: (factory, input, updater) => Effect.sync(() => {
|
|
164
|
+
const entry = build(factory, input);
|
|
165
|
+
const next = isUpdateFunction(updater) ? updater(entry.state.hasData ? dataOf(entry) : void 0) : updater;
|
|
166
|
+
setData(entry, next);
|
|
167
|
+
}),
|
|
168
|
+
subscribeUnsafe,
|
|
169
|
+
[TypeId]: TypeId,
|
|
170
|
+
watch: (factory, ...args) => Stream.unwrap(Effect.map(buildFor(factory, args[0]), (entry) => unsafeCast(changes(entry))))
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
/** A layer that provides a {@link QueryCache} for the layer's lifetime. */
|
|
174
|
+
const layer = (config) => Layer.effect(QueryCache, make(config));
|
|
175
|
+
//#endregion
|
|
176
|
+
export { QueryCache, TypeId, cache_exports, isQueryCache, layer, make };
|