From 15c88fb294f15941ae090502665c1ddc7da5cf34 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 18:39:16 -0400 Subject: [PATCH 1/2] docs(fetchium): make the perf stack's docs and changesets concise Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/activation-timing-fixes.md | 8 +- .changeset/async-store-honors-max-count.md | 2 +- .../config-context-and-suspense-holds.md | 6 +- .changeset/entity-snapshot-fast-path.md | 4 +- .changeset/fast-delivery-prefetch-retain.md | 9 +- .changeset/keep-cached-first-paint.md | 9 +- .changeset/nested-value-reactivity.md | 2 +- .changeset/quiet-reactivation.md | 10 +- .changeset/remove-redundant-entity-saves.md | 4 +- .changeset/should-retry-hook.md | 4 +- .changeset/skip-unchanged-entity-merges.md | 5 +- .changeset/skip-write-and-snapshot-fixes.md | 8 +- .changeset/snapshot-union-array-members.md | 2 +- .changeset/store-reports-deletions.md | 6 +- .changeset/sync-hydrate-first-fetch.md | 5 +- .changeset/sync-store-merge-without-read.md | 2 +- .changeset/upgrade-behavior-changes.md | 27 +++-- docs/src/app/api/fetchium-react/page.md | 14 +-- docs/src/app/api/fetchium/page.md | 100 +++++++++--------- docs/src/app/api/stores-async/page.md | 33 +++--- docs/src/app/api/stores-sync/page.md | 32 +++--- docs/src/app/core/entities/page.md | 2 +- docs/src/app/core/streaming/page.md | 14 +-- docs/src/app/data/caching/page.md | 2 +- docs/src/app/guides/error-handling/page.md | 9 +- docs/src/app/setup/project-setup/page.md | 4 +- 26 files changed, 147 insertions(+), 176 deletions(-) diff --git a/.changeset/activation-timing-fixes.md b/.changeset/activation-timing-fixes.md index 3d344f1..1ff0079 100644 --- a/.changeset/activation-timing-fixes.md +++ b/.changeset/activation-timing-fixes.md @@ -2,8 +2,8 @@ 'fetchium': patch --- -Fixed spurious errors and wrong data when a query mounts, unmounts or changes params quickly. +Fixed errors and wrong data when a query mounts, unmounts or changes params quickly. -- Unmounting and remounting a query, in the same task or mid-fetch (switching a chart timeframe and back), no longer shows an `AbortError`. The query shows its cached data, or stays pending, while it refetches. One exception: a topic query with no data that mounts and unmounts in the same task shows the `AbortError` on its next mount until its data arrives (before, it stayed pending forever). -- Changing a Signal param mid-fetch fetches the new params instead of showing the old params' response. A query whose result is not an entity now caches each params value separately; a cold start could show another params value's data. -- `QueryClient.destroy()` aborts every fetch in flight, and code awaiting one gets an `AbortError`. +- Remounting a query no longer shows an `AbortError`. It shows its cached data, or stays pending, while it refetches. +- Changing a Signal param mid-fetch fetches the new params. Each params value of a non-entity result is cached separately. +- `QueryClient.destroy()` aborts fetches in flight. Queries read afterwards reject with an `AbortError` and send no requests. diff --git a/.changeset/async-store-honors-max-count.md b/.changeset/async-store-honors-max-count.md index 9319171..500c0a3 100644 --- a/.changeset/async-store-honors-max-count.md +++ b/.changeset/async-store-honors-max-count.md @@ -2,4 +2,4 @@ 'fetchium': patch --- -`AsyncQueryStore` now honors a query's `cache.maxCount`, as `SyncQueryStore` does. It used to always keep the default of 50 keys. +`AsyncQueryStore` now honors a query's `cache.maxCount` instead of always keeping 50 entries. diff --git a/.changeset/config-context-and-suspense-holds.md b/.changeset/config-context-and-suspense-holds.md index 947404f..f3f7302 100644 --- a/.changeset/config-context-and-suspense-holds.md +++ b/.changeset/config-context-and-suspense-holds.md @@ -2,6 +2,6 @@ 'fetchium': patch --- -`useSuspenseQuery` no longer refetches in a loop when a slow retry render takes a while to reach the component. The error goes to the error boundary, and resetting the boundary retries. - -Custom `QueryClientConfig` keys still reach queries as `this.context`, except the reserved option names `shouldRetry`, `reactivationGraceMs`, `reactivationStaggerMs`, `activity` and `pollResumeJitterMs`. An invalid value for one of these options is ignored instead of breaking queries. +- Fixed `useSuspenseQuery` refetching in a loop when a retry render is slow. +- A query whose `getConfig()` throws now rejects with that error instead of breaking other queries. +- `shouldRetry`, `reactivationGraceMs`, `reactivationStaggerMs`, `activity` and `pollResumeJitterMs` are reserved `QueryClientConfig` names. Other keys still reach queries as `this.context`. diff --git a/.changeset/entity-snapshot-fast-path.md b/.changeset/entity-snapshot-fast-path.md index 6e25533..2fd63f9 100644 --- a/.changeset/entity-snapshot-fast-path.md +++ b/.changeset/entity-snapshot-fast-path.md @@ -2,6 +2,4 @@ 'fetchium': patch --- -Query results rebuild faster after an update: entities whose data didn't change are not read again. - -List results also keep each row's object identity when the list is inserted into or re-sorted, so memoized rows don't re-render. Development builds still re-read everything to check the result, so the speedup shows in production builds only. +Query results rebuild faster after an update in production builds, and list rows keep their identity when the list is re-sorted or inserted into. diff --git a/.changeset/fast-delivery-prefetch-retain.md b/.changeset/fast-delivery-prefetch-retain.md index 7a5a507..2f3044b 100644 --- a/.changeset/fast-delivery-prefetch-retain.md +++ b/.changeset/fast-delivery-prefetch-retain.md @@ -2,9 +2,6 @@ 'fetchium': minor --- -New `queryClient.prefetch(Query, params, { ttl })` and `queryClient.retain(fn, { ttl })` start queries before any component reads them. A component that mounts while one is held reuses its request and renders the data on its first render if it has arrived. Both return a `release` function; `prefetch` lasts 10 seconds by default and `retain` until released. - -New `useSuspenseQuery` in `fetchium/react`. It suspends only while the query has no data yet, and sends a failed first fetch to the error boundary. - -- A refetch without `debounce` starts on a microtask instead of after `setTimeout(0)`. -- Reading `.value` on a pending or failed query doesn't suspend or throw. The docs said it did. +- New `queryClient.prefetch(Query, params, { ttl })` and `queryClient.retain(fn, { ttl })` start queries before a component reads them. A component that mounts meanwhile reuses the request. +- New `useSuspenseQuery` in `fetchium/react`. It suspends only while the query has no value. +- Refetches without `debounce` start on a microtask instead of after `setTimeout(0)`. diff --git a/.changeset/keep-cached-first-paint.md b/.changeset/keep-cached-first-paint.md index 7069e74..0048df7 100644 --- a/.changeset/keep-cached-first-paint.md +++ b/.changeset/keep-cached-first-paint.md @@ -2,9 +2,8 @@ 'fetchium': patch --- -Cached data now shows on the first render when two entity classes share a typename, such as a list class and a detail class with more fields. Before, the detail query waited for the network if the list had already loaded the entity. +Fixed cached data not showing on first render when two entity classes share a typename, such as a list class and a detail class. -- Writing an entity through one class keeps the other class's fields in the store, so the detail cache survives a restart. Loading the list from the cache now also parses those fields. -- Refetching the list no longer drops entities that only the detail references, such as its nested `t.entity` field. Before, the detail kept the old value, stopped getting updates for it, and its cache failed to load after a restart. This bug was in every earlier version. -- To wipe the cache, call the new `SyncQueryStore.clear()`, not the storage's own clear (such as MMKV `clearAll()`), which can bring deleted fields back. -- Custom stores can add the new optional `QueryStore` methods `readEntity`, `getEntityFieldNames`, `addEntityFieldNames` and `entityFieldNamesComplete`. Without them nothing changes. +- Writes through one class keep the other class's fields, so the cache survives a restart. +- New `SyncQueryStore.clear()`. Use it to wipe the cache instead of the storage's own clear. +- Custom stores can implement the new optional `readEntity`, `getEntityFieldNames`, `addEntityFieldNames` and `entityFieldNamesComplete`. diff --git a/.changeset/nested-value-reactivity.md b/.changeset/nested-value-reactivity.md index cb09f80..0d273e5 100644 --- a/.changeset/nested-value-reactivity.md +++ b/.changeset/nested-value-reactivity.md @@ -2,4 +2,4 @@ 'fetchium': patch --- -Fixed memoized children (`component()` or `React.memo`) showing a stale value when given a nested value such as `token.price` as a prop. A nested `t.object`, `t.record` or live-collection value is now a new object when it changes, and keeps its identity when it doesn't. +Fixed memoized children not updating when passed a nested value such as `token.price`. Nested `t.object`, `t.record` and live-collection values are new objects when they change. diff --git a/.changeset/quiet-reactivation.md b/.changeset/quiet-reactivation.md index 78c626e..37eef21 100644 --- a/.changeset/quiet-reactivation.md +++ b/.changeset/quiet-reactivation.md @@ -2,9 +2,9 @@ 'fetchium': minor --- -New `QueryClient` options that make coming back to a screen, or to the app, quieter. All are off by default. +New `QueryClient` options, all off by default: -- `reactivationGraceMs` (also per query): a query that mounts again with data younger than this doesn't refetch. Data a live subscription kept current counts as fresh. -- `reactivationStaggerMs`: refetches from queries that mount together are spread across this window instead of all starting at once. -- `activity: { isActive(), subscribe(listener) }`: `poll()` stops while the app is inactive and resumes when it's active again. On React Native, wrap `AppState`. `pollResumeJitterMs` spreads out polls that were overdue on resume. -- Pass the topic to `TopicQueryAdapter.sendMutationEvent(event, topic)` so that topic's query counts the push as fresh data. +- `reactivationGraceMs` (also per query): skip the refetch when a query remounts with data younger than this. +- `reactivationStaggerMs`: spread the refetches of queries that remount together across this window. +- `activity` and `pollResumeJitterMs`: pause `poll()` while the app is inactive and spread overdue polls on resume. +- `TopicQueryAdapter.sendMutationEvent(event, topic)` counts the event as fresh data for that topic's query. diff --git a/.changeset/remove-redundant-entity-saves.md b/.changeset/remove-redundant-entity-saves.md index 5f142b2..44049c7 100644 --- a/.changeset/remove-redundant-entity-saves.md +++ b/.changeset/remove-redundant-entity-saves.md @@ -2,6 +2,4 @@ 'fetchium': patch --- -Streamed updates write each changed entity to the store once instead of two or three times, and an update that changes nothing writes nothing. - -A `create` event for an entity that no live collection shows is no longer written to the store, where it was never cleaned up. +Streamed updates write each changed entity once, and unchanged updates write nothing. diff --git a/.changeset/should-retry-hook.md b/.changeset/should-retry-hook.md index be5b0ab..c27ca03 100644 --- a/.changeset/should-retry-hook.md +++ b/.changeset/should-retry-hook.md @@ -2,6 +2,4 @@ 'fetchium': minor --- -New optional `shouldRetry(error, attempt, status)` on `QueryClientConfig` and on a query's or mutation's `retry` config. Return `false` to stop retrying an error you know is permanent, such as a 404. - -`status` is the HTTP status when it is known, and `getErrorStatus` is exported. Without the hook, retries work as before. `retry.retries` is now optional. +New `shouldRetry(error, attempt, status)` option on `QueryClientConfig` and `retry` configs. Return `false` to stop retrying, for example on a 404. `getErrorStatus` is exported and `retry.retries` is now optional. diff --git a/.changeset/skip-unchanged-entity-merges.md b/.changeset/skip-unchanged-entity-merges.md index 388d89f..6a4b1ee 100644 --- a/.changeset/skip-unchanged-entity-merges.md +++ b/.changeset/skip-unchanged-entity-merges.md @@ -2,7 +2,4 @@ 'fetchium': minor --- -A refetch, poll or streamed update that returns the same data no longer re-renders your components. `isPending` and `isFetching` still change as before. - -- Values that can't be compared reliably (a `Date`, a typed array, a class instance) always count as changed. -- `t.record(...)` fields now apply updates. Previously a record field kept its old value. +A refetch, poll or streamed update with identical data no longer re-renders components. `t.record` fields now apply updates. diff --git a/.changeset/skip-write-and-snapshot-fixes.md b/.changeset/skip-write-and-snapshot-fixes.md index 6eb00ef..e957027 100644 --- a/.changeset/skip-write-and-snapshot-fixes.md +++ b/.changeset/skip-write-and-snapshot-fixes.md @@ -2,8 +2,6 @@ 'fetchium': patch --- -Fixed stale data in components when a second query loads entities that are already in memory, and streamed updates that were lost on restart. - -- Development builds freeze query results, so mutating one (such as calling `sort()` on a list in render) throws a `TypeError`. Copy the data first, and treat production results as read-only too. -- In development, the stale-data check logs an error instead of throwing and blocking other components' updates. -- Custom stores can add the new optional `QueryStore.mergeEntity`, so an entity built from streamed updates doesn't overwrite stored fields those updates didn't include. Without it, writes work as before. +- Fixed stale data when a query loads entities already in memory, and streamed updates lost on restart. +- Development builds freeze query results. Copy data before mutating it. +- Custom stores can implement the new optional `QueryStore.mergeEntity`. diff --git a/.changeset/snapshot-union-array-members.md b/.changeset/snapshot-union-array-members.md index 1e4879a..df5530d 100644 --- a/.changeset/snapshot-union-array-members.md +++ b/.changeset/snapshot-union-array-members.md @@ -2,4 +2,4 @@ 'fetchium': patch --- -Fixed components not updating when an entity changes inside a union that has an array or record member, such as `t.union(t.array(t.entity(A)), t.object({ ... }))`. +Fixed components not updating when an entity changes inside a union with an array or record member. diff --git a/.changeset/store-reports-deletions.md b/.changeset/store-reports-deletions.md index b8b3b9d..f8a1f19 100644 --- a/.changeset/store-reports-deletions.md +++ b/.changeset/store-reports-deletions.md @@ -2,7 +2,5 @@ 'fetchium': patch --- -Fixed cached queries failing to load after a restart ("the query cache may be corrupted or invalid") when a query had more than `cache.maxCount` cached params and refetched unchanged data. - -- Custom stores: `QueryStore` has a new optional `onDelete(listener)`. Call the listener with every id the store deletes on its own. A store without it keeps writing every update, as before. -- A `t.array(t.entity(X))` field whose typename two entity classes share now shows an item as soon as it gains the fields `X` requires. +- Fixed cached queries failing to load after a restart when a query had more than `cache.maxCount` cached entries. +- Custom stores can implement the new optional `QueryStore.onDelete(listener)`. diff --git a/.changeset/sync-hydrate-first-fetch.md b/.changeset/sync-hydrate-first-fetch.md index 001b9b8..c93306c 100644 --- a/.changeset/sync-hydrate-first-fetch.md +++ b/.changeset/sync-hydrate-first-fetch.md @@ -2,7 +2,4 @@ 'fetchium': patch --- -With `SyncQueryStore`, a cached query renders its data on the first render instead of showing a loading frame first. - -- The first fetch starts on a microtask instead of after `setTimeout(0)`. Tests that wait for a loading state or count renders may need updating. -- A cached entry that can't be loaded is treated as a cache miss and fetched. Previously a fresh-looking entry could leave the query pending without fetching. +With `SyncQueryStore`, cached data shows on the first render, and the first fetch starts on a microtask. A cache entry that fails to load is refetched. diff --git a/.changeset/sync-store-merge-without-read.md b/.changeset/sync-store-merge-without-read.md index e2d1238..7ab29c0 100644 --- a/.changeset/sync-store-merge-without-read.md +++ b/.changeset/sync-store-merge-without-read.md @@ -2,4 +2,4 @@ 'fetchium': patch --- -Faster streamed updates with `SyncQueryStore` for entities that aren't in memory, which is common when two entity classes share a typename. +Faster streamed updates with `SyncQueryStore` for entities not in memory. diff --git a/.changeset/upgrade-behavior-changes.md b/.changeset/upgrade-behavior-changes.md index 626d5b9..1b18a6f 100644 --- a/.changeset/upgrade-behavior-changes.md +++ b/.changeset/upgrade-behavior-changes.md @@ -2,18 +2,17 @@ 'fetchium': major --- -Breaking changes. Most apps need no code changes, but tests that depend on loading frames, render counts or timers may. +Most apps need no changes. Tests that depend on loading frames, render counts or timers may. -- **Results are frozen in development.** Mutating query data throws a `TypeError`; copy it first (`[...data.items].sort()`). -- **Cached data shows on the first render** with `SyncQueryStore`. Update tests that wait for a loading state or count renders. -- **Fetches start sooner.** The first fetch and refetches without `debounce` start on a microtask, not after `setTimeout(0)`; update tests that advance timers to see them. -- **Identical data doesn't re-render.** A refetch, poll or streamed update with the same data skips the render; read `isFetching` instead of counting renders. -- **Nested values change identity only when they change.** `t.object`, `t.record` and live-collection values are new objects after a change, so memoized children re-render; drop workarounds that forced it. -- **`t.record` and `t.result` fields apply updates** instead of keeping their old value. -- **Changing a Signal param mid-fetch aborts that fetch** and fetches the new params; the old response is dropped. -- **`QueryClient.destroy()` aborts fetches in flight.** Code that awaits a query during teardown gets an `AbortError`; catch it. -- **Awaiting a query can wait for a remount.** If the last component using a query unmounts before its first data arrives, `await fetchQuery(...)` waits for the next mount, `gcTime` collection or `destroy()` instead of rejecting with an `AbortError`. -- **New reserved `QueryClientConfig` names:** `shouldRetry`, `reactivationGraceMs`, `reactivationStaggerMs`, `activity` and `pollResumeJitterMs` are read as options; rename custom context values that use them. -- **`retry: {}` and `retry: { retryDelay }` now retry** like `retry: true`; use `retry: false` for no retries. -- **`AsyncQueryStore` honors `cache.maxCount`.** Check the value if you set one. -- **Custom `QueryStore`s** get new optional methods (`mergeEntity`, `onDelete`, `onPersisted`, `isSettled`, `hasQueuedDeletes`, `hasEntity`); without them nothing changes. +- Query results are frozen in development. Copy data before mutating it. +- With `SyncQueryStore`, cached data shows on the first render. +- The first fetch and refetches without `debounce` start on a microtask, not after `setTimeout(0)`. +- Identical data no longer re-renders. Check `isFetching` instead of counting renders. +- Nested `t.object`, `t.record` and live-collection values are new objects when they change. +- `t.record` and `t.result` fields apply updates. +- Changing a Signal param mid-fetch aborts that fetch. +- After `QueryClient.destroy()`, queries reject with an `AbortError` and send no requests. +- If the last reader unmounts before a query's first data, `await fetchQuery(...)` waits for the next mount, collection or `destroy()`. +- `shouldRetry`, `reactivationGraceMs`, `reactivationStaggerMs`, `activity` and `pollResumeJitterMs` are reserved `QueryClientConfig` names. +- `retry: {}` and `retry: { retryDelay }` now retry like `retry: true`. +- `AsyncQueryStore` honors `cache.maxCount`. diff --git a/docs/src/app/api/fetchium-react/page.md b/docs/src/app/api/fetchium-react/page.md index 3bc22c9..c63ff0a 100644 --- a/docs/src/app/api/fetchium-react/page.md +++ b/docs/src/app/api/fetchium-react/page.md @@ -132,8 +132,8 @@ const SearchResults = component(() => { - `useQuery` is a thin wrapper around Signalium v3's `useReactive`, which is deep-by-default. It returns a **structurally-shared snapshot** of the query result, so memoized children that receive subtrees as props keep stable references when the underlying data is unchanged. - Fetchium registers a custom snapshot for entity proxies so the snapshot walks into entities (instead of returning them by reference), giving you correct re-rendering on entity field changes. -- The snapshot is a plain-object copy of the query result to prevent accidental mutation of the entity cache. Treat it as read-only: in development builds the snapshot and every nested object and array in it are frozen, so an in-place `sort()`, a `push()` or an assignment in render throws a `TypeError` at that line. Production snapshots are not frozen, and a mutation there is carried forward until the entity changes. Sort a copy (`[...items].sort()`) or use `draft()` from `fetchium` if you need a mutable copy for mutations. -- `useQuery` never suspends. Reading `.value` while pending returns `undefined`. Use [`useSuspenseQuery`](#usesuspensequery) to suspend on a cold miss. +- The snapshot is a read-only copy of the query result. Development builds freeze it, so mutating it throws a `TypeError`. Sort a copy (`[...items].sort()`) or use `draft()` from `fetchium` when you need a mutable copy. +- `useQuery` never suspends: `.value` is `undefined` until the first value arrives. Use [`useSuspenseQuery`](#usesuspensequery) to suspend. --- @@ -146,13 +146,9 @@ function useSuspenseQuery( ): ReadyReactivePromise>; ``` -`useQuery` for components under a React `` boundary. It suspends only on a **cold miss**: the query has never produced a value, neither in memory nor, with a synchronous store, in the persisted cache. Anything else renders at once, including a stale value whose refetch is in flight. Refetches never suspend. Because the hook only returns once a value exists, `value` is always defined. +Like `useQuery`, but suspends while the query has no value yet. Refetches don't suspend, so `value` is always defined. A failed first fetch is thrown to the nearest error boundary, and resetting the boundary retries it. -A cold fetch that fails is thrown to the nearest error boundary. Resetting the boundary (remounting the component) tries the fetch again. - -Changing params to ones with no value yet is a cold miss, so the component suspends. Wrap the change in `startTransition` to keep showing the previous result. - -Call it from a plain function component. Like `useQuery`, it cannot run inside a Signalium `component()`, which is a reactive context. +Changing params to ones with no value yet suspends again. Wrap the change in `startTransition` to keep showing the previous result. ```tsx import { Suspense } from 'react'; @@ -167,5 +163,3 @@ function UserName({ id }: { id: number }) { ; ``` - -While suspended, the client keeps the query active, since React discards a suspended render without subscribing to anything. The component's commit takes over. diff --git a/docs/src/app/api/fetchium/page.md b/docs/src/app/api/fetchium/page.md index 824ffde..8799a5b 100644 --- a/docs/src/app/api/fetchium/page.md +++ b/docs/src/app/api/fetchium/page.md @@ -225,38 +225,38 @@ Central coordinator for queries, mutations, entity storage, caching, and garbage new QueryClient(config: QueryClientConfig) ``` -| Field | Type | Default | Description | -| ----------------------- | ------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `store` | `QueryStore` | In-memory | Persistent storage backend. Defaults to `SyncQueryStore(MemoryPersistentStore)`. | -| `adapters` | `QueryAdapter[]` | `[]` | Transport adapters (e.g. `new RESTQueryAdapter({ fetch, baseUrl })`). | -| `log` | `LogContext \| undefined` | `console` | Logger with `error`, `warn`, `info`, `debug` methods. | -| `evictionMultiplier` | `number \| undefined` | `1` | Scales all GC times for testing. Set to `0.001` to make timers fire in milliseconds. | -| `networkManager` | `NetworkManager` | `new NetworkManager()` | Tracks network connectivity. | -| `gcManager` | `GcManager \| NoOpGcManager` | Auto-detected | GC manager. Uses `NoOpGcManager` on the server. | -| `reactivationGraceMs` | `number` | `0` | Milliseconds. A query that reactivates (a watcher returns, or a paused scope resumes) with data younger than this is not refetched, even if stale. Data a subscription pushed to (a `subscribe` stream event, or a topic event sent with its topic) counts as fresh from the last push; a running `poll()` counts through the fetches it makes. Network reconnects, `__refetch()` and invalidation still refetch. `Infinity` never refetches on reactivation. Per-query override: `reactivationGraceMs` in the query config. | -| `reactivationStaggerMs` | `number` | `0` | Milliseconds. Reactivation refetches that start in the same task are spread evenly across this window, in activation order, instead of all starting at once. A value that is not a finite positive number counts as 0. | -| `shouldRetry` | `(error, attempt, status) => boolean` | — | Optional. Whether a failed attempt is retried. Without it, every failed attempt is retried, whatever its status. `attempt` starts at 0; `status` is the attempt's HTTP status when known (the adapter's error response, or `status` / `statusCode` / `response.status` on the error). Return `false` to stop retrying errors you know are permanent, such as a 4xx. Per-query override: `retry.shouldRetry`. | -| `activity` | `ActivitySource \| undefined` | — | `{ isActive(), subscribe(listener) }` foreground source. When set, `poll()` stops its timers while inactive and resumes them on activation. A value without `isActive` and `subscribe` functions is ignored, with a warning in development. | -| `pollResumeJitterMs` | `number` | `0` | Milliseconds. A `poll()` tick that is overdue on resume (or whose timer fired over a second late, as after JS timers were frozen in the background) is rescheduled at a random point in this window instead of firing at once. A value that is not a finite positive number counts as 0. Per-poll override: `poll({ interval, resumeJitterMs })`. | - -Every config key, including keys this table does not list, also reaches query and mutation code as `this.context`, so you can pass your own services through the config. The names in the table are reserved: a value under one of them is read as that option, so do not reuse them for your own context values. +| Field | Type | Default | Description | +| ----------------------- | ------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `store` | `QueryStore` | In-memory | Persistent storage backend. Defaults to `SyncQueryStore(MemoryPersistentStore)`. | +| `adapters` | `QueryAdapter[]` | `[]` | Transport adapters (e.g. `new RESTQueryAdapter({ fetch, baseUrl })`). | +| `log` | `LogContext \| undefined` | `console` | Logger with `error`, `warn`, `info`, `debug` methods. | +| `evictionMultiplier` | `number \| undefined` | `1` | Scales all GC times for testing. Set to `0.001` to make timers fire in milliseconds. | +| `networkManager` | `NetworkManager` | `new NetworkManager()` | Tracks network connectivity. | +| `gcManager` | `GcManager \| NoOpGcManager` | Auto-detected | GC manager. Uses `NoOpGcManager` on the server. | +| `reactivationGraceMs` | `number` | `0` | Milliseconds. A query that remounts with data younger than this doesn't refetch. Data a subscription pushed counts as fresh. Reconnects, `__refetch()` and invalidation still refetch. `Infinity` never refetches. Per query: `reactivationGraceMs`. | +| `reactivationStaggerMs` | `number` | `0` | Milliseconds. Spreads the refetches of queries that remount together across this window. | +| `shouldRetry` | `(error, attempt, status) => boolean` | — | Return `false` to stop retrying an error, such as a 404. `status` is the HTTP status when known. Per query: `retry.shouldRetry`. | +| `activity` | `ActivitySource \| undefined` | — | `{ isActive(), subscribe(listener) }`. `poll()` pauses while inactive. | +| `pollResumeJitterMs` | `number` | `0` | Milliseconds. Spreads polls that are overdue on resume across this window. Per poll: `poll({ interval, resumeJitterMs })`. | + +Other config keys reach queries and mutations as `this.context`. Don't reuse the option names above for your own values. #### Methods -| Method | Signature | Description | -| -------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `getContext` | `(): QueryContext` | Returns the `QueryContext` passed at construction. | -| `applyMutationEvent` | `(event: MutationEvent): void` | Applies an external mutation event (create/update/delete) to the entity store. | -| `invalidateQueries` | `(targets: ReadonlyArray): void` | Marks matching query instances as stale. Accepts query classes and optional param subsets for filtering. | -| `prefetch` | `(QueryClass, params?, { ttl? }?): () => void` | Starts a query now and keeps it active for `ttl` ms (default `DEFAULT_PREFETCH_TTL`, 10 s). See below. | -| `retain` | `(fn: () => unknown, { ttl? }?): () => void` | Keeps the queries `fn` reads active until the returned `release` is called, or for `ttl` ms. See below. | -| `destroy` | `(): void` | Tears down the GC manager, network manager, and all caches, releases outstanding leases, and unsubscribes the store listeners it registered. | +| Method | Signature | Description | +| -------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `getContext` | `(): QueryContext` | Returns the `QueryContext` passed at construction. | +| `applyMutationEvent` | `(event: MutationEvent): void` | Applies an external mutation event (create/update/delete) to the entity store. | +| `invalidateQueries` | `(targets: ReadonlyArray): void` | Marks matching query instances as stale. Accepts query classes and optional param subsets for filtering. | +| `prefetch` | `(QueryClass, params?, { ttl? }?): () => void` | Starts a query and keeps it active for `ttl` ms (default 10 s). | +| `retain` | `(fn: () => unknown, { ttl? }?): () => void` | Keeps the queries `fn` reads active until `release()` is called, or for `ttl` ms. | +| `destroy` | `(): void` | Aborts fetches in flight and releases everything the client holds. Queries read afterwards reject with an `AbortError`. | #### `prefetch` and `retain` -Both take a lease: the queries stay active (fetched, subscribed, exempt from GC) without a component reading them. A reader that mounts while the lease is held joins the active query, so it starts no request of its own and renders the data on its first render if it has arrived. Both return an idempotent `release` function. Releasing never interrupts other readers; once the last one goes, the query deactivates and its `gcTime` starts as usual. Calling `release` while a fetch is in flight, with no reader, aborts that fetch. A `ttl` is an upper bound: the lease goes when it runs out even if the fetch is still in flight (offline, or a topic never fulfilled), which aborts the fetch unless a reader has joined it. Call `retain` and `prefetch` from event handlers or effects, not from inside a reactive computation; called from one, they warn in development and keep only the leases from the computation's latest run. +Both keep queries active without a component reading them and return a `release` function. A component that mounts meanwhile reuses the running request. Call them from event handlers or effects, not during render. -`prefetch` is for the moment a user commits to a navigation, such as a tap. Call it in the handler, before navigating: +Use `prefetch` when the user commits to a navigation: ```ts function onPressToken(id: string) { @@ -265,21 +265,17 @@ function onPressToken(id: string) { } ``` -With a synchronous store, a cached result is applied inside the call. A fresh one makes no request, and a stale one is refetched, as on any activation. - -`retain` keeps a set of queries warm, for example the queries of a screen that is hidden but likely to come back. `fn` runs immediately, with the client as `QueryClientContext`, and again when what it reads changes. It holds a query when it reads one of the query's fields, or when it returns the query's promise or an array of them: +Use `retain` to keep a screen's queries warm. `fn` reruns when what it reads changes, and the lease lasts until `release()` unless you pass a `ttl`: ```ts const release = queryClient.retain(() => [ fetchQuery(GetPortfolio), fetchQuery(GetPrices, { ids }), ]); -// when the surface is gone for good: +// later release(); ``` -Without a `ttl`, a `retain` lease lasts until `release` is called. - --- ### `QueryAdapter` (abstract) @@ -300,9 +296,9 @@ Base class for transport adapters. An adapter handles sending queries and mutati #### Properties -| Property | Type | Description | -| ------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `coalescesRequests` | `boolean \| undefined` | Optional. Set when the adapter merges requests sent close together. `reactivationStaggerMs` then starts its queries' reactivation refetches together, unspread. | +| Property | Type | Description | +| ------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `coalescesRequests` | `boolean \| undefined` | Optional. Set if the adapter batches requests sent close together. Its queries' refetches are then not staggered. | #### Protected properties @@ -654,16 +650,16 @@ The `t` object provides a declarative type definition DSL for describing query p #### `QueryConfigOptions` properties -| Property | Type | Default | Description | -| ------------------------- | ---------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `gcTime` | `number` | `5` | In-memory eviction time in minutes. `0` = next-tick, `Infinity` = never. | -| `staleTime` | `number` | `0` | Milliseconds data is considered fresh. `0` = always stale. | -| `debounce` | `number` | `0` | Milliseconds to debounce refetches. At `0`, a refetch starts on a microtask (invalidation: on the next task). | -| `networkMode` | `NetworkMode` | `NetworkMode.Online` | When to allow fetching. | -| `retry` | `RetryConfig \| number \| boolean` | `3` (client) / `0` (server) | Retry configuration. | -| `refreshStaleOnReconnect` | `boolean` | `true` | Whether to refetch stale queries when network reconnects. | -| `reactivationGraceMs` | `number` | client value (`0`) | Milliseconds. Skip the reactivation refetch while data is younger than this. Overrides the client's `reactivationGraceMs`. | -| `subscribe` | `(onEvent) => () => void` | — | Subscribe to external events that should trigger refetches. | +| Property | Type | Default | Description | +| ------------------------- | ---------------------------------- | --------------------------- | ------------------------------------------------------------------------ | +| `gcTime` | `number` | `5` | In-memory eviction time in minutes. `0` = next-tick, `Infinity` = never. | +| `staleTime` | `number` | `0` | Milliseconds data is considered fresh. `0` = always stale. | +| `debounce` | `number` | `0` | Milliseconds to debounce refetches. | +| `networkMode` | `NetworkMode` | `NetworkMode.Online` | When to allow fetching. | +| `retry` | `RetryConfig \| number \| boolean` | `3` (client) / `0` (server) | Retry configuration. | +| `refreshStaleOnReconnect` | `boolean` | `true` | Whether to refetch stale queries when network reconnects. | +| `reactivationGraceMs` | `number` | client value (`0`) | Milliseconds. Overrides the client's `reactivationGraceMs`. | +| `subscribe` | `(onEvent) => () => void` | — | Subscribe to external events that should trigger refetches. | ### Mutation types @@ -693,9 +689,9 @@ The `t` object provides a declarative type definition DSL for describing query p ### Retry types -| Type | Definition | Description | -| ------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | -| `RetryConfig` | `{ retries?: number; retryDelay?: (attemptIndex: number) => number; shouldRetry?: (error, attempt, status) => boolean }` | Retry configuration. Default delay: exponential backoff (`1000ms * 2^attempt`). `shouldRetry` overrides the client's `shouldRetry`. | +| Type | Definition | Description | +| ------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| `RetryConfig` | `{ retries?: number; retryDelay?: (attemptIndex: number) => number; shouldRetry?: (error, attempt, status) => boolean }` | Default delay: exponential backoff (`1000ms * 2^attempt`). `shouldRetry` overrides the client's. | ### Utility types @@ -712,8 +708,8 @@ The `t` object provides a declarative type definition DSL for describing query p ### Store types -| Type | Definition | Description | -| -------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `QueryStore` | Interface | Storage backend interface. See [stores/sync](/api/stores-sync) and [stores/async](/api/stores-async). Besides the required methods, a store may implement `onDelete` (the client then skips writing entities whose records are current, and writes one again once the store reports its record dropped), `onPersisted` (for a store that applies writes later), `isSettled`, `hasEntity` (lets a streamed event refresh the record of an entity that is not in memory) and `mergeEntity` (merges the fields streamed events supplied for an entity built from them over its record; without it, such an entity is written whole through `saveEntity`). A store without `onDelete` is written on every apply, as before 0.6; when `hasEntity` is missing or answers `undefined`, an event for an entity that is not in memory and that no live array retains is written only as a `mergeEntity` call with `ifStored`, which writes nothing when the store holds no record, and not at all without `mergeEntity`. | -| `CachedQuery` | `{ value: unknown; refIds: Set \| undefined; updatedAt: number; preloadedEntities?: PreloadedEntityMap }` | Cached query data returned by `QueryStore.loadQuery()`. | -| `PreloadedEntityMap` | `Map>` | Pre-loaded entity data for hydration. | +| Type | Definition | Description | +| -------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `QueryStore` | Interface | Storage backend interface. See [stores/sync](/api/stores-sync) and [stores/async](/api/stores-async). | +| `CachedQuery` | `{ value: unknown; refIds: Set \| undefined; updatedAt: number; preloadedEntities?: PreloadedEntityMap }` | Cached query data returned by `QueryStore.loadQuery()`. | +| `PreloadedEntityMap` | `Map>` | Pre-loaded entity data for hydration. | diff --git a/docs/src/app/api/stores-async/page.md b/docs/src/app/api/stores-async/page.md index ab56842..74d907c 100644 --- a/docs/src/app/api/stores-async/page.md +++ b/docs/src/app/api/stores-async/page.md @@ -41,19 +41,19 @@ new AsyncQueryStore(config: AsyncQueryStoreConfig) #### Methods -| Method | Signature | Description | -| ------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): Promise` | Loads a cached query. Only works when a `delegate` is available (writer mode). Returns `undefined` if no delegate, if the entry has expired, or if it does not exist. Preloads referenced entities. | -| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Dispatches a save-query message. In writer mode, enqueues for serial processing. In reader mode, sends via `sendMessage`. | -| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set): void` | Dispatches a save-entity message. | -| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set, ifStored?: boolean): void` | Dispatches a save-entity message with `merge: true`; the writer merges `fields` over the stored record, if there is one (see `SyncQueryStore.mergeEntity`). With `ifStored` and no stored record, the writer writes nothing and reports the key to `onDelete` listeners. | -| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Dispatches an activate-query message to update the LRU queue. A queue persisted with a different `maxCount` is resized by the writer: growing reallocates it, shrinking evicts the keys that no longer fit. | -| `deleteQuery` | `(queryKey: number): void` | Dispatches a delete-query message. | -| `purgeStaleQueries` | `(): Promise` | Scans all stored query classes and removes expired entries. In writer mode it runs through the same serial queue as the messages, after everything queued before it; a reader with its own `delegate` runs it directly. Only operates when a `delegate` is available. | -| `onDelete` | `(listener: (key: number) => void): () => void` | Writer mode only. Registers a listener called with the key of every record the writer drops on its own (an LRU eviction, a reference-count cascade, a purge) or fails to write. Returns an unsubscribe function. | -| `onPersisted` | `(listener: (key: number) => void): () => void` | Writer mode only. Registers a listener called with the key of every entity record a queued write has stored. Returns an unsubscribe function. | -| `isSettled` | `(): boolean` | Whether every operation handed to the writer has been applied (nothing queued, nothing mid-flight). Always `true` in reader mode. | -| `hasEntity` | `(key: number): boolean \| undefined` | Writer mode only. Whether the delegate holds a record for the key, answered from a set the writer reads from the delegate when it starts (alongside its queue, not ahead of it) and keeps current with every write it queues and every deletion it performs. `undefined` for a key it has not written itself until that read lands; after a read that keeps failing, nothing is assumed held. An event for an entity not in memory is sent as an `ifStored` merge while the answer is `undefined`, and always from a reader. | +| Method | Signature | Description | +| ------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): Promise` | Loads a cached query. Only works when a `delegate` is available (writer mode). Returns `undefined` if no delegate, if the entry has expired, or if it does not exist. Preloads referenced entities. | +| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Dispatches a save-query message. In writer mode, enqueues for serial processing. In reader mode, sends via `sendMessage`. | +| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set, rest?: string, ownedKeys?: string[]): void` | Dispatches a save-entity message. | +| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set, ifStored?: boolean): void` | Dispatches a merge of `fields` over the stored record. With `ifStored`, the writer skips it when no record is stored. | +| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Dispatches an activate-query message to update the LRU queue. | +| `deleteQuery` | `(queryKey: number): void` | Dispatches a delete-query message. | +| `purgeStaleQueries` | `(): Promise` | Removes expired entries. Requires a `delegate`. | +| `onDelete` | `(listener: (key: number) => void): () => void` | Writer only. Calls `listener` with each record the writer deletes or fails to write. Returns an unsubscribe function. | +| `onPersisted` | `(listener: (key: number) => void): () => void` | Writer only. Calls `listener` with each entity record the writer stores. Returns an unsubscribe function. | +| `isSettled` | `(): boolean` | Whether the writer has applied everything queued. Always `true` for a reader. | +| `hasEntity` | `(key: number): boolean \| undefined` | Writer only. Whether a record is stored for `key`, or `undefined` until the writer has read its keys. | --- @@ -131,7 +131,7 @@ type StoreMessage = value: unknown; updatedAt: number; cacheTime: number; - maxCount?: number; // absent from readers built before 0.6; the writer then keeps the queue's size + maxCount?: number; refIds?: number[]; } | { @@ -139,7 +139,10 @@ type StoreMessage = entityKey: number; value: unknown; refIds?: number[]; - merge?: boolean; // `value` holds only some fields: merge them over the stored record + merge?: boolean; // merge `value` over the stored record + ifStored?: boolean; // with `merge`: skip if no record is stored + rest?: string; // stored fields to keep + ownedKeys?: string[]; // keep stored fields outside these keys } | { type: 2; // ActivateQuery diff --git a/docs/src/app/api/stores-sync/page.md b/docs/src/app/api/stores-sync/page.md index f43cdda..9e29c7f 100644 --- a/docs/src/app/api/stores-sync/page.md +++ b/docs/src/app/api/stores-sync/page.md @@ -32,22 +32,22 @@ new SyncQueryStore(kv: SyncPersistentStore) #### Methods -| Method | Signature | Description | -| -------------------------- | -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): CachedQuery \| undefined` | Loads a cached query by key. Returns `undefined` if the cache entry has expired (beyond `cacheTime`) or does not exist. Also preloads referenced entities. | -| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Persists a query result, its timestamp, and entity reference IDs. Activates the query in the LRU queue. | -| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set, rest?: string): void` | Persists an entity's serialized data and its child entity references. Manages reference counts for nested entities. `rest`, when given, is a JSON object body of fields the record already held that no class applied to the in-memory entity declares; it is written with the value so the write does not delete them. | -| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set): void` | Writes the fields streamed events supplied for an entity built from them: merged over the stored record when there is one (the record's references are then derived from the merged value), written as the record otherwise. A record that cannot be merged into is replaced. | -| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Moves a query to the front of the LRU queue for its query class. If the queue is full, the oldest entry is evicted. A queue persisted with a different `maxCount` is resized: growing reallocates it, shrinking evicts the keys that no longer fit. | -| `deleteQuery` | `(queryKey: number): void` | Deletes a query's stored value, reference IDs, and decrements reference counts for all referenced entities. | -| `purgeStaleQueries` | `(): void` | Scans all query classes and removes those whose `lastUsedAt` timestamp exceeds their `cacheTime`. Called automatically on `QueryClient` construction. | -| `onDelete` | `(listener: (key: number) => void): () => void` | Registers a listener called with the key of every record the store drops on its own (an LRU eviction, the reference-count cascade that follows it, a purge). Listeners are shared by every store over the same `kv`. Returns an unsubscribe function; `QueryClient.destroy()` calls it. | -| `hasEntity` | `(key: number): boolean` | Whether the store holds a record for the key. The client refreshes the record of an entity a streamed event updates while the entity is not in memory only when this answers `true`. | -| `readEntity` | `(entityKey: number): Record \| undefined` | The stored record of an entity, parsed, or `undefined`. The client reads it once, before the first write of an entity whose record may hold fields of another class of its typename, so that write keeps them. | -| `getEntityFieldNames` | `(typename: string): readonly string[] \| undefined` | The top-level field names the entity classes of `typename` declared, in this session or an earlier one (stored under `sq:meta:fields:`). A name no class has declared for 30 days is forgotten. | -| `addEntityFieldNames` | `(typename: string, fields: readonly string[]): void` | Records that a class of `typename` declares `fields`. Called once per class and client; writes only when a name is new or its time is over a day old. | -| `entityFieldNamesComplete` | `(): boolean` | Whether the remembered field names cover every record in the store. `false` for 30 days after this version first opens the store, since records an earlier version wrote may hold fields of a class not registered since. `true` after `clear()`. While `false`, an entity built from streamed events is merged into its stored record instead of replacing it. | -| `clear` | `(): void` | Deletes every cached query, entity record and queue (`sq:doc:` keys) and reports each record to `onDelete` listeners, so a client over the store drops the fields it kept from those records. Wipe the cache with this, not on the `kv` directly: a client is not told about records deleted under it, and its next write of an entity of a typename two classes share puts the other class's deleted fields back. Field names (`sq:meta:`) are kept. | +| Method | Signature | Description | +| -------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): CachedQuery \| undefined` | Loads a cached query by key. Returns `undefined` if the cache entry has expired (beyond `cacheTime`) or does not exist. Also preloads referenced entities. | +| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Persists a query result, its timestamp, and entity reference IDs. Activates the query in the LRU queue. | +| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set, rest?: string): void` | Persists an entity and its child references. `rest` holds stored fields to keep that the entity's class doesn't declare. | +| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set): void` | Merges `fields` over the stored record, or writes them as the record if none is stored. | +| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Moves a query to the front of its class's LRU queue, evicting the oldest entry when full. | +| `deleteQuery` | `(queryKey: number): void` | Deletes a query's stored value, reference IDs, and decrements reference counts for all referenced entities. | +| `purgeStaleQueries` | `(): void` | Scans all query classes and removes those whose `lastUsedAt` timestamp exceeds their `cacheTime`. Called automatically on `QueryClient` construction. | +| `onDelete` | `(listener: (key: number) => void): () => void` | Calls `listener` with each record the store deletes on its own (eviction, cascade, purge). Returns an unsubscribe function. | +| `hasEntity` | `(key: number): boolean` | Whether a record is stored for `key`. | +| `readEntity` | `(entityKey: number): Record \| undefined` | The stored record for `entityKey`, parsed, or `undefined`. | +| `getEntityFieldNames` | `(typename: string): readonly string[] \| undefined` | Field names the classes of `typename` declared in the last 30 days. | +| `addEntityFieldNames` | `(typename: string, fields: readonly string[]): void` | Records the field names a class of `typename` declares. | +| `entityFieldNamesComplete` | `(): boolean` | Whether the remembered field names cover every stored record. `false` for 30 days after upgrading, `true` after `clear()`. | +| `clear` | `(): void` | Deletes all cached queries and records and reports them to `onDelete`. Use it instead of clearing the `kv` directly. | --- diff --git a/docs/src/app/core/entities/page.md b/docs/src/app/core/entities/page.md index 11745ae..71b430b 100644 --- a/docs/src/app/core/entities/page.md +++ b/docs/src/app/core/entities/page.md @@ -58,7 +58,7 @@ class GetPost extends RESTQuery { ``` {% callout type="warning" %} -Entities are **read-only**, and so are the snapshots `useQuery` and `useReactive` take of them. Attempting to set a property on an entity, or on a snapshot in a development build, will throw an error. To update entity data, use mutations or streaming updates. +Entities are **read-only**, and so are the snapshots `useQuery` takes of them. Setting a property throws (on snapshots, in development). Use mutations or streaming updates to change entity data. {% /callout %} --- diff --git a/docs/src/app/core/streaming/page.md b/docs/src/app/core/streaming/page.md index 41ca8da..6021133 100644 --- a/docs/src/app/core/streaming/page.md +++ b/docs/src/app/core/streaming/page.md @@ -207,13 +207,13 @@ class MyStreamAdapter extends TopicQueryAdapter { The adapter has several protected helper methods: -| Method | Description | -| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `fulfillTopic(topic, data)` | Resolve the query with initial data. Can be called before or after the query activates. | -| `rejectTopic(topic, error)` | Reject the query with an error. Can be called before or after the query activates. | -| `sendMutationEvent(event, topic?)` | Push a `MutationEvent` through Fetchium's entity event system. Pass the `topic` it arrived on so that topic's query counts as current (see `reactivationGraceMs`). | -| `clearTopic(topic)` | Clear buffered state for a topic. Call this in `unsubscribe` to reset for the next subscription cycle. | -| `clearAll()` | Clear all buffered topic state. Useful when resetting the connection. | +| Method | Description | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `fulfillTopic(topic, data)` | Resolve the query with initial data. Can be called before or after the query activates. | +| `rejectTopic(topic, error)` | Reject the query with an error. Can be called before or after the query activates. | +| `sendMutationEvent(event, topic?)` | Push a `MutationEvent` through Fetchium's entity event system. Pass `topic` so its query counts the event as fresh data. | +| `clearTopic(topic)` | Clear buffered state for a topic. Call this in `unsubscribe` to reset for the next subscription cycle. | +| `clearAll()` | Clear all buffered topic state. Useful when resetting the connection. | ### Registering the adapter diff --git a/docs/src/app/data/caching/page.md b/docs/src/app/data/caching/page.md index c3582a0..feacbd1 100644 --- a/docs/src/app/data/caching/page.md +++ b/docs/src/app/data/caching/page.md @@ -133,7 +133,7 @@ class GetUser extends RESTQuery { } ``` -`maxCount` limits how many distinct parameter combinations are stored for this query class. When the limit is exceeded, the oldest entries are evicted. The default is `50`. Changing `maxCount` between releases resizes the queue already persisted for the class the first time the store touches that class after starting: growing keeps every entry, shrinking evicts the oldest entries that no longer fit. +`maxCount` limits how many distinct parameter combinations are stored for this query class. When the limit is exceeded, the oldest entries are evicted. The default is `50`. --- diff --git a/docs/src/app/guides/error-handling/page.md b/docs/src/app/guides/error-handling/page.md index c44362b..8eeb44e 100644 --- a/docs/src/app/guides/error-handling/page.md +++ b/docs/src/app/guides/error-handling/page.md @@ -147,7 +147,7 @@ class GetUser extends RESTQuery { The default retry delay uses exponential backoff --- each successive attempt waits longer than the last (roughly 1s, 2s, 4s, ...). This avoids hammering a struggling server with rapid retries. -Every failed attempt is retried, whatever its status, including `4xx` responses. When you know an error is permanent, opt out with `shouldRetry`, either per query in `retry` or for every query on the `QueryClient`. It receives the error, the attempt index and the attempt's HTTP status when known: the response the adapter received (a REST error response whose body doesn't match `result`), or `status`, `statusCode` or `response.status` on the thrown error. A query's `shouldRetry` overrides the client's. +Every failed attempt is retried, including `4xx` responses. Use `shouldRetry` to stop retrying errors you know are permanent. It gets the error, the attempt index and the HTTP status when known. Set it per query in `retry`, or for every query on the `QueryClient`. ```ts class GetUser extends RESTQuery { @@ -161,7 +161,7 @@ class GetUser extends RESTQuery { } ``` -`getErrorStatus(error)` reads the status from an error, if you need it elsewhere. +`getErrorStatus(error)` reads the status from an error. To disable retries entirely: @@ -311,7 +311,7 @@ if (result.isRejected) { ### Opting into throw behavior -If you _want_ error boundaries to catch query failures --- for example, when using React Suspense or when you prefer a centralized error UI --- throw the error yourself. Reading `.value` does not throw: on a rejected query it holds the last successful value, or `undefined` if there never was one. +To let an error boundary handle query failures, throw the error yourself. Reading `.value` doesn't throw. ```tsx import { ErrorBoundary } from 'react-error-boundary'; @@ -319,7 +319,6 @@ import { ErrorBoundary } from 'react-error-boundary'; function UserProfile({ userId }: { userId: number }) { const result = useQuery(GetUser, { id: userId }); - // Hand a failure with nothing to show to the ErrorBoundary above if (result.isRejected && !result.isReady) throw result.error; if (!result.isReady) return ; @@ -339,7 +338,7 @@ function App() { } ``` -`useSuspenseQuery` from `fetchium/react` does this for you: it suspends while a query with no value loads, and throws a failed cold fetch to the error boundary. +`useSuspenseQuery` does this for you when the query has no value yet. This is a conscious opt-in. The default explicit-checking pattern (`isRejected` + `error`) is recommended for most use cases because it gives you the most flexibility. Error boundaries are best reserved for catching truly unexpected failures that shouldn't be handled inline. diff --git a/docs/src/app/setup/project-setup/page.md b/docs/src/app/setup/project-setup/page.md index b7ca2b8..7e2761b 100644 --- a/docs/src/app/setup/project-setup/page.md +++ b/docs/src/app/setup/project-setup/page.md @@ -289,8 +289,8 @@ Fetchium uses a compile-time constant `IS_DEV` to enable additional runtime chec - **Parse validation warnings** --- detailed logs when response data doesn't match your type definitions - **Entity write protection** --- throws when you accidentally try to mutate an entity property directly (entities are read-only) -- **Frozen snapshots** --- the snapshots `useQuery` and `useReactive` hand out are frozen, so mutating one (an in-place `sort()`, an assignment in render) throws a `TypeError` at the offending line instead of silently corrupting what the next render shows -- **Stale-snapshot detection** --- a snapshot taken at an unchanged entity version re-reads only the fields whose values live elsewhere; development builds re-read the rest too and, if one changed, serve the re-read value, log the discrepancy through the client's `log.error`, and raise it once per typename and field as an uncaught error +- **Frozen snapshots** --- mutating a snapshot from `useQuery` throws a `TypeError` +- **Stale-snapshot detection** --- logs an error when a snapshot misses an entity change - **Reference validation** --- catches common mistakes like using conditional logic in class field definitions In production builds, all `IS_DEV` code paths are tree-shaken, so there is zero runtime cost. From 5416b42a9609834fca3807e1cc251ce09d27d158 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 20:20:40 -0400 Subject: [PATCH 2/2] docs(fetchium): note the async store limit in the #75 changeset Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/keep-cached-first-paint.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/keep-cached-first-paint.md b/.changeset/keep-cached-first-paint.md index 0048df7..c0856f6 100644 --- a/.changeset/keep-cached-first-paint.md +++ b/.changeset/keep-cached-first-paint.md @@ -4,6 +4,6 @@ Fixed cached data not showing on first render when two entity classes share a typename, such as a list class and a detail class. -- Writes through one class keep the other class's fields, so the cache survives a restart. +- Writes through one class keep the other class's fields, so the cache survives a restart. With `AsyncQueryStore`, only for classes registered in the session. - New `SyncQueryStore.clear()`. Use it to wipe the cache instead of the storage's own clear. - Custom stores can implement the new optional `readEntity`, `getEntityFieldNames`, `addEntityFieldNames` and `entityFieldNamesComplete`.