@raf-query/core 0.0.19 → 0.1.1

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 CHANGED
@@ -1,10 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.1](https://github.com/KATT/raf-query/compare/0.1.0...0.1.1) (2026-10-12)
4
+
5
+ ### Features
6
+
7
+ * **core:** add predicate to QueryFilter ([#24](https://github.com/KATT/raf-query/issues/24)) ([c0b9c18](https://github.com/KATT/raf-query/commit/c0b9c1829cb3b6446f765b151e520f0195af5276))
8
+
9
+ ## [0.1.0](https://github.com/KATT/raf-query/compare/0.0.19...0.1.0) (2026-10-12)
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ - Effect-native engine; core exposes only Effect APIs ([#23](https://github.com/KATT/raf-query/issues/23))
14
+ - **core:** make QueryObserver an Effect-style data type ([#29](https://github.com/KATT/raf-query/issues/29))
15
+
16
+ - **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))
17
+ - 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))
18
+
19
+ ### Features
20
+
21
+ - **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)
22
+ - 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))
23
+
3
24
  ## [0.0.19](https://github.com/KATT/raf-query/compare/0.0.18...0.0.19) (2026-10-12)
4
25
 
5
26
  ### Features
6
27
 
7
- * **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))
28
+ - **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))
8
29
 
9
30
  ## [0.0.18](https://github.com/KATT/raf-query/compare/0.0.17...0.0.18) (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: `QueryClient`, cache, `queryOptions` / `infiniteQueryOptions`, handles, dehydrate / hydrate stream, `allKeyed` |
18
- | `@raf-query/react` | Hooks and provider. Re-exports everything from `@raf-query/core`, so apps install and import only this package |
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
 
@@ -356,14 +358,24 @@ function EditTitle(props: { post: Post }) {
356
358
 
357
359
  `updatePost` is a server function (or a Next.js server action). `await qc.invalidate(...)` resolves once every query currently being read has refetched, so the action's pending state lasts until the fresh data is on screen. For an error message, wrap the action in `useActionState` and return the caught error.
358
360
 
359
- | Call | Effect |
360
- | ------------------------------------- | ----------------------------------------------------------------------- |
361
- | `qc.invalidate(factory, input)` | Mark one query stale and refetch it if it is being read |
362
- | `qc.invalidate({ queryKey, exact? })` | Same, for every query whose key starts with `queryKey` (unless `exact`) |
363
- | `qc.setData(factory, input, updater)` | Write data directly |
364
- | `qc.getData(factory, input)` | Read cached data, or `undefined` |
361
+ | Call | Effect |
362
+ | ------------------------------------- | ------------------------------------------------------- |
363
+ | `qc.invalidate(factory, input)` | Mark one query stale and refetch it if it is being read |
364
+ | `qc.invalidate(filter)` | Same, for every query the [filter](#filters) selects |
365
+ | `qc.setData(factory, input, updater)` | Write data directly |
366
+ | `qc.getData(factory, input)` | Read cached data, or `undefined` |
367
+
368
+ ### Filters
369
+
370
+ `invalidate`, `reset` and `remove` also take a filter, `{ queryKey?, exact?, predicate? }`. `queryKey` selects every query whose key starts with it, or only that key with `exact: true`; object segments match partially, so `{ queryKey: ["issues", { projectId: 3 }] }` selects every issue list in project 3. `predicate` gets each remaining `Query` and keeps the ones it returns `true` for: read its `key` and `handle.input`, or use the `Query` functions (`Query.getUnsafe(query)` for its state, `Query.observerCountUnsafe(query)`, `Query.isStaleUnsafe(query)`). Without `queryKey` every key matches, so `{}` selects the whole cache.
371
+
372
+ ```ts
373
+ qc.remove({ predicate: (query) => Query.observerCountUnsafe(query) === 0 }); // everything not being read
374
+ ```
365
375
 
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).getState().hasData` tells them apart.
376
+ `handle.input` is the input of the latest factory call for that entry. An entry that was only hydrated, and not yet read or started on this client, has `undefined` input, so match on `key` to include those.
377
+
378
+ 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
379
 
368
380
  ## SSR and hydration
369
381
 
@@ -392,7 +404,7 @@ export function getRouter() {
392
404
  }
393
405
  ```
394
406
 
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 generic error, so its message doesn't leak into the page.
407
+ `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
408
 
397
409
  ### Next.js App Router
398
410
 
@@ -458,13 +470,19 @@ A factory that imports server-only code (a database client) can't be imported by
458
470
 
459
471
  ## Effect
460
472
 
461
- raf-query runs requests on [Effect](https://effect.website), and Effect code can use it without wrapping:
473
+ 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
474
 
463
475
  - A `queryFn` can return an `Effect`. Cancelling the request interrupts it.
476
+ - 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
477
  - `retry` accepts a `Schedule` as well as a count or predicate.
465
- - `qc.effect` has Effect versions of `ensure`, `fetch` and `invalidate`, plus `watch`, a `Stream` of the entry's state.
478
+ - `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.
479
+ - 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.
480
+ - 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.
481
+ - `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`.
482
+ - 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
483
 
467
484
  ```ts
485
+ import { QueryCache, queryOptions } from "@raf-query/core";
468
486
  import { Effect, Schedule, Stream } from "effect";
469
487
 
470
488
  export function issueQuery(input: { id: number }) {
@@ -476,15 +494,69 @@ export function issueQuery(input: { id: number }) {
476
494
  }
477
495
 
478
496
  const program = Effect.gen(function* () {
479
- const issue = yield* qc.effect.fetch(issueQuery, { id: 1 });
480
- yield* qc.effect.watch(issueQuery, { id: 1 }).pipe(
497
+ const cache = yield* QueryCache.QueryCache;
498
+ const issue = yield* cache.fetch(issueQuery, { id: 1 });
499
+ yield* cache.watch(issueQuery, { id: 1 }).pipe(
481
500
  Stream.map((state) => state.data?.title),
482
501
  Stream.runForEach((title) => Effect.log(title)),
483
502
  );
484
503
  });
504
+
505
+ Effect.runFork(program.pipe(Effect.provide(QueryCache.layer())));
506
+ ```
507
+
508
+ `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.
509
+
510
+ ### Services
511
+
512
+ 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:
513
+
514
+ ```ts
515
+ import { QueryCache, queryOptions } from "@raf-query/core";
516
+ import { Context, Effect, Layer } from "effect";
517
+
518
+ class IssueApi extends Context.Service<
519
+ IssueApi,
520
+ { readonly get: (id: number) => Effect.Effect<Issue, IssueNotFound> }
521
+ >()("app/IssueApi") {}
522
+
523
+ const IssueApiLive = Layer.succeed(IssueApi, { get: IssueRepo.get });
524
+
525
+ export function issueQuery(input: { id: number }) {
526
+ return queryOptions({
527
+ queryKey: ["issue", input.id],
528
+ queryFn: () =>
529
+ Effect.gen(function* () {
530
+ const api = yield* IssueApi;
531
+ return yield* api.get(input.id);
532
+ }),
533
+ });
534
+ }
535
+
536
+ // Effect<Issue, IssueNotFound | Query.CancelledError, QueryCache | IssueApi>
537
+ const issue = QueryCache.QueryCache.use((cache) =>
538
+ cache.fetch(issueQuery, { id: 1 }),
539
+ );
540
+
541
+ Effect.runFork(
542
+ issue.pipe(Effect.provide(QueryCache.layer()), Effect.provide(IssueApiLive)),
543
+ );
544
+ ```
545
+
546
+ - 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.
547
+ - The caller's `Scope` isn't passed on: a request outlives the call that started it. Scope a `queryFn`'s resources with `Effect.scoped`.
548
+ - 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.
549
+
550
+ `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:
551
+
552
+ ```ts
553
+ const queryClient = new QueryClient({ layer: IssueApiLive }); // QueryClient<IssueApi>
554
+ const handle = queryClient.prefetch(issueQuery, { id: 1 });
555
+
556
+ await queryClient.dispose();
485
557
  ```
486
558
 
487
- `watch` counts as a reader while it runs, so invalidation and window focus refetch the entry.
559
+ `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
560
 
489
561
  ## Devtools
490
562
 
@@ -533,17 +605,18 @@ Both render nothing on the server, since the server's cache isn't the browser's.
533
605
 
534
606
  | Member | Use |
535
607
  | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
536
- | `new QueryClient(config?)` | `config.defaults` (query options below), `config.onError`, `config.isServer` |
608
+ | `new QueryClient(config?)` | `config.defaults` (query options below), `config.onError`, `config.isServer`, `config.layer` ([services](#services)) |
537
609
  | `prefetch`, `ensure`, `fetch` | [Start queries](#starting-queries) |
538
610
  | `invalidate`, `setData`, `getData` | [Write and read the cache](#mutations) |
539
- | `reset(factory, input)` / `reset(filter)` | Drop data and errors; read queries suspend and refetch. Resolves when they settle |
540
- | `remove(factory, input)` / `remove(filter)` | Remove entries and abort their requests |
541
- | `effect` | [Effect versions](#effect) of `ensure`, `fetch`, `invalidate`, plus `watch` |
611
+ | `reset(factory, input)` / `reset(filter)` | Drop data and errors; read queries suspend and refetch. Resolves when they settle. [Filters](#filters) |
612
+ | `remove(factory, input)` / `remove(filter)` | Remove entries and abort their requests. [Filters](#filters) |
613
+ | `effect` | The [Effect](#effect) `QueryCache` behind the client |
542
614
  | `subscribe(listener)` | Cache events: `added`, `updated`, `observers` (a reader subscribed or left), `removed`, and `failed` (with the `error`) |
543
615
  | `getAll()`, `getQuery(factory, input?)` | Cache entries, for devtools and integrations |
544
616
  | `clear()` | Drop every entry and abort in-flight requests |
545
617
  | `mount()` | Start window-focus and reconnect refetching. The providers call it |
546
618
  | `isServer` | Detected from `window`. Server clients don't retry, never gc, skip stale timers, and never revalidate cached data |
619
+ | `dispose()` | Interrupt every request and release the layer's services. Resolves when its finalizers have run |
547
620
 
548
621
  Query options, accepted by `queryOptions`, `infiniteQueryOptions` and `config.defaults`:
549
622
 
@@ -562,29 +635,34 @@ In the browser, retries pause while `navigator.onLine` is `false`: after the ret
562
635
 
563
636
  `@raf-query/core`:
564
637
 
565
- | Export | Use |
566
- | ---------------------------------------------- | ---------------------------------------------------------------- |
567
- | `QueryClient` | The cache |
568
- | `queryOptions(options)` | Typed options for a query factory |
569
- | `infiniteQueryOptions(options)` | Typed options for a paged query factory |
570
- | `allKeyed(record)` | Await a record of promises, keeping keys |
571
- | `dehydrate(client)` / `hydrate(client, state)` | Low-level cache transfer used by the integrations |
572
- | `hashKey(key)`, `matchesKey(key, prefix)` | Key helpers |
573
- | `QueryObserver` | One reader's view of a query, with tracked results; for bindings |
574
- | `CancelledError` | Rejection of a request cancelled by `clear()` or a restart |
575
- | `QueryHandle<TFactory>` | Handle type for props |
576
- | `EnsuredQueryHandle<TFactory>` | Handle type for props fed by `ensure` |
577
- | `Thenable<T>` | Promise with `status` / `value` / `reason`, read by `use()` |
578
- | `DataOf<TFactory>`, `InputOf<TFactory>` | A factory's data and input types |
638
+ | Export | Use |
639
+ | ------------------------------------------------------------ | -------------------------------------------------------------------------- |
640
+ | `QueryCache` | The cache service (`qc.effect`): `make`, `layer`, and the verbs as Effects |
641
+ | `Query` | Cache entries: `Query.fetch`, `Query.changes`, `Query.getUnsafe`, … |
642
+ | `Platform` | The page the cache watches (focus, network); swap it in tests |
643
+ | `queryOptions(options)` | Typed options for a query factory |
644
+ | `infiniteQueryOptions(options)` | Typed options for a paged query factory |
645
+ | `hashKey(key)`, `matchesKey(key, prefix)` | Key helpers |
646
+ | `QueryObserver` | One reader's view of a query, with tracked results; for bindings |
647
+ | `Query.CancelledError` | Failure of a request whose entry was removed or cleared before it settled |
648
+ | `RedactedError` | What a streamed pending query rejects with when its server request failed |
649
+ | `QueryHandle<TFactory>` | Handle type for props |
650
+ | `EnsuredQueryHandle<TFactory>` | Handle type for props fed by `ensure` |
651
+ | `Thenable<T>` | Promise with `status` / `value` / `reason`, read by `use()` |
652
+ | `DataOf<TFactory>`, `InputOf<TFactory>`, `ErrorOf<TFactory>` | A factory's data, input and error types |
653
+ | `ServicesOf<TFactory>` | The services a factory's `queryFn` Effect needs; `never` for promises |
579
654
 
580
655
  `@raf-query/react` (plus all of the above):
581
656
 
582
- | Export | Use |
583
- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
584
- | `QueryClientProvider` | Provides a client to the tree |
585
- | `useQueryClient()` | Read the client |
586
- | `useQuery(factory, handle, options?)` | `[Thenable<Data>, controls]`; for paged factories `Data` is `{ pages, pageParams }` and controls include paging |
587
- | `useWaterfall(factory, input)` | Start a query during render and return its handle |
657
+ | Export | Use |
658
+ | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
659
+ | `QueryClient` | The cache with Promise methods ([above](#api-reference)) |
660
+ | `allKeyed(record)` | Await a record of promises, keeping keys |
661
+ | `dehydrate(client)` / `hydrate(client, state)` | Low-level cache transfer used by the integrations |
662
+ | `QueryClientProvider` | Provides a client to the tree |
663
+ | `useQueryClient()` | Read the client |
664
+ | `useQuery(factory, handle, options?)` | `[Thenable<Data>, controls]`; for paged factories `Data` is `{ pages, pageParams }` and controls include paging |
665
+ | `useWaterfall(factory, input)` | Start a query during render and return its handle |
588
666
 
589
667
  ## Development
590
668
 
@@ -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 };
@@ -0,0 +1,151 @@
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
+ /**
34
+ * Selects cache entries: by key prefix (exact key with `exact`), then by
35
+ * `predicate`. Without `queryKey` every key matches, so `{}` selects the
36
+ * whole cache.
37
+ */
38
+ export interface Filter {
39
+ readonly exact?: boolean;
40
+ /**
41
+ * Keep only entries this returns `true` for; runs after key matching.
42
+ * `query.handle.input` is the input of the latest factory call, and stays
43
+ * `undefined` for an entry only hydrated so far; match on `query.key` to
44
+ * include those.
45
+ */
46
+ readonly predicate?: (query: Query) => boolean;
47
+ readonly queryKey?: QueryKey;
48
+ }
49
+ /** `ensure` / `fetch` arguments: the input, then load options for paged factories. */
50
+ export type LoadArgs<TFactory extends QueryFactory> = InputArgs<TFactory> | [input: InputOf<TFactory>, options: LoadOptions<TFactory>];
51
+ /** The entries `invalidate`, `reset` and `remove` act on: one factory call, or a filter. */
52
+ export type Target<TFactory extends QueryFactory> = [factory: TFactory, ...args: InputArgs<TFactory>] | [filter: Filter];
53
+ /**
54
+ * A new value, or a function of the current one. `previous` is `undefined`
55
+ * both with no data and with `undefined` data.
56
+ */
57
+ export type Updater<TData> = TData | ((previous: TData | undefined) => TData);
58
+ export interface QueryCache {
59
+ /** Every entry. */
60
+ readonly all: Effect.Effect<ReadonlyArray<Query>>;
61
+ /** Drop every entry and interrupt their requests. */
62
+ readonly clear: Effect.Effect<void>;
63
+ /** Snapshot the cache for transfer to another cache (server → browser). */
64
+ dehydrate(options?: DehydrateOptions): Effect.Effect<DehydratedState>;
65
+ /**
66
+ * Snapshot the cache, then stream entries as they start or settle, each
67
+ * entry once. Entries that change in the same tick share a batch, which
68
+ * is sent a microtask later. `updates` ends when the scope closes, after
69
+ * sending what changed before; `hydrateStream` is the receiving end.
70
+ */
71
+ dehydrateStream(options?: DehydrateOptions): Effect.Effect<DehydratedStream, never, Scope.Scope>;
72
+ /**
73
+ * Start a query and wait for it on a cache miss. Fails if that request
74
+ * fails. Stale cached data succeeds immediately and revalidates in the
75
+ * browser. The handle is marked `ensured`, so `useQuery` returns its data
76
+ * directly.
77
+ */
78
+ ensure<TFactory extends QueryFactory>(factory: TFactory, ...args: LoadArgs<TFactory>): Effect.Effect<EnsuredQueryHandle<TFactory>, ErrorOf<TFactory> | CancelledError, ServicesOf<TFactory>>;
79
+ /** Cache events from the moment the stream starts. */
80
+ readonly events: Stream.Stream<Event>;
81
+ /** Like `ensure`, but succeeds with the data. */
82
+ fetch<TFactory extends QueryFactory>(factory: TFactory, ...args: LoadArgs<TFactory>): Effect.Effect<DataOf<TFactory>, ErrorOf<TFactory> | CancelledError, ServicesOf<TFactory>>;
83
+ /**
84
+ * Cached data for a factory call. `Some(undefined)` when the data itself
85
+ * is `undefined`.
86
+ */
87
+ getData<TFactory extends QueryFactory>(factory: TFactory, ...args: InputArgs<TFactory>): Effect.Effect<Option.Option<DataOf<TFactory>>>;
88
+ /**
89
+ * The cache entry for a factory call, created if missing. Doesn't fetch,
90
+ * but its requests run with this fiber's services from now on. For
91
+ * bindings; apps use the verbs.
92
+ */
93
+ getQuery<TFactory extends QueryFactory>(factory: TFactory, ...args: InputArgs<TFactory>): Effect.Effect<Of<TFactory>, never, ServicesOf<TFactory>>;
94
+ /**
95
+ * `getQuery`, synchronously, for renders. Leaves the entry's services as
96
+ * they are: the cache's, or the latest verb caller's.
97
+ */
98
+ getQueryUnsafe<TFactory extends QueryFactory>(factory: TFactory, ...args: InputArgs<TFactory>): Of<TFactory>;
99
+ /**
100
+ * Write dehydrated entries. Existing entries only take newer data; pending
101
+ * entries resume the sender's request.
102
+ */
103
+ hydrate(state: DehydratedState, options?: HydrateOptions): Effect.Effect<void>;
104
+ /** `hydrate` every state of a stream as it arrives (`dehydrateStream`'s `updates`). */
105
+ hydrateStream<E, R>(states: Stream.Stream<DehydratedState, E, R>, options?: HydrateOptions): Effect.Effect<void, E, R>;
106
+ /**
107
+ * Mark queries stale and refetch the ones being read. Succeeds once those
108
+ * refetches settle, so an action stays pending until fresh data is on
109
+ * screen.
110
+ */
111
+ invalidate<TFactory extends QueryFactory>(...target: Target<TFactory>): Effect.Effect<void>;
112
+ readonly isServer: boolean;
113
+ /**
114
+ * Refetch read, stale queries on window focus and reconnect while the
115
+ * scope is open. Mounts share one platform subscription. Does nothing on
116
+ * the server.
117
+ */
118
+ readonly mount: Effect.Effect<void, never, Scope.Scope>;
119
+ /**
120
+ * Start a query without waiting and return its handle. Fresh data is
121
+ * reused; stale data refetches in the background (in the browser only, see
122
+ * {@link Config.isServer}). Errors surface where the data is read, never
123
+ * here.
124
+ */
125
+ prefetch<TFactory extends QueryFactory>(factory: TFactory, ...args: InputArgs<TFactory>): Effect.Effect<QueryHandle<TFactory>, never, ServicesOf<TFactory>>;
126
+ /**
127
+ * Remove queries from the cache and interrupt their requests. Readers keep
128
+ * the removed entry until they render again.
129
+ */
130
+ remove<TFactory extends QueryFactory>(...target: Target<TFactory>): Effect.Effect<void>;
131
+ /**
132
+ * Drop queries' data and errors, as if they were never fetched. Read ones
133
+ * suspend and refetch; succeeds once those refetches settle.
134
+ */
135
+ reset<TFactory extends QueryFactory>(...target: Target<TFactory>): Effect.Effect<void>;
136
+ /** Write data for a factory call. Readers re-render without suspending. */
137
+ setData<TFactory extends QueryFactory>(factory: TFactory, input: InputOf<TFactory>, updater: Updater<DataOf<TFactory>>): Effect.Effect<void>;
138
+ /** Listen to cache events synchronously, as they happen. */
139
+ subscribeUnsafe(listener: (event: Event) => void): () => void;
140
+ readonly [TypeId]: TypeId;
141
+ /** A factory call's `Query.changes`. */
142
+ watch<TFactory extends QueryFactory>(factory: TFactory, ...args: InputArgs<TFactory>): Stream.Stream<State<DataOf<TFactory>>, never, ServicesOf<TFactory>>;
143
+ }
144
+ export declare const QueryCache: Context.Service<QueryCache, QueryCache>;
145
+ export declare function isQueryCache(value: unknown): value is QueryCache;
146
+ /** Build a cache in the current scope, with the current `Clock` and {@link Platform}. */
147
+ export declare const make: (config?: Config) => Effect.Effect<QueryCache, never, Scope.Scope>;
148
+ /** A layer that provides a {@link QueryCache} for the layer's lifetime. */
149
+ export declare const layer: (config?: Config) => Layer.Layer<QueryCache>;
150
+ //#endregion
151
+ export { cache_d_exports };
package/dist/cache.mjs ADDED
@@ -0,0 +1,185 @@
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
+ function keyMatcher(filter) {
48
+ const prefix = filter.queryKey;
49
+ if (prefix === void 0) return () => true;
50
+ if (filter.exact) {
51
+ const hash = hashKey(prefix);
52
+ return (entry) => entry.hash === hash;
53
+ }
54
+ return (entry) => matchesKey(entry.key, prefix);
55
+ }
56
+ /**
57
+ * Background refetch of cached data a verb reuses. A server cache renders
58
+ * once, so a refetch there could only duplicate a request whose result is
59
+ * never shown; it refetches only what `invalidate` marked.
60
+ */
61
+ function revalidate(entry) {
62
+ const state = entry.state;
63
+ const wanted = entry.cache.isServer ? state.isInvalidated : isStaleUnsafe(entry);
64
+ if (!state.isFetching && wanted) start(entry);
65
+ }
66
+ /**
67
+ * Waits for an entry's data on a cache miss. Cached data returns at once,
68
+ * and revalidates in the background (see `revalidate`).
69
+ */
70
+ function load(entry, pages) {
71
+ if (!entry.state.hasData) return unsafeCast(fetch(entry, { pages }));
72
+ revalidate(entry);
73
+ return Effect.succeed(dataOf(entry));
74
+ }
75
+ /** Settles every effect, started together. */
76
+ function settleAll(effects) {
77
+ return Effect.all(effects, {
78
+ concurrency: "unbounded",
79
+ discard: true
80
+ });
81
+ }
82
+ /** Build a cache in the current scope, with the current `Clock` and {@link Platform}. */
83
+ const make = Effect.fnUntraced(function* (config = {}) {
84
+ const platform = yield* Platform;
85
+ const state = {
86
+ clock: yield* Clock.Clock,
87
+ defaults: config.defaults ?? {},
88
+ entries: /* @__PURE__ */ new Map(),
89
+ isServer: config.isServer ?? platform.isServer,
90
+ listeners: /* @__PURE__ */ new Set(),
91
+ platform,
92
+ scope: yield* Effect.scope,
93
+ services: yield* Effect.context()
94
+ };
95
+ const entryFor = (key) => {
96
+ const hash = hashKey(key);
97
+ const existing = state.entries.get(hash);
98
+ if (existing) return existing;
99
+ const entry = make$1(state, key, hash);
100
+ state.entries.set(hash, entry);
101
+ emit(state, {
102
+ query: entry,
103
+ type: "added"
104
+ });
105
+ return entry;
106
+ };
107
+ const build = (factory, input) => {
108
+ const options = optionsFor(factory, input);
109
+ const entry = entryFor(options.queryKey);
110
+ setOptions(entry, options, input);
111
+ return entry;
112
+ };
113
+ /** `build`, for a verb: the entry's requests get the caller's services. */
114
+ const buildFor = (factory, input) => Effect.contextWith((caller) => {
115
+ const entry = build(factory, input);
116
+ provide(entry, caller);
117
+ return Effect.succeed(entry);
118
+ });
119
+ const match = (target) => {
120
+ const [first, input] = unsafeCast(target);
121
+ if (Predicate.isFunction(first)) {
122
+ const entry = state.entries.get(hashKey(optionsFor(first, input).queryKey));
123
+ return entry ? [entry] : [];
124
+ }
125
+ const keyMatches = keyMatcher(first);
126
+ return [...state.entries.values()].filter((entry) => keyMatches(entry) && (first.predicate?.(entry) ?? true));
127
+ };
128
+ const subscription = yield* RcRef.make({ acquire: Effect.acquireRelease(Effect.sync(() => platform.subscribeUnsafe((event) => {
129
+ for (const entry of state.entries.values()) if (isObserved(entry)) onPlatformEvent(entry, event);
130
+ })), (unsubscribe) => Effect.sync(unsubscribe)) });
131
+ const subscribeUnsafe = (listener) => {
132
+ state.listeners.add(listener);
133
+ return () => {
134
+ state.listeners.delete(listener);
135
+ };
136
+ };
137
+ const getQueryUnsafe = (factory, ...args) => typed(build(factory, args[0]));
138
+ return {
139
+ all: Effect.sync(() => [...state.entries.values()]),
140
+ clear: Effect.sync(() => {
141
+ for (const entry of state.entries.values()) remove(entry);
142
+ }),
143
+ dehydrate: (options = {}) => Effect.sync(() => dehydrateEntries(state.entries.values(), options)),
144
+ dehydrateStream: (options = {}) => dehydrateStream(() => state.entries.values(), subscribeUnsafe, options),
145
+ ensure: (factory, ...args) => Effect.flatMap(buildFor(factory, args[0]), (entry) => load(entry, args[1]?.pages).pipe(Effect.as({
146
+ ...handleOf(entry),
147
+ ensured: true
148
+ }))),
149
+ events: listen(subscribeUnsafe),
150
+ fetch: (factory, ...args) => Effect.flatMap(buildFor(factory, args[0]), (entry) => load(entry, args[1]?.pages)),
151
+ getData: (factory, ...args) => Effect.sync(() => {
152
+ const options = optionsFor(factory, args[0]);
153
+ const entry = state.entries.get(hashKey(options.queryKey));
154
+ return entry?.state.hasData ? Option.some(dataOf(entry)) : Option.none();
155
+ }),
156
+ getQuery: (factory, ...args) => Effect.map(buildFor(factory, args[0]), typed),
157
+ getQueryUnsafe,
158
+ hydrate: (dehydrated, options = {}) => Effect.sync(() => hydrateEntries(entryFor, dehydrated, options)),
159
+ hydrateStream: (states, options = {}) => Stream.runForEach(states, (dehydrated) => Effect.sync(() => hydrateEntries(entryFor, dehydrated, options))),
160
+ invalidate: (...target) => Effect.suspend(() => settleAll(match(target).map(invalidate))),
161
+ isServer: state.isServer,
162
+ mount: state.isServer ? Effect.void : Effect.asVoid(RcRef.get(subscription)),
163
+ prefetch: (factory, ...args) => Effect.map(buildFor(factory, args[0]), (entry) => {
164
+ if (entry.state.hasData) revalidate(entry);
165
+ else start(entry);
166
+ return handleOf(entry);
167
+ }),
168
+ remove: (...target) => Effect.sync(() => {
169
+ for (const entry of match(target)) remove(entry);
170
+ }),
171
+ reset: (...target) => Effect.suspend(() => settleAll(match(target).map(reset))),
172
+ setData: (factory, input, updater) => Effect.sync(() => {
173
+ const entry = build(factory, input);
174
+ const next = isUpdateFunction(updater) ? updater(entry.state.hasData ? dataOf(entry) : void 0) : updater;
175
+ setData(entry, next);
176
+ }),
177
+ subscribeUnsafe,
178
+ [TypeId]: TypeId,
179
+ watch: (factory, ...args) => Stream.unwrap(Effect.map(buildFor(factory, args[0]), (entry) => unsafeCast(changes(entry))))
180
+ };
181
+ });
182
+ /** A layer that provides a {@link QueryCache} for the layer's lifetime. */
183
+ const layer = (config) => Layer.effect(QueryCache, make(config));
184
+ //#endregion
185
+ export { QueryCache, TypeId, cache_exports, isQueryCache, layer, make };