diff --git a/.changeset/activation-timing-fixes.md b/.changeset/activation-timing-fixes.md
index 3d344f1..e73f8cf 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 after an aborted fetch shows its cached data, or stays pending, instead of an `AbortError`. A topic query with no data that mounts and unmounts in the same task is the exception: it shows the `AbortError` until its data arrives, and its first awaiter rejects.
+- 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
new file mode 100644
index 0000000..7069e74
--- /dev/null
+++ b/.changeset/keep-cached-first-paint.md
@@ -0,0 +1,10 @@
+---
+'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.
+
+- 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.
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..c2a13c9 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()`. A topic query mounted and unmounted in the same task still rejects.
+- `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..a636cbc 100644
--- a/docs/src/app/api/fetchium-react/page.md
+++ b/docs/src/app/api/fetchium-react/page.md
@@ -7,6 +7,19 @@ description: API reference for the fetchium React integration.
React hooks for using Fetchium queries in React components. Built on top of Signalium's `useReactive` hook.
+Use these hooks in ordinary React function components. Inside a Signalium `component()`, which is already reactive, call `fetchQuery` directly instead:
+
+```tsx
+import { component } from 'signalium/react';
+import { fetchQuery } from 'fetchium';
+
+const UserList = component(() => {
+ const query = fetchQuery(GetUsers);
+ if (!query.isReady) return
Loading...
;
+ return {query.value.total} users
;
+});
+```
+
```ts
import { useQuery, useSuspenseQuery } from 'fetchium/react';
```
@@ -60,12 +73,11 @@ The resolved `QueryResult` includes pagination helpers:
#### Requirements
- A `QueryClient` must be provided via `QueryClientContext` using Signalium's `ContextProvider`.
-- The component must be wrapped in a Signalium `component()` or use `useReactive` for the reactive system to function.
+- Call it from an ordinary React function component, not inside a Signalium `component()`.
#### Example
```tsx
-import { component } from 'signalium/react';
import { useQuery } from 'fetchium/react';
class GetUsers extends RESTQuery {
@@ -77,7 +89,7 @@ class GetUsers extends RESTQuery {
};
}
-const UserList = component(() => {
+function UserList() {
const query = useQuery(GetUsers);
if (query.isPending) {
@@ -100,17 +112,18 @@ const UserList = component(() => {
);
-});
+}
```
#### With parameters
```tsx
-const UserProfile = component(({ userId }: { userId: string }) => {
+function UserProfile({ userId }: { userId: string }) {
const query = useQuery(GetUser, { id: userId });
+ if (!query.isReady) return null;
return {query.value.name}
;
-});
+}
```
#### With reactive parameters
@@ -120,20 +133,21 @@ import { signal } from 'signalium';
const searchTerm = signal('');
-const SearchResults = component(() => {
+function SearchResults() {
const query = useQuery(SearchUsers, { q: searchTerm });
+ if (!query.isReady) return null;
- // Component re-renders when searchTerm changes and the query refetches
+ // Re-renders when searchTerm changes and the query refetches
return {query.value.results.length} results
;
-});
+}
```
#### Notes
- `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 +160,11 @@ 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 suspends again. Wrap the change in `startTransition` to keep showing the previous result.
-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.
+Like `useQuery`, call it from an ordinary React component, not inside a Signalium `component()`.
```tsx
import { Suspense } from 'react';
@@ -167,5 +179,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..81d473e 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. Releasing, or reaching the `ttl`, aborts a request still in flight unless a reader has joined it, and never interrupts joined readers. 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..0dee90b 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): 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,8 @@ 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
}
| {
type: 2; // ActivateQuery
diff --git a/docs/src/app/api/stores-sync/page.md b/docs/src/app/api/stores-sync/page.md
index c4f4235..f32b20c 100644
--- a/docs/src/app/api/stores-sync/page.md
+++ b/docs/src/app/api/stores-sync/page.md
@@ -32,17 +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): void` | Persists an entity's serialized data and its child entity references. Manages reference counts for nested entities. |
-| `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`. |
+| 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` | 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 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. |
---
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..1dd053f 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
@@ -343,14 +343,15 @@ When the subscription fires a `create` event for a `ChatMessage` whose `channelI
```tsx
import { component } from 'signalium/react';
-import { useQuery } from 'fetchium/react';
+import { fetchQuery } from 'fetchium';
const ChatRoom = component(({ channelId }: { channelId: string }) => {
- const { messages } = useQuery(GetMessages, { channelId });
+ const query = fetchQuery(GetMessages, { channelId });
+ if (!query.isReady) return null;
return (
- {messages.map((msg) => (
+ {query.value.messages.map((msg) => (
{msg.author.name}: {msg.text}
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.
diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts
index d1a82ae..09f4636 100644
--- a/packages/fetchium/src/EntityInstance.ts
+++ b/packages/fetchium/src/EntityInstance.ts
@@ -11,6 +11,15 @@ import type { QueryClient } from './QueryClient.js';
import { ValidatorDef, WRAPPED_VALUE } from './typeDefs.js';
import type { LiveCollectionBinding } from './LiveCollection.js';
import { entitySatisfiesShape } from './parseEntities.js';
+import { recordRestOutside } from './stores/shared.js';
+
+/** Stored record fields written by another class of the typename, kept so writes don't drop them. */
+export interface RecordRest {
+ keys: string[];
+ /** Raw JSON object body (`"a":1,"b":{…}`), written as is. */
+ json: string;
+ refIds: number[];
+}
// ======================================================
// Nested proxy wrapping — transparently unwraps WRAPPED_VALUE items
@@ -561,6 +570,13 @@ export class EntityInstance {
_deferredWrite: boolean = false;
/** The store is known to hold a record of this entity. */
_recorded: boolean = false;
+ _recordRest: RecordRest | undefined = undefined;
+ /** Raw stored record, turned into `_recordRest` lazily at the first write. */
+ private _storedRecord: Record
| undefined = undefined;
+ /** The first write must re-read the stored record (synchronous stores only). */
+ _checkStoredRecord: boolean = false;
+ /** `entityRefs` came from a full payload, untouched since by partial updates. */
+ _refsCounted: boolean = false;
private _saving: boolean = false;
entityRefs: Map | undefined;
liveCollections: LiveCollectionBinding[] = [];
@@ -652,6 +668,7 @@ export class EntityInstance {
}
addChildRef(child: EntityInstance, persist: boolean = true): void {
+ this._refsCounted = false;
if (this.entityRefs === undefined) this.entityRefs = new Map();
const count = this.entityRefs.get(child) ?? 0;
this.entityRefs.set(child, count + 1);
@@ -660,6 +677,7 @@ export class EntityInstance {
}
removeChildRef(child: EntityInstance, persist: boolean = true): void {
+ this._refsCounted = false;
if (this.entityRefs === undefined) return;
const count = this.entityRefs.get(child);
if (count === undefined) return;
@@ -770,7 +788,59 @@ export class EntityInstance {
this.markUnwritten();
}
- /** Forces the next apply to write. Queued writes hold older data, so their acks are dropped. */
+ recordDeleted(): void {
+ this._recordRest = undefined;
+ this._storedRecord = undefined;
+ this._checkStoredRecord = false;
+ this.recordDropped();
+ }
+
+ keepsRecordFields(): boolean {
+ return this._recordRest !== undefined || this._storedRecord !== undefined || this._checkStoredRecord;
+ }
+
+ /** A rereadable store's record is read again at the first write instead of held in memory. */
+ recordHoldsOtherFields(record: Record, rereadable: boolean): void {
+ if (rereadable) {
+ this._storedRecord = undefined;
+ this._recordRest = undefined;
+ this._checkStoredRecord = true;
+ } else {
+ this.noteRecord(record);
+ }
+ }
+
+ /** A field the data holds, even as `undefined`, is the instance's to write. */
+ noteRecord(record: Record): void {
+ this._storedRecord = record;
+ this._recordRest = undefined;
+ }
+
+ /** Drops kept fields that an applied class now declares. */
+ recordRestForWrite(): RecordRest | undefined {
+ const data = this.data;
+ const record = this._storedRecord;
+ if (record !== undefined) {
+ this._storedRecord = undefined;
+ return (this._recordRest = recordRestOutside(record, data));
+ }
+ const rest = this._recordRest;
+ if (rest === undefined) return undefined;
+ const keys = rest.keys;
+ for (let i = 0; i < keys.length; i++) {
+ if (Object.hasOwn(data, keys[i])) {
+ const values = JSON.parse(`{${rest.json}}`) as Record;
+ return (this._recordRest = recordRestOutside(values, data));
+ }
+ }
+ return rest;
+ }
+
+ /**
+ * Its record is stale: the next apply writes it even if it finds nothing
+ * changed. Writes still queued hold older data, so their acknowledgements
+ * no longer mark it persisted.
+ */
markUnwritten(): void {
this._persisted = false;
this._pendingWrites = 0;
@@ -825,8 +895,15 @@ const DYNAMIC_MASKS = Mask.ENTITY | Mask.LIVE | Mask.HAS_FORMAT;
const staticFieldDefs = new WeakMap, boolean>();
-/** Unrecognised defs answer false, which costs a re-read but never a stale value. */
-function isStaticFieldDef(def: unknown): boolean {
+/**
+ * Whether a field's snapshot is a pure function of its entity's own `data`.
+ * Only the shapes recognised here (the allowlist `getEntityDef` validates
+ * against) answer `true`, so an unfamiliar def costs a re-read, never a stale
+ * value.
+ */
+export function isStaticFieldDef(def: unknown): boolean {
+ // `t.string` is a bare `Mask`, `t.typename('X')` the literal string,
+ // `t.enum`/`t.const` a `Set` of allowed values.
if (typeof def === 'number') return (def & DYNAMIC_MASKS) === 0;
if (typeof def === 'string') return true;
if (def instanceof Set) return true;
diff --git a/packages/fetchium/src/EntityStore.ts b/packages/fetchium/src/EntityStore.ts
index 32f8522..d118a9f 100644
--- a/packages/fetchium/src/EntityStore.ts
+++ b/packages/fetchium/src/EntityStore.ts
@@ -3,18 +3,13 @@ import type { QueryClient } from './QueryClient.js';
import { EntityInstance } from './EntityInstance.js';
import { ValidatorDef } from './typeDefs.js';
-type PersistEntity = (
- key: number,
- data: Record,
- refKeys?: Set,
- merge?: boolean,
- ifStored?: boolean,
-) => void;
-
export class EntityStore {
private instances = new Map();
private persistEntity: PersistEntity;
+ /** Whether the store can merge fields over a record it holds. */
mergesEntities: boolean = false;
+ /** Synchronous stores only. */
+ readEntity: ((key: number) => Record | undefined) | undefined = undefined;
constructor(persistEntity: PersistEntity) {
this.persistEntity = persistEntity;
@@ -80,7 +75,35 @@ export class EntityStore {
if (merge) {
value = {};
for (const k of mergeKeys) value[k] = instance.data[k];
+ this.persistEntity(instance.key, value, refKeys, true, ifStored);
+ return;
+ }
+ if (instance._checkStoredRecord) {
+ if (this.readEntity === undefined) {
+ // The writer keeps the record's other fields, so the flag stays for every write.
+ this.persistEntity(instance.key, value, refKeys, false, false, undefined, Object.keys(value));
+ return;
+ }
+ // Cleared after the read so a read that throws is retried.
+ const stored = this.readEntity(instance.key);
+ instance._checkStoredRecord = false;
+ if (stored !== undefined) instance.noteRecord(stored);
}
- this.persistEntity(instance.key, value, refKeys, merge, ifStored);
+ const rest = instance.recordRestForWrite();
+ if (rest !== undefined && rest.refIds.length > 0) {
+ refKeys ??= new Set();
+ for (let i = 0; i < rest.refIds.length; i++) refKeys.add(rest.refIds[i]);
+ }
+ this.persistEntity(instance.key, value, refKeys, false, false, rest?.json);
}
}
+
+type PersistEntity = (
+ key: number,
+ data: Record,
+ refKeys: Set | undefined,
+ merge: boolean,
+ ifStored?: boolean,
+ rest?: string,
+ ownedKeys?: string[],
+) => void;
diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts
index 4735402..69d64de 100644
--- a/packages/fetchium/src/QueryClient.ts
+++ b/packages/fetchium/src/QueryClient.ts
@@ -11,7 +11,7 @@ import {
} from './types.js';
import { PROXY_ID } from './proxyId.js';
import { EntityStore } from './EntityStore.js';
-import { EntityInstance, type EntityKeys } from './EntityInstance.js';
+import { EntityInstance, isStaticFieldDef, type EntityKeys } from './EntityInstance.js';
import { NetworkManager, NoOpNetworkManager } from './NetworkManager.js';
import { QueryInstance } from './QueryResult.js';
import { MutationResultImpl } from './MutationResult.js';
@@ -208,16 +208,28 @@ export class QueryClient {
config.gcManager ??
(this.isServer ? new NoOpGcManager() : new GcManager(this.handleEviction, evictionMultiplier));
this.networkManager = config.networkManager ?? new NetworkManager();
- this.entityMap = new EntityStore((key, data, refs, merge, ifStored) => {
+ this.entityMap = new EntityStore((key, data, refs, merge, ifStored, rest, ownedKeys) => {
if (this.destroyed) return;
if (merge) this.store.mergeEntity!(key, data, refs, ifStored);
+ else if (ownedKeys !== undefined) this.store.saveEntity(key, data, refs, undefined, ownedKeys);
+ else if (rest !== undefined) this.store.saveEntity(key, data, refs, rest);
else this.store.saveEntity(key, data, refs);
});
this.entityMap.mergesEntities = typeof this.store.mergeEntity === 'function';
+ if (typeof this.store.readEntity === 'function') {
+ const store = this.store;
+ this.entityMap.readEntity = key => store.readEntity!(key);
+ }
// A record the store drops must be written again by the next apply.
this.storeReportsDeletes = typeof this.store.onDelete === 'function';
+ this.storeKnowsTypenameFields =
+ typeof this.store.getEntityFieldNames === 'function' && this.store.entityFieldNamesComplete?.() === true;
const offDelete = this.store.onDelete?.(key => {
- this.entityMap.getEntity(key)?.recordDropped();
+ const instance = this.entityMap.getEntity(key);
+ if (instance === undefined) return;
+ // Failed writes are reported too. A surviving record keeps its fields.
+ if (this.store.hasEntity?.(key) === true) instance.recordDropped();
+ else instance.recordDeleted();
});
if (typeof offDelete === 'function') this.storeUnsubscribes.push(offDelete);
this.storeAcksWrites = typeof this.store.onPersisted === 'function';
@@ -336,8 +348,83 @@ export class QueryClient {
} else {
this.typenameRegistry.set(typename, [def]);
}
- // Builds V8's enum cache for the shape, which speeds up later iterations over it.
- Object.keys(def.shape as object);
+ this.noteTypenameFields(typename, def);
+ }
+
+ /** Per def: whether another class of its typename, in any session, declares a field it lacks. */
+ private foreignFieldDefs = new WeakMap, boolean>();
+ private typenameFields = new Map>();
+ private foreignRefFieldsByDef = new WeakMap, readonly string[]>();
+ hasForeignFieldDefs: boolean = false;
+ /** The store's remembered field names cover every class that wrote a record it holds. */
+ readonly storeKnowsTypenameFields: boolean;
+
+ /**
+ * Whether a stored record of this def's typename may hold fields the def
+ * lacks. Registers the def first: a hydration parse can run before the apply
+ * does. @internal
+ */
+ mayMissForeignFields(def: ValidatorDef): boolean {
+ let verdict = this.foreignFieldDefs.get(def);
+ if (verdict === undefined) {
+ this.registerEntityDef(def);
+ verdict = this.foreignFieldDefs.get(def);
+ if (verdict === undefined) {
+ // A typename's merged def. A new class replaces it, so this can't go stale.
+ const fields = def.typenameValue !== undefined ? this.typenameFields.get(def.typenameValue) : undefined;
+ const shape = def.shape as Record | undefined;
+ verdict = fields !== undefined && shape !== undefined && fields.size > Object.keys(shape).length;
+ this.foreignFieldDefs.set(def, verdict);
+ }
+ }
+ return verdict;
+ }
+
+ /** Entity-holding fields other classes of the typename declare and this def lacks. @internal */
+ foreignRefFields(def: ValidatorDef): readonly string[] {
+ let names = this.foreignRefFieldsByDef.get(def);
+ if (names === undefined) {
+ const found: string[] = [];
+ const shape = def.shape as Record | undefined;
+ const defs = def.typenameValue !== undefined ? this.typenameRegistry.get(def.typenameValue) : undefined;
+ if (shape !== undefined && defs !== undefined) {
+ for (const d of defs) {
+ if (d === def) continue;
+ const other = d.shape as Record;
+ for (const f of Object.keys(other)) {
+ if (!(f in shape) && !found.includes(f) && !isStaticFieldDef(other[f])) found.push(f);
+ }
+ }
+ }
+ this.foreignRefFieldsByDef.set(def, (names = found));
+ }
+ return names;
+ }
+
+ private noteTypenameFields(typename: string, def: ValidatorDef): void {
+ let fields = this.typenameFields.get(typename);
+ if (fields === undefined) {
+ fields = new Set(this.store.getEntityFieldNames?.(typename));
+ this.typenameFields.set(typename, fields);
+ }
+ const declared = Object.keys(def.shape as Record);
+ let added: boolean = false;
+ for (const k of declared) {
+ if (!fields.has(k)) {
+ fields.add(k);
+ added = true;
+ }
+ }
+ // All names, not just new ones: the store forgets names undeclared for a while.
+ this.store.addEntityFieldNames?.(typename, declared);
+ for (const d of this.typenameRegistry.get(typename)!) {
+ // A known name may now have an entity-holding def.
+ this.foreignRefFieldsByDef.delete(d);
+ if (!added && d !== def) continue;
+ const misses = fields.size > Object.keys(d.shape as Record).length;
+ this.foreignFieldDefs.set(d, misses);
+ if (misses) this.hasForeignFieldDefs = true;
+ }
}
getEntityDefsForTypename(typename: string): ValidatorDef[] | undefined {
@@ -1058,6 +1145,9 @@ export class QueryClient {
this.entityMap.clear();
this.constraintRegistry.clear();
this.typenameRegistry.clear();
+ this.typenameFields.clear();
+ this.foreignFieldDefs = new WeakMap();
+ this.foreignRefFieldsByDef = new WeakMap();
this.mergedDefCache.clear();
}
}
diff --git a/packages/fetchium/src/__benchmarks__/shared-typename-stream.bench.ts b/packages/fetchium/src/__benchmarks__/shared-typename-stream.bench.ts
new file mode 100644
index 0000000..beed360
--- /dev/null
+++ b/packages/fetchium/src/__benchmarks__/shared-typename-stream.bench.ts
@@ -0,0 +1,163 @@
+import { bench, describe } from 'vitest';
+import { watcher, withContexts } from 'signalium';
+import { MemoryPersistentStore, SyncQueryStore } from '../stores/sync.js';
+import { QueryClient, QueryClientContext } from '../QueryClient.js';
+import { RESTQueryAdapter } from '../rest/RESTQueryAdapter.js';
+import { RESTQuery } from '../rest/index.js';
+import { Entity } from '../proxy.js';
+import { fetchQuery } from '../query.js';
+import { t } from '../typeDefs.js';
+
+// Streamed full updates where list and detail classes share a typename.
+// Each list write must keep the detail's fields. Run with `npm run bench`.
+
+const meta = { logo: t.string, website: t.string, tags: t.array(t.string) };
+
+class MarketRow extends Entity {
+ __typename = t.typename('Market');
+ id = t.id;
+ symbol = t.string;
+ name = t.string;
+ price = t.number;
+ change = t.number;
+ volume = t.number;
+}
+
+class MarketDetail extends Entity {
+ __typename = t.typename('Market');
+ id = t.id;
+ symbol = t.string;
+ name = t.string;
+ price = t.number;
+ change = t.number;
+ volume = t.number;
+ extraMetadata = t.object(meta);
+ categories = t.array(t.string);
+}
+
+class PlainRow extends Entity {
+ __typename = t.typename('Plain');
+ id = t.id;
+ symbol = t.string;
+ name = t.string;
+ price = t.number;
+ change = t.number;
+ volume = t.number;
+}
+
+class GetMarkets extends RESTQuery {
+ params = { n: t.number };
+ path = `/markets/${this.params.n}`;
+ result = { items: t.array(t.entity(MarketRow)) };
+ getConfig() {
+ return { retry: 0, gcTime: Infinity };
+ }
+}
+
+class GetMarket extends RESTQuery {
+ static cache = { maxCount: 1000 };
+ params = { id: t.string };
+ path = `/market/${this.params.id}`;
+ result = { market: t.entity(MarketDetail) };
+ getConfig() {
+ return { retry: 0, gcTime: Infinity };
+ }
+}
+
+class GetPlain extends RESTQuery {
+ params = { n: t.number };
+ path = `/plain/${this.params.n}`;
+ result = { items: t.array(t.entity(PlainRow)) };
+ getConfig() {
+ return { retry: 0, gcTime: Infinity };
+ }
+}
+
+const row = (typename: string, i: number, price = 100 + i) => ({
+ __typename: typename,
+ id: `m-${i}`,
+ symbol: `SYM${i}`,
+ name: `Market ${i}`,
+ price,
+ change: 0.5,
+ volume: 1000 * i,
+});
+
+const detail = (i: number) => ({
+ ...row('Market', i),
+ extraMetadata: { logo: `logo-${i}.png`, website: `m${i}.example.com`, tags: ['defi', 'x'] },
+ categories: ['a', 'b'],
+});
+
+function respond(body: unknown) {
+ return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
+}
+
+function client(kv: MemoryPersistentStore, n: number) {
+ return new QueryClient({
+ store: new SyncQueryStore(kv),
+ adapters: [
+ new RESTQueryAdapter({
+ fetch: (async (url: string) => {
+ const path = new URL(url).pathname;
+ if (path.startsWith('/market/')) return respond({ market: detail(Number(path.split('-')[1])) });
+ if (path.startsWith('/plain/'))
+ return respond({ items: Array.from({ length: n }, (_, i) => row('Plain', i)) });
+ return respond({ items: Array.from({ length: n }, (_, i) => row('Market', i)) });
+ }) as unknown as typeof fetch,
+ baseUrl: 'http://localhost',
+ }),
+ ],
+ });
+}
+
+async function hold(c: QueryClient, start: () => unknown): Promise {
+ let q: any;
+ const w = withContexts([[QueryClientContext, c]], () => {
+ q = start();
+ return watcher(() => q.value);
+ });
+ w.addListener(() => {});
+ await q;
+}
+
+const N = 100;
+const kv = new MemoryPersistentStore();
+
+const first = client(kv, N);
+for (let i = 0; i < N; i++) await hold(first, () => fetchQuery(GetMarket, { id: `m-${i}` }));
+first.destroy();
+
+const c = client(kv, N);
+await hold(c, () => fetchQuery(GetMarkets, { n: N }));
+await hold(c, () => fetchQuery(GetPlain, { n: N }));
+
+let tick = 0;
+
+describe(`streamed full updates, ${N} in-memory list entities`, () => {
+ bench('shared typename: one entity, price changes', () => {
+ tick++;
+ c.applyMutationEvent({ type: 'update', typename: 'Market', data: row('Market', tick % N, tick) });
+ });
+
+ bench('single-class typename (control): one entity, price changes', () => {
+ tick++;
+ c.applyMutationEvent({ type: 'update', typename: 'Plain', data: row('Plain', tick % N, tick) });
+ });
+
+ bench(`shared typename: all ${N} entities, price changes`, () => {
+ tick++;
+ for (let i = 0; i < N; i++)
+ c.applyMutationEvent({ type: 'update', typename: 'Market', data: row('Market', i, tick) });
+ });
+
+ bench(`single-class typename (control): all ${N} entities, price changes`, () => {
+ tick++;
+ for (let i = 0; i < N; i++)
+ c.applyMutationEvent({ type: 'update', typename: 'Plain', data: row('Plain', i, tick) });
+ });
+
+ bench('shared typename: one entity, identical update', () => {
+ c.applyMutationEvent({ type: 'update', typename: 'Market', data: row('Market', 7, 107) });
+ });
+});
diff --git a/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts b/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts
new file mode 100644
index 0000000..287a0bf
--- /dev/null
+++ b/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts
@@ -0,0 +1,1429 @@
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { watcher, withContexts } from 'signalium';
+import { t } from '../typeDefs.js';
+import { Entity } from '../proxy.js';
+import { RESTQuery } from '../rest/index.js';
+import { fetchQuery } from '../query.js';
+import { QueryClient, QueryClientContext } from '../QueryClient.js';
+import { RESTQueryAdapter } from '../rest/RESTQueryAdapter.js';
+import { SyncQueryStore, MemoryPersistentStore } from '../stores/sync.js';
+import { AsyncQueryStore, type StoreMessage } from '../stores/async.js';
+import { valueKeyFor, refIdsKeyFor, refCountKeyFor } from '../stores/shared.js';
+import { hashValue } from 'signalium/utils';
+import { sleep, testWithClient } from './utils.js';
+import { GcManager } from '../GcManager.js';
+
+// A cached query is served when an in-memory entity lacks fields the cache can
+// fill, but never with values the fresher in-memory entity contradicts.
+
+let clients: QueryClient[] = [];
+afterEach(() => {
+ for (const c of clients) c.destroy();
+ clients = [];
+});
+
+type Route = { body: unknown; delay: number };
+function makeFetch() {
+ const routes = new Map();
+ const calls: string[] = [];
+ const fetch = (url: string) => {
+ const path = new URL(url).pathname;
+ calls.push(path);
+ const route = routes.get(path);
+ if (route === undefined) return Promise.reject(new Error(`no route for ${path}`));
+ const body = JSON.parse(JSON.stringify(route.body));
+ return new Promise(resolve =>
+ setTimeout(
+ () =>
+ resolve({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ headers: new Headers(),
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ }),
+ route.delay,
+ ),
+ );
+ };
+ return {
+ fetch,
+ calls,
+ set(path: string, body: unknown, delay = 0) {
+ routes.set(path, { body, delay });
+ },
+ count(path: string) {
+ return calls.filter(c => c === path).length;
+ },
+ };
+}
+
+function makeClient(kv: MemoryPersistentStore, f: ReturnType, logs: string[] = []) {
+ const client = new QueryClient({
+ store: new SyncQueryStore(kv),
+ adapters: [new RESTQueryAdapter({ fetch: f.fetch as any, baseUrl: 'http://localhost' })],
+ log: {
+ warn: (m: unknown, e?: unknown) => logs.push(`warn: ${String(m)} ${e instanceof Error ? e.message : ''}`),
+ error: (m: unknown) => logs.push(`error: ${String(m)}`),
+ },
+ } as any);
+ clients.push(client);
+ return client;
+}
+
+function start(client: QueryClient, fn: () => T): T {
+ return withContexts([[QueryClientContext, client]], () => {
+ const q = fn();
+ const w = watcher(() => (q as any).value);
+ w.addListener(() => {});
+ return q;
+ });
+}
+
+function read(fn: () => T): T | string {
+ try {
+ return fn();
+ } catch (e) {
+ return `read threw: ${(e as Error).message}`;
+ }
+}
+
+function recordOf(kv: MemoryPersistentStore, typename: string, id: string): Record | undefined {
+ const raw = kv.getString(valueKeyFor(hashValue([typename, id])));
+ return raw === undefined ? undefined : JSON.parse(raw);
+}
+
+/** A fresh client over the same kv (an app restart). */
+async function session(
+ kv: MemoryPersistentStore,
+ f: ReturnType,
+ run: (c: QueryClient) => Promise,
+) {
+ const c = makeClient(kv, f);
+ await run(c);
+ c.destroy();
+ clients = clients.filter(x => x !== c);
+}
+
+// A. Required top-level format field
+
+class Reading extends Entity {
+ __typename = t.typename('Reading');
+ id = t.id;
+ value = t.number;
+ takenAt = t.format('date-time');
+}
+class GetLatestReading extends RESTQuery {
+ path = '/reading/latest';
+ result = { reading: t.entity(Reading) };
+}
+class GetReadingPage extends RESTQuery {
+ path = '/reading/page';
+ result = { reading: t.entity(Reading), label: t.string };
+}
+const readingPayload = { __typename: 'Reading', id: 'r1', value: 1, takenAt: '2026-01-01T00:00:00.000Z' };
+
+describe('A. required top-level t.format on an in-memory entity', () => {
+ for (const inMemory of [false, true]) {
+ it(`cached query is served (entity in memory: ${inMemory})`, async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ f.set('/reading/page', { reading: readingPayload, label: 'cached' });
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetReadingPage));
+ });
+
+ const logs: string[] = [];
+ const c2 = makeClient(kv, f, logs);
+ f.set('/reading/latest', { reading: { ...readingPayload, value: 2 } });
+ const latest = inMemory ? start(c2, () => fetchQuery(GetLatestReading)) : undefined;
+ if (latest !== undefined) await latest;
+
+ f.set('/reading/page', { reading: { ...readingPayload, value: 3 }, label: 'net' }, 200);
+ const page: any = start(c2, () => fetchQuery(GetReadingPage));
+ await sleep(20);
+ const early = {
+ isReady: page.isReady,
+ label: read(() => page.value?.label),
+ value: read(() => page.value?.reading.value),
+ takenAt: read(() => page.value?.reading.takenAt?.toISOString?.()),
+ };
+ const latestEarly =
+ latest !== undefined ? read(() => (latest as any).value.reading.takenAt.toISOString()) : undefined;
+ await sleep(300);
+
+ expect(logs).toEqual([]);
+ expect(early.isReady).toBe(true);
+ expect(early.label).toBe('cached');
+ // Memory is fresher than the record.
+ expect(early.value).toBe(inMemory ? 2 : 1);
+ expect(early.takenAt).toBe('2026-01-01T00:00:00.000Z');
+ if (latest !== undefined) expect(latestEarly).toBe('2026-01-01T00:00:00.000Z');
+ expect(f.count('/reading/page')).toBe(2);
+ expect(page.value.label).toBe('net');
+ expect(page.value.reading.value).toBe(3);
+ });
+ }
+});
+
+// B. Two classes share a typename; the summary class lacks `details`
+
+class ProductSummary extends Entity {
+ __typename = t.typename('Product');
+ id = t.id;
+ name = t.string;
+}
+class ProductDetail extends Entity {
+ __typename = t.typename('Product');
+ id = t.id;
+ name = t.string;
+ details = t.object({ rating: t.number, tags: t.array(t.string) });
+}
+class GetProductList extends RESTQuery {
+ path = '/products';
+ result = { items: t.array(t.entity(ProductSummary)) };
+}
+class GetProductDetail extends RESTQuery {
+ path = '/product';
+ result = { product: t.entity(ProductDetail), label: t.string };
+}
+
+const detailBody = (rating: number, label: string, name = 'P') => ({
+ product: { __typename: 'Product', id: 'p1', name, details: { rating, tags: ['a'] } },
+ label,
+});
+const listBody = (name = 'P') => ({ items: [{ __typename: 'Product', id: 'p1', name }] });
+
+type DetailOutcome = {
+ isReady: boolean;
+ label: unknown;
+ name: unknown;
+ rating: unknown;
+ listName?: unknown;
+ listKeys?: unknown;
+ logs: string[];
+ finalLabel: unknown;
+ finalRating: unknown;
+ detailRequests: number;
+ recordAfterHydrate: Record | undefined;
+ recordAfterFetch: Record | undefined;
+};
+
+/** The detail's response is delayed so the first read shows whether the cache was served. */
+async function runDetail(
+ kv: MemoryPersistentStore,
+ f: ReturnType,
+ before: (c: QueryClient) => Promise<{ list?: any } | undefined>,
+): Promise {
+ const logs: string[] = [];
+ const c = makeClient(kv, f, logs);
+ // Wrapped: an async function returning a bare (thenable) QueryPromise would unwrap it.
+ const list = (await before(c))?.list;
+ f.set('/product', detailBody(6, 'net', 'P-net'), 200);
+ const requestsBefore = f.count('/product');
+ const d: any = start(c, () => fetchQuery(GetProductDetail));
+ await sleep(20);
+ const out: DetailOutcome = {
+ isReady: d.isReady,
+ label: read(() => d.value?.label),
+ name: read(() => d.value?.product.name),
+ rating: read(() => d.value?.product.details?.rating),
+ listName: list !== undefined ? read(() => list.value.items[0].name) : undefined,
+ listKeys: list !== undefined ? read(() => Object.keys(list.value.items[0]).sort().join(',')) : undefined,
+ logs,
+ finalLabel: undefined,
+ finalRating: undefined,
+ detailRequests: 0,
+ recordAfterHydrate: recordOf(kv, 'Product', 'p1'),
+ recordAfterFetch: undefined,
+ };
+ await sleep(300);
+ out.finalLabel = read(() => d.value?.label);
+ out.finalRating = read(() => d.value?.product.details?.rating);
+ out.detailRequests = f.count('/product') - requestsBefore;
+ out.recordAfterFetch = recordOf(kv, 'Product', 'p1');
+ return out;
+}
+
+async function seedDetail(kv: MemoryPersistentStore, f: ReturnType) {
+ f.set('/product', detailBody(5, 'cached'));
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetProductDetail));
+ });
+}
+
+async function seedListCache(kv: MemoryPersistentStore, f: ReturnType) {
+ // Detail last, so the record on disk is the detail's.
+ f.set('/products', listBody());
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetProductList));
+ });
+ await seedDetail(kv, f);
+}
+
+function expectServed(o: DetailOutcome, name = 'P') {
+ expect(o.logs).toEqual([]);
+ expect(o.isReady).toBe(true);
+ expect(o.label).toBe('cached');
+ expect(o.name).toBe(name);
+ expect(o.rating).toBe(5);
+ expect(o.detailRequests).toBe(1);
+ expect(o.finalLabel).toBe('net');
+ expect(o.finalRating).toBe(6);
+ expect(o.recordAfterFetch).toMatchObject({ name: 'P-net', details: { rating: 6, tags: ['a'] } });
+}
+
+describe('B. shared typename, the in-memory class lacks a field the cached query needs', () => {
+ it('B0 control: nothing in memory, the cache is served', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ await seedDetail(kv, f);
+ const o = await runDetail(kv, f, async () => undefined);
+ expectServed(o);
+ });
+
+ it('B1 the summary was hydrated from its own cache (record intact): the cache is served', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ await seedListCache(kv, f);
+ f.set('/products', listBody(), 1000); // keep the list's refetch out of the window
+ const o = await runDetail(kv, f, async c => {
+ const list: any = start(c, () => fetchQuery(GetProductList));
+ await sleep(5);
+ expect(list.isReady).toBe(true);
+ return { list };
+ });
+ expectServed(o);
+ expect(o.listName).toBe('P');
+ expect(o.listKeys).toBe('__typename,id,name');
+ expect(o.recordAfterHydrate).toMatchObject({ name: 'P', details: { rating: 5 } });
+ });
+
+ it('B2 the summary was fetched from the network this session: the cache is served', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ await seedDetail(kv, f);
+ f.set('/products', listBody());
+ const o = await runDetail(kv, f, async c => {
+ const list: any = start(c, () => fetchQuery(GetProductList));
+ await list;
+ return { list };
+ });
+ expectServed(o);
+ expect(o.listName).toBe('P');
+ expect(o.recordAfterHydrate).toMatchObject({ name: 'P', details: { rating: 5, tags: ['a'] } });
+ });
+
+ it('B3 the summary wrote the record in an earlier session: a cold start still serves the detail cache', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ await seedDetail(kv, f);
+ f.set('/products', listBody('P2'));
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetProductList));
+ });
+ expect(recordOf(kv, 'Product', 'p1')).toMatchObject({ name: 'P2', details: { rating: 5 } });
+ const o = await runDetail(kv, f, async () => undefined);
+ expectServed(o, 'P2');
+ });
+
+ it('B4 the summary hydrated, then refetched with a change, before the detail opens: served, newer name wins', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ await seedListCache(kv, f);
+ f.set('/products', listBody('P3'));
+ const o = await runDetail(kv, f, async c => {
+ const list: any = start(c, () => fetchQuery(GetProductList));
+ for (let i = 0; i < 20 && read(() => list.value?.items[0].name) !== 'P3'; i++) await sleep(5);
+ expect(list.value.items[0].name).toBe('P3');
+ return { list };
+ });
+ expectServed(o, 'P3');
+ expect(o.listName).toBe('P3');
+ });
+});
+
+// C. Negative controls: the cache must not be served
+
+class ProductSummaryOptional extends Entity {
+ __typename = t.typename('Product');
+ id = t.id;
+ name = t.string;
+ details = t.optional(t.object({ rating: t.number, tags: t.array(t.string) }));
+}
+class GetProductListOptional extends RESTQuery {
+ path = '/products-opt';
+ result = { items: t.array(t.entity(ProductSummaryOptional)) };
+}
+
+class ProductSummaryConflict extends Entity {
+ __typename = t.typename('Product');
+ id = t.id;
+ name = t.string;
+ details = t.string;
+}
+class GetProductListConflict extends RESTQuery {
+ path = '/products-conflict';
+ result = { items: t.array(t.entity(ProductSummaryConflict)) };
+}
+
+class ProductSummaryNested extends Entity {
+ __typename = t.typename('Product');
+ id = t.id;
+ name = t.string;
+ details = t.object({ rating: t.number });
+}
+class GetProductListNested extends RESTQuery {
+ path = '/products-nested';
+ result = { items: t.array(t.entity(ProductSummaryNested)) };
+}
+
+function expectNotServed(o: DetailOutcome) {
+ expect(o.isReady).toBe(false);
+ expect(o.rating === 5).toBe(false);
+ expect(o.detailRequests).toBe(1);
+ expect(o.finalLabel).toBe('net');
+ expect(o.finalRating).toBe(6);
+}
+
+describe('C. negative controls: a fresher in-memory value contradicts the cache', () => {
+ it('C1 the in-memory class declares the field and the fresh response omitted it: not served', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ await seedDetail(kv, f);
+ f.set('/products-opt', { items: [{ __typename: 'Product', id: 'p1', name: 'P' }] });
+ const o = await runDetail(kv, f, async c => {
+ const list: any = start(c, () => fetchQuery(GetProductListOptional));
+ await list;
+ return { list };
+ });
+ expectNotServed(o);
+ });
+
+ it('C2 the in-memory class holds the field with another type: not served, the other query keeps its value', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ await seedDetail(kv, f);
+ f.set('/products-conflict', { items: [{ __typename: 'Product', id: 'p1', name: 'P', details: 'plain' }] });
+ let listRef: any;
+ const o = await runDetail(kv, f, async c => {
+ const list: any = start(c, () => fetchQuery(GetProductListConflict));
+ await list;
+ listRef = list;
+ return { list };
+ });
+ expectNotServed(o);
+ expect(read(() => listRef.value.items[0].name)).toBe('P-net');
+ });
+
+ it('C3 the in-memory nested object lacks a required nested field: not served (nested fill is out of scope)', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ await seedDetail(kv, f);
+ f.set('/products-nested', { items: [{ __typename: 'Product', id: 'p1', name: 'P', details: { rating: 9 } }] });
+ const o = await runDetail(kv, f, async c => {
+ const list: any = start(c, () => fetchQuery(GetProductListNested));
+ await list;
+ return { list };
+ });
+ expectNotServed(o);
+ });
+});
+
+// D. Writes keep the fields another class declared, without a per-write cost
+
+class CountingStore extends SyncQueryStore {
+ reads = 0;
+ parsed = 0;
+ readEntity(entityKey: number) {
+ this.reads++;
+ const record = super.readEntity(entityKey);
+ if (record !== undefined) this.parsed++;
+ return record;
+ }
+}
+
+function makeCountingClient(store: SyncQueryStore, f: ReturnType) {
+ const client = new QueryClient({
+ store,
+ adapters: [new RESTQueryAdapter({ fetch: f.fetch as any, baseUrl: 'http://localhost' })],
+ } as any);
+ clients.push(client);
+ return client;
+}
+
+class Vendor extends Entity {
+ __typename = t.typename('Vendor');
+ id = t.id;
+ name = t.string;
+}
+class ItemSummary extends Entity {
+ __typename = t.typename('Item');
+ id = t.id;
+ name = t.string;
+}
+class ItemSummaryOptional extends Entity {
+ __typename = t.typename('Item');
+ id = t.id;
+ name = t.string;
+ details = t.optional(t.object({ rating: t.number }));
+}
+class ItemDetail extends Entity {
+ __typename = t.typename('Item');
+ id = t.id;
+ name = t.string;
+ details = t.object({ rating: t.number });
+ vendor = t.entity(Vendor);
+}
+class GetItems extends RESTQuery {
+ path = '/items';
+ result = { items: t.array(t.entity(ItemSummary)) };
+}
+class GetItemsOptional extends RESTQuery {
+ path = '/items-opt';
+ result = { items: t.array(t.entity(ItemSummaryOptional)) };
+}
+class GetItem extends RESTQuery {
+ path = '/item';
+ result = { item: t.entity(ItemDetail) };
+}
+const itemDetail = (vendor: string, rating = 5, name = 'I') => ({
+ item: {
+ __typename: 'Item',
+ id: 'i1',
+ name,
+ details: { rating },
+ vendor: { __typename: 'Vendor', id: vendor, name: vendor },
+ },
+});
+const vendorKey = (id: string) => hashValue(['Vendor', id]);
+
+describe('D. writes keep the fields another class sharing the typename declared', () => {
+ it('D1 a typename with one class never reads a record before writing', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ f.set('/reading/page', { reading: readingPayload, label: 'x' });
+ for (let session = 0; session < 2; session++) {
+ const store = new CountingStore(kv);
+ const c = makeCountingClient(store, f);
+ await start(c, () => fetchQuery(GetReadingPage));
+ expect(store.reads).toBe(0);
+ c.destroy();
+ }
+ });
+
+ it('D2 a kept field that references an entity keeps that record alive, and a class declaring it releases it', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ f.set('/item', itemDetail('v1'));
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetItem));
+ });
+
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] });
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetItems));
+ });
+ expect(recordOf(kv, 'Item', 'i1')).toEqual({
+ __typename: 'Item',
+ id: 'i1',
+ name: 'I2',
+ details: { rating: 5 },
+ vendor: { __entityRef: vendorKey('v1') },
+ });
+ expect(kv.getString(valueKeyFor(vendorKey('v1')))).toBeDefined();
+
+ f.set('/item', itemDetail('v2', 6), 100);
+ const c = makeClient(kv, f);
+ const item: any = start(c, () => fetchQuery(GetItem));
+ await sleep(20);
+ expect(item.isReady).toBe(true);
+ expect(item.value.item.name).toBe('I2');
+ expect(item.value.item.vendor.name).toBe('v1');
+ await sleep(200);
+ expect(item.value.item.vendor.name).toBe('v2');
+ expect(recordOf(kv, 'Item', 'i1')).toMatchObject({
+ details: { rating: 6 },
+ vendor: { __entityRef: vendorKey('v2') },
+ });
+ expect(kv.getString(valueKeyFor(vendorKey('v1')))).toBeUndefined();
+ });
+
+ it('D3 a class that declares the field optional and receives it absent removes it from the record', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ f.set('/item', itemDetail('v1'));
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetItem));
+ });
+ f.set('/items-opt', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] });
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetItemsOptional));
+ });
+ const record = recordOf(kv, 'Item', 'i1')!;
+ expect(record.details).toBeUndefined();
+ expect(record.vendor).toEqual({ __entityRef: vendorKey('v1') });
+ });
+
+ it('D4 streamed full updates of an in-memory entity read the record at most once', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ f.set('/item', itemDetail('v1'));
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetItem));
+ });
+
+ const store = new CountingStore(kv);
+ const c = makeCountingClient(store, f);
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] });
+ const list: any = start(c, () => fetchQuery(GetItems));
+ await list;
+ expect(store.reads).toBe(1);
+
+ const itemValueKey = valueKeyFor(hashValue(['Item', 'i1']));
+ const getString = kv.getString.bind(kv);
+ let recordReads = 0;
+ kv.getString = (key: string) => {
+ if (key === itemValueKey) recordReads++;
+ return getString(key);
+ };
+ for (let i = 0; i < 50; i++) {
+ c.applyMutationEvent({ type: 'update', typename: 'Item', data: { __typename: 'Item', id: 'i1', name: `N${i}` } });
+ }
+ await sleep(0);
+ expect(store.reads).toBe(1);
+ expect(recordReads).toBe(0);
+ expect(list.value.items[0].name).toBe('N49');
+ expect(recordOf(kv, 'Item', 'i1')).toMatchObject({ name: 'N49', details: { rating: 5 } });
+ });
+
+ it('D6 an optional field only another class declares is served from the record, not as missing', async () => {
+ class NoteSummary extends Entity {
+ __typename = t.typename('Note');
+ id = t.id;
+ title = t.string;
+ }
+ class NoteDetail extends Entity {
+ __typename = t.typename('Note');
+ id = t.id;
+ title = t.string;
+ body = t.optional(t.string);
+ }
+ class GetNotes extends RESTQuery {
+ path = '/notes';
+ result = { notes: t.array(t.entity(NoteSummary)) };
+ }
+ class GetNote extends RESTQuery {
+ path = '/note';
+ result = { note: t.entity(NoteDetail) };
+ }
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ f.set('/note', { note: { __typename: 'Note', id: 'n1', title: 'T', body: 'cached body' } });
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetNote));
+ });
+ const c = makeClient(kv, f);
+ f.set('/notes', { notes: [{ __typename: 'Note', id: 'n1', title: 'T2' }] });
+ await start(c, () => fetchQuery(GetNotes));
+ f.set('/note', { note: { __typename: 'Note', id: 'n1', title: 'T3', body: 'net body' } }, 100);
+ const note: any = start(c, () => fetchQuery(GetNote));
+ await sleep(20);
+ expect(note.isReady).toBe(true);
+ expect(note.value.note.title).toBe('T2');
+ expect(note.value.note.body).toBe('cached body');
+ await sleep(200);
+ expect(note.value.note.body).toBe('net body');
+ });
+
+ it('D7 an event-built entity a cached list fills in keeps the other class fields at its next writes', async () => {
+ class Shelf extends Entity {
+ __typename = t.typename('Shelf');
+ id = t.id;
+ top = t.entity(ItemSummary);
+ }
+ class GetShelf extends RESTQuery {
+ path = '/shelf';
+ result = { shelf: t.entity(Shelf) };
+ }
+ const summary = (id: string, name: string) => ({ __typename: 'Item', id, name });
+ const shelfEvent = (name: string) => ({
+ type: 'update' as const,
+ typename: 'Shelf',
+ data: { __typename: 'Shelf', id: 's1', top: summary('i1', name) },
+ });
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ f.set('/items', { items: [summary('i1', 'I')] });
+ f.set('/item', itemDetail('v1'));
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetItems));
+ await start(c, () => fetchQuery(GetItem));
+ });
+
+ const c = makeClient(kv, f);
+ f.set('/shelf', { shelf: { __typename: 'Shelf', id: 's1', top: summary('i2', 'J') } });
+ await start(c, () => fetchQuery(GetShelf));
+ c.applyMutationEvent(shelfEvent('E1'));
+ f.set('/items', { items: [summary('i1', 'L')] }, 50);
+ const list: any = start(c, () => fetchQuery(GetItems));
+ await sleep(10);
+ expect(list.isReady).toBe(true);
+ const kept = { details: { rating: 5 }, vendor: { __entityRef: vendorKey('v1') } };
+ c.applyMutationEvent(shelfEvent('E2'));
+ expect(recordOf(kv, 'Item', 'i1')).toEqual({ __typename: 'Item', id: 'i1', name: 'E2', ...kept });
+ await sleep(100);
+ expect(recordOf(kv, 'Item', 'i1')).toEqual({ __typename: 'Item', id: 'i1', name: 'L', ...kept });
+ });
+
+ it('D8 a failed record read is retried at the next write, so the other class fields are kept', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ f.set('/item', itemDetail('v1'));
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetItem));
+ });
+
+ class FlakyReadStore extends SyncQueryStore {
+ failNext = true;
+ readEntity(entityKey: number) {
+ if (this.failNext) {
+ this.failNext = false;
+ throw new Error('read failed');
+ }
+ return super.readEntity(entityKey);
+ }
+ }
+ class GetFirst extends RESTQuery {
+ path = '/first';
+ result = { items: t.array(t.entity(ItemSummary)) };
+ }
+ class GetSecond extends RESTQuery {
+ path = '/second';
+ result = { items: t.array(t.entity(ItemSummary)) };
+ }
+ f.set('/first', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] });
+ f.set('/second', { items: [{ __typename: 'Item', id: 'i1', name: 'I3' }] });
+ const c = makeCountingClient(new FlakyReadStore(kv), f);
+
+ await Promise.resolve(start(c, () => fetchQuery(GetFirst))).catch(() => {});
+ await start(c, () => fetchQuery(GetSecond));
+ expect(recordOf(kv, 'Item', 'i1')).toMatchObject({ name: 'I3', details: { rating: 5 } });
+ });
+
+ it('D9 a failed async write over a surviving record keeps the other class fields at the next write', async () => {
+ const data = new Map();
+ let failNextWriteOf: string | undefined;
+ const delegate = {
+ has: async (k: string) => data.has(k),
+ getString: async (k: string) => data.get(k) as string | undefined,
+ setString: async (k: string, v: string) => {
+ if (k === failNextWriteOf) {
+ failNextWriteOf = undefined;
+ throw new Error('write failed');
+ }
+ data.set(k, v);
+ },
+ getNumber: async (k: string) => data.get(k) as number | undefined,
+ setNumber: async (k: string, v: number) => void data.set(k, v),
+ getBuffer: async (k: string) => data.get(k) as Uint32Array | undefined,
+ setBuffer: async (k: string, v: Uint32Array) => void data.set(k, v),
+ delete: async (k: string) => void data.delete(k),
+ getAllKeys: async () => [...data.keys()],
+ };
+ const writerStore = () =>
+ new AsyncQueryStore({
+ isWriter: true,
+ delegate,
+ connect: handleMessage => ({ sendMessage: msg => handleMessage(msg) }),
+ });
+ const drain = async (store: AsyncQueryStore) => {
+ for (let i = 0; i < 500 && !store.isSettled(); i++) await sleep(2);
+ };
+ const record = () => JSON.parse(data.get(valueKeyFor(hashValue(['Item', 'i1']))) as string);
+ const f = makeFetch();
+ f.set('/item', itemDetail('v1'));
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I' }] });
+
+ const first = writerStore();
+ const c1 = makeCountingClient(first as any, f);
+ await start(c1, () => fetchQuery(GetItem));
+ await start(c1, () => fetchQuery(GetItems));
+ await drain(first);
+ c1.destroy();
+
+ const second = writerStore();
+ const c2 = makeCountingClient(second as any, f);
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ await start(c2, () => fetchQuery(GetItems));
+ await drain(second);
+ const update = (name: string) =>
+ c2.applyMutationEvent({ type: 'update', typename: 'Item', data: { __typename: 'Item', id: 'i1', name } });
+
+ failNextWriteOf = valueKeyFor(hashValue(['Item', 'i1']));
+ update('I2');
+ await drain(second);
+ expect(record()).toMatchObject({ name: 'I', details: { rating: 5 } });
+
+ update('I3');
+ await drain(second);
+ expect(record()).toMatchObject({ name: 'I3', details: { rating: 5 } });
+ } finally {
+ consoleError.mockRestore();
+ }
+ });
+
+ function asyncWriter() {
+ const data = new Map();
+ const failReads = new Map();
+ const reads: string[] = [];
+ const delegate = {
+ has: async (k: string) => data.has(k),
+ getString: async (k: string) => {
+ reads.push(k);
+ const n = failReads.get(k) ?? 0;
+ if (n > 0) {
+ failReads.set(k, n - 1);
+ throw new Error('read failed');
+ }
+ return data.get(k) as string | undefined;
+ },
+ setString: async (k: string, v: string) => void data.set(k, v),
+ getNumber: async (k: string) => data.get(k) as number | undefined,
+ setNumber: async (k: string, v: number) => void data.set(k, v),
+ getBuffer: async (k: string) => data.get(k) as Uint32Array | undefined,
+ setBuffer: async (k: string, v: Uint32Array) => void data.set(k, v),
+ delete: async (k: string) => void data.delete(k),
+ getAllKeys: async () => [...data.keys()],
+ };
+ const store = () =>
+ new AsyncQueryStore({
+ isWriter: true,
+ delegate,
+ connect: handleMessage => ({ sendMessage: msg => handleMessage(msg) }),
+ });
+ const drain = async (s: AsyncQueryStore) => {
+ for (let i = 0; i < 500 && !s.isSettled(); i++) await sleep(2);
+ };
+ const record = (typename: string, id: string) => {
+ const raw = data.get(valueKeyFor(hashValue([typename, id]))) as string | undefined;
+ return raw === undefined ? undefined : JSON.parse(raw);
+ };
+ return { data, failReads, reads, store, drain, record };
+ }
+
+ function makeQuietClient(store: AsyncQueryStore, f: ReturnType) {
+ const client = new QueryClient({
+ store,
+ adapters: [new RESTQueryAdapter({ fetch: f.fetch as any, baseUrl: 'http://localhost' })],
+ log: { warn: () => {}, error: () => {} },
+ } as any);
+ clients.push(client);
+ return client;
+ }
+
+ async function asyncSession(
+ env: ReturnType,
+ f: ReturnType,
+ run: (c: QueryClient) => Promise,
+ ) {
+ const s = env.store();
+ const c = makeQuietClient(s, f);
+ await run(c);
+ await sleep(10);
+ await env.drain(s);
+ c.destroy();
+ clients = clients.filter(x => x !== c);
+ }
+
+ const itemValueKey = valueKeyFor(hashValue(['Item', 'i1']));
+ const keptOnDisk = (env: ReturnType, name: string) => {
+ expect(env.record('Item', 'i1')).toEqual({
+ __typename: 'Item',
+ id: 'i1',
+ name,
+ details: { rating: 5 },
+ vendor: { __entityRef: vendorKey('v1') },
+ });
+ expect(env.data.get(refIdsKeyFor(hashValue(['Item', 'i1'])))).toEqual(new Uint32Array([vendorKey('v1')]));
+ expect(env.record('Vendor', 'v1')).toBeDefined();
+ };
+ const keptAfterRestart = async (env: ReturnType, f: ReturnType) => {
+ f.set('/item', itemDetail('v2', 6), 1000);
+ await asyncSession(env, f, async c => {
+ const item: any = start(c, () => fetchQuery(GetItem));
+ await item;
+ expect(item.value.item.details.rating).toBe(5);
+ expect(item.value.item.vendor.name).toBe('v1');
+ });
+ };
+
+ it('D10 with the detail out of memory, a failed async hydration read then a fetch keeps the detail fields, on disk and after a restart', async () => {
+ class GetItemBriefly extends RESTQuery {
+ path = '/item';
+ result = { item: t.entity(ItemDetail) };
+ config = { gcTime: 0 };
+ }
+ const env = asyncWriter();
+ const f = makeFetch();
+ f.set('/item', itemDetail('v1'));
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I' }] });
+ await asyncSession(env, f, async c => {
+ await start(c, () => fetchQuery(GetItem));
+ await start(c, () => fetchQuery(GetItems));
+ });
+
+ await asyncSession(env, f, async c => {
+ c.gcManager = new GcManager((c as any).handleEviction);
+ await testWithClient(c, async () => {
+ await fetchQuery(GetItemBriefly);
+ });
+ await sleep(20);
+ expect(c.entityMap.hasEntity(hashValue(['Item', 'i1']))).toBe(false);
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] });
+ env.failReads.set(itemValueKey, 1);
+ await Promise.resolve(start(c, () => fetchQuery(GetItems))).catch(() => {});
+ expect(env.failReads.get(itemValueKey)).toBe(0);
+ });
+ keptOnDisk(env, 'I2');
+ await keptAfterRestart(env, f);
+ });
+
+ it('D11 as D10 with the detail class registered by another entity, and a failed writer read retried at the next write', async () => {
+ class GetOtherItem extends RESTQuery {
+ path = '/other-item';
+ result = { item: t.entity(ItemDetail) };
+ }
+ const env = asyncWriter();
+ const f = makeFetch();
+ f.set('/item', itemDetail('v1'));
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I' }] });
+ f.set('/other-item', { item: { ...itemDetail('v9').item, id: 'i9' } });
+ await asyncSession(env, f, async c => {
+ await start(c, () => fetchQuery(GetItem));
+ await start(c, () => fetchQuery(GetItems));
+ });
+
+ const s = env.store();
+ const c = makeQuietClient(s, f);
+ await start(c, () => fetchQuery(GetOtherItem));
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] });
+ env.failReads.set(itemValueKey, 1);
+ const list: any = start(c, () => fetchQuery(GetItems));
+ await Promise.resolve(list).catch(() => {});
+ await sleep(10);
+ await env.drain(s);
+ expect(env.failReads.get(itemValueKey)).toBe(0);
+ keptOnDisk(env, 'I2');
+
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I3' }] });
+ env.failReads.set(itemValueKey, 1);
+ await list.value.__refetch();
+ await sleep(10);
+ await env.drain(s);
+ expect(env.failReads.get(itemValueKey)).toBe(0);
+ keptOnDisk(env, 'I2');
+ } finally {
+ consoleError.mockRestore();
+ }
+
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I4' }] });
+ await list.value.__refetch();
+ await sleep(10);
+ await env.drain(s);
+ keptOnDisk(env, 'I4');
+ c.destroy();
+ await keptAfterRestart(env, f);
+ });
+
+ it('D12 an async write reads the record first only for a class that lacks fields another class of its typename declares', async () => {
+ class GetOtherItem extends RESTQuery {
+ path = '/other-item';
+ result = { item: t.entity(ItemDetail) };
+ }
+ const env = asyncWriter();
+ const f = makeFetch();
+ f.set('/reading/page', { reading: readingPayload, label: 'x' });
+ f.set('/other-item', { item: { ...itemDetail('v9').item, id: 'i9' } });
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I' }] });
+ const readingValueKey = valueKeyFor(hashValue(['Reading', 'r1']));
+ await asyncSession(env, f, async c => {
+ await start(c, () => fetchQuery(GetReadingPage));
+ await start(c, () => fetchQuery(GetOtherItem));
+ await start(c, () => fetchQuery(GetItems));
+ });
+ expect(env.record('Reading', 'r1')).toBeDefined();
+ expect(env.reads.filter(k => k === readingValueKey).length).toBe(0);
+ expect(env.reads.filter(k => k === itemValueKey).length).toBe(1);
+ });
+
+ it('D5 the stores write the kept fields with the record, the async writer included', async () => {
+ const kv = new MemoryPersistentStore();
+ new SyncQueryStore(kv).saveEntity(7, { id: 'a', name: 'n' }, undefined, '"extra":{"x":1}');
+ expect(JSON.parse(kv.getString(valueKeyFor(7))!)).toEqual({ id: 'a', name: 'n', extra: { x: 1 } });
+
+ const delegate = new Map();
+ const asyncDelegate = {
+ has: async (k: string) => delegate.has(k),
+ getString: async (k: string) => delegate.get(k) as string | undefined,
+ setString: async (k: string, v: string) => void delegate.set(k, v),
+ getNumber: async (k: string) => delegate.get(k) as number | undefined,
+ setNumber: async (k: string, v: number) => void delegate.set(k, v),
+ getBuffer: async (k: string) => delegate.get(k) as Uint32Array | undefined,
+ setBuffer: async (k: string, v: Uint32Array) => void delegate.set(k, v),
+ delete: async (k: string) => void delegate.delete(k),
+ getAllKeys: async () => [...delegate.keys()],
+ };
+ let toWriter: ((msg: StoreMessage) => void) | undefined;
+ const writer = new AsyncQueryStore({
+ isWriter: true,
+ delegate: asyncDelegate,
+ connect: handleMessage => {
+ toWriter = handleMessage;
+ return { sendMessage: () => {} };
+ },
+ });
+ const reader = new AsyncQueryStore({
+ isWriter: false,
+ connect: () => ({ sendMessage: msg => toWriter!(msg) }),
+ });
+ reader.saveEntity(8, { id: 'b', name: 'n' }, new Set([9]), '"extra":{"__entityRef":9}');
+ for (let i = 0; i < 50 && !writer.isSettled(); i++) await sleep(1);
+ await sleep(1);
+ expect(JSON.parse(delegate.get(valueKeyFor(8)) as string)).toEqual({
+ id: 'b',
+ name: 'n',
+ extra: { __entityRef: 9 },
+ });
+ });
+});
+
+class Part extends Entity {
+ __typename = t.typename('Part');
+ id = t.id;
+ name = t.string;
+}
+class GizmoRow extends Entity {
+ __typename = t.typename('Gizmo');
+ id = t.id;
+ name = t.string;
+ maker = t.entity(Part);
+}
+class GizmoDetail extends Entity {
+ __typename = t.typename('Gizmo');
+ id = t.id;
+ name = t.string;
+ maker = t.entity(Part);
+ parts = t.array(t.entity(Part));
+}
+class GetGizmos extends RESTQuery {
+ path = '/gizmos';
+ result = { items: t.array(t.entity(GizmoRow)) };
+}
+class GetGizmo extends RESTQuery {
+ path = '/gizmo';
+ result = { item: t.entity(GizmoDetail) };
+}
+const part = (id: string) => ({ __typename: 'Part', id, name: id });
+const row = (name: string) => ({ __typename: 'Gizmo', id: 'g1', name, maker: part('m1') });
+const partKey = (id: string) => hashValue(['Part', id]);
+
+describe('F. references and records the other class holds', () => {
+ async function listThenDetail(kv: MemoryPersistentStore, f: ReturnType) {
+ const c = makeClient(kv, f);
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I' }] });
+ const list: any = start(c, () => fetchQuery(GetItems));
+ await list;
+ f.set('/item', itemDetail('v1'));
+ const item: any = start(c, () => fetchQuery(GetItem));
+ await item;
+ return { c, list, item };
+ }
+
+ it('F1 a summary refetch over an entity the detail holds keeps the detail child referenced, on disk and after a restart', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ const { c, list, item } = await listThenDetail(kv, f);
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] });
+ await list.value.__refetch();
+
+ expect(item.value.item.vendor.name).toBe('v1');
+ expect(recordOf(kv, 'Item', 'i1')).toEqual({
+ __typename: 'Item',
+ id: 'i1',
+ name: 'I2',
+ details: { rating: 5 },
+ vendor: { __entityRef: vendorKey('v1') },
+ });
+ expect(Array.from(kv.getBuffer(refIdsKeyFor(hashValue(['Item', 'i1']))) ?? [])).toEqual([vendorKey('v1')]);
+ expect(recordOf(kv, 'Vendor', 'v1')).toEqual({ __typename: 'Vendor', id: 'v1', name: 'v1' });
+ c.destroy();
+ clients = clients.filter(x => x !== c);
+
+ f.set('/item', itemDetail('v1', 6, 'net'), 100);
+ const cold = makeClient(kv, f);
+ const served: any = start(cold, () => fetchQuery(GetItem));
+ await sleep(20);
+ expect(served.isReady).toBe(true);
+ expect(served.value.item.vendor.name).toBe('v1');
+ });
+
+ it('F2 after a summary refetch, an update of the detail child still reaches the detail', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ const { c, list, item } = await listThenDetail(kv, f);
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] });
+ await list.value.__refetch();
+
+ c.applyMutationEvent({ type: 'update', typename: 'Vendor', data: { __typename: 'Vendor', id: 'v1', name: 'w1' } });
+ await sleep(0);
+ expect(item.value.item.vendor.name).toBe('w1');
+ expect(recordOf(kv, 'Vendor', 'v1')).toMatchObject({ name: 'w1' });
+ });
+
+ it('F3 a summary fetched over records without detail fields reads each record once and writes the fetched fields', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ f.set('/item', itemDetail('v1'));
+ f.set('/items', {
+ items: [
+ { __typename: 'Item', id: 'i2', name: 'J' },
+ { __typename: 'Item', id: 'i3', name: 'K' },
+ ],
+ });
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetItem));
+ await start(c, () => fetchQuery(GetItems));
+ });
+
+ const store = new CountingStore(kv);
+ const c = makeCountingClient(store, f);
+ await start(c, () => fetchQuery(GetItem));
+ f.set('/other-items', {
+ items: [
+ { __typename: 'Item', id: 'i1', name: 'I2' },
+ { __typename: 'Item', id: 'i2', name: 'J2' },
+ { __typename: 'Item', id: 'i3', name: 'K2' },
+ ],
+ });
+ const before = { reads: store.reads, parsed: store.parsed };
+ class GetOtherItems extends RESTQuery {
+ path = '/other-items';
+ result = { items: t.array(t.entity(ItemSummary)) };
+ }
+ await start(c, () => fetchQuery(GetOtherItems));
+ expect({ reads: store.reads - before.reads, parsed: store.parsed - before.parsed }).toEqual({
+ reads: 2,
+ parsed: 2,
+ });
+ expect(recordOf(kv, 'Item', 'i2')).toEqual({ __typename: 'Item', id: 'i2', name: 'J2' });
+ expect(recordOf(kv, 'Item', 'i1')).toMatchObject({ name: 'I2', details: { rating: 5 } });
+ });
+
+ it('F6 after a restart, a detail class registered after a summary refetch keeps its children referenced through the next one', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ f.set('/gizmo', { item: { ...row('G'), parts: [part('p1'), part('p2')] } });
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetGizmo));
+ });
+
+ // The detail registers after the summary refetch and adds no new field name.
+ const c = makeClient(kv, f);
+ f.set('/gizmos', { items: [row('A')] });
+ const list: any = start(c, () => fetchQuery(GetGizmos));
+ await list;
+ f.set('/gizmos', { items: [row('B')] });
+ await list.value.__refetch();
+ const item: any = start(c, () => fetchQuery(GetGizmo));
+ await item;
+ await sleep(10);
+ f.set('/gizmos', { items: [row('C')] });
+ await list.value.__refetch();
+
+ expect(item.value.item.parts.map((p: any) => p.name)).toEqual(['p1', 'p2']);
+ expect(new Set(kv.getBuffer(refIdsKeyFor(hashValue(['Gizmo', 'g1']))) ?? [])).toEqual(
+ new Set([partKey('m1'), partKey('p1'), partKey('p2')]),
+ );
+ c.applyMutationEvent({ type: 'update', typename: 'Part', data: { ...part('p1'), name: 'q1' } });
+ await sleep(0);
+ expect(item.value.item.parts[0].name).toBe('q1');
+ });
+
+ it('F7 a summary refetch that changes nothing still drops the reference a streamed update replaced', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ const c = makeClient(kv, f);
+ f.set('/gizmos', { items: [row('A')] });
+ const list: any = start(c, () => fetchQuery(GetGizmos));
+ await list;
+ f.set('/gizmo', { item: { ...row('A'), parts: [part('p1')] } });
+ const item: any = start(c, () => fetchQuery(GetGizmo));
+ await item;
+ await list.value.__refetch();
+ c.applyMutationEvent({
+ type: 'update',
+ typename: 'Gizmo',
+ data: { __typename: 'Gizmo', id: 'g1', maker: part('m2') },
+ });
+ await sleep(0);
+ f.set('/gizmos', { items: [{ ...row('A'), maker: part('m2') }] });
+ await list.value.__refetch();
+
+ expect(list.value.items[0].maker.name).toBe('m2');
+ expect(new Set(kv.getBuffer(refIdsKeyFor(hashValue(['Gizmo', 'g1']))) ?? [])).toEqual(
+ new Set([partKey('m2'), partKey('p1')]),
+ );
+ expect(c.entityMap.getEntity(partKey('m1'))).toBeUndefined();
+ });
+
+ it('F4 a summary hydrated from its cache over a detail record reads the record again at its first write, not holding it until then', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ f.set('/item', itemDetail('v1'));
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I' }] });
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetItem));
+ await start(c, () => fetchQuery(GetItems));
+ });
+
+ const store = new CountingStore(kv);
+ const c = makeCountingClient(store, f);
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I' }] }, 1000);
+ const list: any = start(c, () => fetchQuery(GetItems));
+ await sleep(5);
+ expect(list.isReady).toBe(true);
+ const instance = (c as any).entityMap.getEntity(hashValue(['Item', 'i1']));
+ expect(instance._storedRecord).toBeUndefined();
+ expect(store.reads).toBe(0);
+
+ c.applyMutationEvent({ type: 'update', typename: 'Item', data: { __typename: 'Item', id: 'i1', name: 'U' } });
+ c.applyMutationEvent({ type: 'update', typename: 'Item', data: { __typename: 'Item', id: 'i1', name: 'V' } });
+ expect({ reads: store.reads, parsed: store.parsed }).toEqual({ reads: 1, parsed: 1 });
+ expect(recordOf(kv, 'Item', 'i1')).toEqual({
+ __typename: 'Item',
+ id: 'i1',
+ name: 'V',
+ details: { rating: 5 },
+ vendor: { __entityRef: vendorKey('v1') },
+ });
+ });
+
+ it('F5 in a store an earlier release wrote, a summary hydrated from its cache keeps the detail fields under a streamed update', async () => {
+ let kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ f.set('/item', itemDetail('v1'));
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I' }] });
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetItem));
+ await start(c, () => fetchQuery(GetItems));
+ });
+ // Strip the field names, as an earlier release left the store.
+ const copy = new MemoryPersistentStore();
+ for (const k of kv.getAllKeys()) {
+ if (k.startsWith('sq:meta:')) continue;
+ const v = kv.getString(k) ?? kv.getNumber(k) ?? kv.getBuffer(k);
+ if (typeof v === 'string') copy.setString(k, v);
+ else if (typeof v === 'number') copy.setNumber(k, v);
+ else if (v !== undefined) copy.setBuffer(k, v);
+ }
+ kv = copy;
+
+ const c = makeClient(kv, f);
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I' }] }, 1000);
+ const list: any = start(c, () => fetchQuery(GetItems));
+ await sleep(5);
+ expect(list.isReady).toBe(true);
+ c.applyMutationEvent({ type: 'update', typename: 'Item', data: { __typename: 'Item', id: 'i1', name: 'U' } });
+ expect(recordOf(kv, 'Item', 'i1')).toEqual({
+ __typename: 'Item',
+ id: 'i1',
+ name: 'U',
+ details: { rating: 5 },
+ vendor: { __entityRef: vendorKey('v1') },
+ });
+ });
+});
+
+describe('E. what the store remembers about the other class, and for how long', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('E1 a cache wiped with clear() does not get the other class fields back from memory', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ f.set('/item', itemDetail('v1'));
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetItem));
+ });
+
+ const store = new SyncQueryStore(kv);
+ const c = makeCountingClient(store, f);
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] });
+ const list: any = start(c, () => fetchQuery(GetItems));
+ await list;
+ expect(recordOf(kv, 'Item', 'i1')).toMatchObject({ details: { rating: 5 } });
+
+ store.clear();
+ expect(kv.getAllKeys().filter(k => k.startsWith('sq:doc:'))).toEqual([]);
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I3' }] });
+ await list.value.__refetch();
+
+ expect(list.value.items[0].name).toBe('I3');
+ expect(recordOf(kv, 'Item', 'i1')).toEqual({ __typename: 'Item', id: 'i1', name: 'I3' });
+ expect(kv.getBuffer(refIdsKeyFor(hashValue(['Item', 'i1'])))).toBeUndefined();
+ expect(kv.getNumber(refCountKeyFor(vendorKey('v1')))).toBeUndefined();
+ });
+
+ it('E2 a field name no class has declared for 30 days is forgotten', async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ const day = 24 * 60 * 60 * 1000;
+ const t0 = Date.now();
+ const now = vi.spyOn(Date, 'now');
+ now.mockReturnValue(t0);
+ f.set('/item', itemDetail('v1'));
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetItem));
+ });
+
+ const listSession = async (at: number): Promise => {
+ now.mockReturnValue(at);
+ const copy = new MemoryPersistentStore();
+ for (const k of kv.getAllKeys()) {
+ const v = kv.getString(k) ?? kv.getNumber(k) ?? kv.getBuffer(k);
+ if (typeof v === 'string') copy.setString(k, v);
+ else if (typeof v === 'number') copy.setNumber(k, v);
+ else if (v !== undefined) copy.setBuffer(k, v);
+ }
+ const store = new CountingStore(copy);
+ const c = makeCountingClient(store, f);
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] });
+ await start(c, () => fetchQuery(GetItems));
+ c.destroy();
+ return store.reads;
+ };
+
+ expect(await listSession(t0 + 29 * day)).toBe(1);
+ expect(await listSession(t0 + 31 * day)).toBe(0);
+ });
+
+ it('E3 a typename with one class: an event carrying every field writes the record whole, without a merge', async () => {
+ const kv = new MemoryPersistentStore();
+ // Otherwise the field names take 30 days to cover every record.
+ new SyncQueryStore(kv).clear();
+ const f = makeFetch();
+ f.set('/reading/latest', { reading: readingPayload });
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetLatestReading));
+ });
+ const store = new SyncQueryStore(kv);
+ const c = makeCountingClient(store, f);
+ f.set('/reading/page', { reading: { ...readingPayload, id: 'r2' }, label: 'x' });
+ await start(c, () => fetchQuery(GetReadingPage));
+ const merges = vi.spyOn(store, 'mergeEntity');
+ const saves = vi.spyOn(store, 'saveEntity');
+ c.applyMutationEvent({ type: 'update', typename: 'Reading', data: { ...readingPayload, value: 2 } });
+ expect(merges).not.toHaveBeenCalled();
+ expect(saves.mock.calls.map(call => call[0])).toEqual([hashValue(['Reading', 'r1'])]);
+ expect(recordOf(kv, 'Reading', 'r1')).toMatchObject({ value: 2 });
+ });
+
+ it('E5 records an earlier release wrote keep the other class fields under such events until the names cover them', async () => {
+ const day = 24 * 60 * 60 * 1000;
+ const t0 = Date.now();
+ const now = vi.spyOn(Date, 'now');
+ now.mockReturnValue(t0);
+ let kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ f.set('/item', itemDetail('v1'));
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetItem));
+ });
+ /** `stripMeta` mimics a store an earlier release left. */
+ const reopen = (stripMeta: boolean) => {
+ const copy = new MemoryPersistentStore();
+ for (const k of kv.getAllKeys()) {
+ if (stripMeta && k.startsWith('sq:meta:')) continue;
+ const v = kv.getString(k) ?? kv.getNumber(k) ?? kv.getBuffer(k);
+ if (typeof v === 'string') copy.setString(k, v);
+ else if (typeof v === 'number') copy.setNumber(k, v);
+ else if (v !== undefined) copy.setBuffer(k, v);
+ }
+ kv = copy;
+ };
+ const listSession = async (at: number, name: string) => {
+ now.mockReturnValue(at);
+ const store = new SyncQueryStore(kv);
+ const c = makeCountingClient(store, f);
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i2', name: 'J' }] });
+ await start(c, () => fetchQuery(GetItems));
+ const merges = vi.spyOn(store, 'mergeEntity');
+ const saves = vi.spyOn(store, 'saveEntity');
+ c.applyMutationEvent({ type: 'update', typename: 'Item', data: { __typename: 'Item', id: 'i1', name } });
+ c.destroy();
+ clients = clients.filter(x => x !== c);
+ return {
+ merged: merges.mock.calls.length,
+ savedWhole: saves.mock.calls.some(call => call[0] === hashValue(['Item', 'i1'])),
+ };
+ };
+ const kept = { details: { rating: 5 }, vendor: { __entityRef: vendorKey('v1') } };
+
+ const hour = 60 * 60 * 1000;
+ reopen(true);
+ expect(await listSession(t0 + hour, 'U1')).toEqual({ merged: 1, savedWhole: false });
+ expect(recordOf(kv, 'Item', 'i1')).toEqual({ __typename: 'Item', id: 'i1', name: 'U1', ...kept });
+ reopen(false);
+ expect(await listSession(t0 + 2 * hour, 'U2')).toEqual({ merged: 1, savedWhole: false });
+ expect(recordOf(kv, 'Item', 'i1')).toEqual({ __typename: 'Item', id: 'i1', name: 'U2', ...kept });
+ reopen(false);
+ kv.setNumber('sq:meta:fieldsSince', t0 + 3 * hour - 30 * day);
+ expect(await listSession(t0 + 3 * hour, 'U3')).toEqual({ merged: 0, savedWhole: true });
+
+ const fresh = new MemoryPersistentStore();
+ const scans = vi.spyOn(fresh, 'getAllKeys');
+ expect(new SyncQueryStore(fresh).entityFieldNamesComplete()).toBe(false);
+ expect(scans).not.toHaveBeenCalled();
+ const upgraded = new MemoryPersistentStore();
+ upgraded.setString('sq:doc:value:1', '{}');
+ const upgradedScans = vi.spyOn(upgraded, 'getAllKeys');
+ expect(new SyncQueryStore(upgraded).entityFieldNamesComplete()).toBe(false);
+ expect(upgradedScans).not.toHaveBeenCalled();
+ reopen(true);
+ const store = new SyncQueryStore(kv);
+ expect(store.entityFieldNamesComplete()).toBe(false);
+ store.clear();
+ expect(store.entityFieldNamesComplete()).toBe(true);
+ reopen(false);
+ expect(new SyncQueryStore(kv).entityFieldNamesComplete()).toBe(true);
+ });
+
+ it('E4 a store that does not remember field names merges such an event, keeping fields of a class it has not seen', async () => {
+ class NoFieldNamesStore extends SyncQueryStore {}
+ (NoFieldNamesStore.prototype as any).getEntityFieldNames = undefined;
+ (NoFieldNamesStore.prototype as any).addEntityFieldNames = undefined;
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ f.set('/item', itemDetail('v1'));
+ await session(kv, f, async c => {
+ await start(c, () => fetchQuery(GetItem));
+ });
+ const c = makeCountingClient(new NoFieldNamesStore(kv), f);
+ f.set('/items', { items: [{ __typename: 'Item', id: 'i2', name: 'J' }] });
+ await start(c, () => fetchQuery(GetItems));
+ c.applyMutationEvent({ type: 'update', typename: 'Item', data: { __typename: 'Item', id: 'i1', name: 'I2' } });
+ expect(recordOf(kv, 'Item', 'i1')).toEqual({
+ __typename: 'Item',
+ id: 'i1',
+ name: 'I2',
+ details: { rating: 5 },
+ vendor: { __entityRef: vendorKey('v1') },
+ });
+ });
+});
diff --git a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts
index ce9c86f..a127524 100644
--- a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts
+++ b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts
@@ -120,47 +120,70 @@ describe('hydration of a cached query whose entity is already in memory', () =>
}
});
- it('rejects the cached query when the in-memory entity lacks a field the query requires', async () => {
- class Narrow extends Entity {
- __typename = t.typename('Doc');
- id = t.id;
- title = t.string;
- }
- class Wide extends Entity {
- __typename = t.typename('Doc');
- id = t.id;
- title = t.string;
- body = t.string;
- }
- class GetNarrow extends RESTQuery {
- path = '/narrow';
- result = { doc: t.entity(Narrow) };
- }
- class GetWide extends RESTQuery {
- path = '/wide';
- result = { doc: t.entity(Wide) };
- config = { staleTime: 60_000 };
- }
- const kv = new MemoryPersistentStore();
- const store = new SyncQueryStore(kv);
- const mockFetch = createMockFetch();
- mockFetch.get('/wide', { doc: { __typename: 'Doc', id: 1, title: 'T', body: 'B' } });
- mockFetch.get('/narrow', { doc: { __typename: 'Doc', id: 1, title: 'T' } });
-
- const { client: first } = makeClient(store, mockFetch);
- await holdQuery(first, () => fetchQuery(GetWide));
- first.destroy();
-
- // The narrow query fetches fresh, so Doc:1 is in memory without `body`.
- const { client, warn } = makeClient(store, mockFetch);
- await holdQuery(client, () => fetchQuery(GetNarrow));
- mockFetch.get('/wide', { doc: { __typename: 'Doc', id: 1, title: 'T', body: 'B2' } });
- const wide = holdQuery(client, () => fetchQuery(GetWide));
- await wide;
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('query cache may be corrupted'), expect.anything());
- expect((wide.value as any).doc.body).toBe('B2');
- client.destroy();
- });
+ for (const absent of [false, true]) {
+ it(
+ absent
+ ? 'rejects the cached query when the in-memory entity holds a field the query requires as absent'
+ : 'serves the cached query when the in-memory entity lacks a field only another class declares',
+ async () => {
+ class Narrow extends Entity {
+ __typename = t.typename('Doc');
+ id = t.id;
+ title = t.string;
+ }
+ // A response without the optional `body` is fresher than the cache.
+ class NarrowWithBody extends Entity {
+ __typename = t.typename('Doc');
+ id = t.id;
+ title = t.string;
+ body = t.optional(t.object({ text: t.string }));
+ }
+ class Wide extends Entity {
+ __typename = t.typename('Doc');
+ id = t.id;
+ title = t.string;
+ body = t.object({ text: t.string });
+ }
+ class GetNarrow extends RESTQuery {
+ path = '/narrow';
+ result = { doc: t.entity(absent ? NarrowWithBody : Narrow) };
+ }
+ class GetWide extends RESTQuery {
+ path = '/wide';
+ result = { doc: t.entity(Wide) };
+ config = { staleTime: 60_000 };
+ }
+ const kv = new MemoryPersistentStore();
+ const store = new SyncQueryStore(kv);
+ const mockFetch = createMockFetch();
+ mockFetch.get('/wide', { doc: { __typename: 'Doc', id: 1, title: 'T', body: { text: 'B' } } });
+ mockFetch.get('/narrow', { doc: { __typename: 'Doc', id: 1, title: 'T2' } });
+
+ const { client: first } = makeClient(store, mockFetch);
+ await holdQuery(first, () => fetchQuery(GetWide));
+ first.destroy();
+
+ // The narrow query fetches fresh, so Doc:1 is in memory without `body`.
+ const { client, warn } = makeClient(store, mockFetch);
+ await holdQuery(client, () => fetchQuery(GetNarrow));
+ const docKey = hashValue(['Doc', 1]);
+ mockFetch.get('/wide', { doc: { __typename: 'Doc', id: 1, title: 'T3', body: { text: 'B2' } } });
+ const wide = holdQuery(client, () => fetchQuery(GetWide));
+ await wide;
+ if (absent) {
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('query cache may be corrupted'), expect.anything());
+ expect((wide.value as any).doc.body.text).toBe('B2');
+ } else {
+ expect(warn).not.toHaveBeenCalled();
+ expect(mockFetch.calls.filter(c => c.url.endsWith('/wide'))).toHaveLength(1);
+ expect((wide.value as any).doc.title).toBe('T2');
+ expect((wide.value as any).doc.body.text).toBe('B');
+ expect(getDoc(kv, docKey)).toEqual({ __typename: 'Doc', id: 1, title: 'T2', body: { text: 'B' } });
+ }
+ client.destroy();
+ },
+ );
+ }
it('rejects the cached query when the in-memory entity lacks a nested entity the query requires', async () => {
class Org extends Entity {
@@ -217,52 +240,70 @@ describe('hydration of a cached query whose entity is already in memory', () =>
client.destroy();
});
- it('a cached list holding an in-memory entity the query cannot use is refetched whole, not served with a hole', async () => {
- class Narrow extends Entity {
- __typename = t.typename('Doc');
- id = t.id;
- title = t.string;
- }
- class Wide extends Entity {
- __typename = t.typename('Doc');
- id = t.id;
- title = t.string;
- body = t.string;
- }
- class GetNarrow extends RESTQuery {
- params = { id: t.id };
- path = `/narrow/${this.params.id}`;
- result = { doc: t.entity(Narrow) };
- }
- class GetDocs extends RESTQuery {
- path = '/docs';
- result = { docs: t.array(t.entity(Wide)) };
- config = { staleTime: 60_000 };
- }
- const kv = new MemoryPersistentStore();
- const store = new SyncQueryStore(kv);
- const mockFetch = createMockFetch();
- const docs = [
- { __typename: 'Doc', id: 1, title: 'T1', body: 'B1' },
- { __typename: 'Doc', id: 2, title: 'T2', body: 'B2' },
- ];
- mockFetch.get('/docs', { docs });
- mockFetch.get('/narrow/1', { doc: { __typename: 'Doc', id: 1, title: 'T1' } });
-
- const { client: first } = makeClient(store, mockFetch);
- await holdQuery(first, () => fetchQuery(GetDocs));
- first.destroy();
-
- // Doc:1 comes into memory through the narrow query, without `body`.
- const { client, warn } = makeClient(store, mockFetch);
- await holdQuery(client, () => fetchQuery(GetNarrow, { id: 1 }));
- mockFetch.get('/docs', { docs: docs.map(d => ({ ...d, body: `${d.body}!` })) });
- const list = holdQuery(client, () => fetchQuery(GetDocs));
- await list;
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('query cache may be corrupted'), expect.anything());
- expect((list.value as any).docs.map((d: any) => d.body)).toEqual(['B1!', 'B2!']);
- client.destroy();
- });
+ for (const absent of [false, true]) {
+ it(
+ absent
+ ? 'a cached list holding an in-memory entity the query cannot use is refetched whole, not served with a hole'
+ : 'a cached list holding an in-memory entity that lacks a field only another class declares is served whole',
+ async () => {
+ class Narrow extends Entity {
+ __typename = t.typename('Doc');
+ id = t.id;
+ title = t.string;
+ }
+ class NarrowWithBody extends Entity {
+ __typename = t.typename('Doc');
+ id = t.id;
+ title = t.string;
+ body = t.optional(t.object({ text: t.string }));
+ }
+ class Wide extends Entity {
+ __typename = t.typename('Doc');
+ id = t.id;
+ title = t.string;
+ body = t.object({ text: t.string });
+ }
+ class GetNarrow extends RESTQuery {
+ params = { id: t.id };
+ path = `/narrow/${this.params.id}`;
+ result = { doc: t.entity(absent ? NarrowWithBody : Narrow) };
+ }
+ class GetDocs extends RESTQuery {
+ path = '/docs';
+ result = { docs: t.array(t.entity(Wide)) };
+ config = { staleTime: 60_000 };
+ }
+ const kv = new MemoryPersistentStore();
+ const store = new SyncQueryStore(kv);
+ const mockFetch = createMockFetch();
+ const docs = [
+ { __typename: 'Doc', id: 1, title: 'T1', body: { text: 'B1' } },
+ { __typename: 'Doc', id: 2, title: 'T2', body: { text: 'B2' } },
+ ];
+ mockFetch.get('/docs', { docs });
+ mockFetch.get('/narrow/1', { doc: { __typename: 'Doc', id: 1, title: 'T1' } });
+
+ const { client: first } = makeClient(store, mockFetch);
+ await holdQuery(first, () => fetchQuery(GetDocs));
+ first.destroy();
+
+ // Doc:1 comes into memory through the narrow query, without `body`.
+ const { client, warn } = makeClient(store, mockFetch);
+ await holdQuery(client, () => fetchQuery(GetNarrow, { id: 1 }));
+ mockFetch.get('/docs', { docs: docs.map(d => ({ ...d, body: { text: `${d.body.text}!` } })) });
+ const list = holdQuery(client, () => fetchQuery(GetDocs));
+ await list;
+ if (absent) {
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('query cache may be corrupted'), expect.anything());
+ expect((list.value as any).docs.map((d: any) => d.body.text)).toEqual(['B1!', 'B2!']);
+ } else {
+ expect(warn).not.toHaveBeenCalled();
+ expect((list.value as any).docs.map((d: any) => d.body.text)).toEqual(['B1', 'B2']);
+ }
+ client.destroy();
+ },
+ );
+ }
it('a member the query narrows out anyway does not reject the cached query', async () => {
class UserPreview extends Entity {
diff --git a/packages/fetchium/src/applyEntities.ts b/packages/fetchium/src/applyEntities.ts
index 88d7ae4..c269988 100644
--- a/packages/fetchium/src/applyEntities.ts
+++ b/packages/fetchium/src/applyEntities.ts
@@ -11,7 +11,7 @@ import type { QueryClient } from './QueryClient.js';
import type { EntityInstance } from './EntityInstance.js';
import type { ParseContext, ParsedEntity } from './parseEntities.js';
import { FormattedValue, ValidatorDef } from './typeDefs.js';
-import { Mask } from './types.js';
+import { type EntityDef, Mask } from './types.js';
import { createLiveCollection, LiveCollectionBinding } from './LiveCollection.js';
import { PROXY_ID } from './proxyId.js';
import { dropNestedWrapper } from './nestedNotifiers.js';
@@ -127,7 +127,7 @@ function applyEntity(
appendMode: boolean,
created: Set | undefined,
): Record {
- const { key, data, shape: entityShape, rawKeys, eventKeys, fillsPartial } = entity;
+ const { key, data, shape: entityShape, rawKeys, eventKeys, fillsPartial, record } = entity;
const shapeFields = entityShape.shape;
// Already applied from another slot: re-applying would release every child ref.
@@ -147,7 +147,7 @@ function applyEntity(
isUpdate && rawKeys !== undefined && entityInstance.entityRefs !== undefined
? new Map(entityInstance.entityRefs)
: new Map();
-
+ // A refetch or poll that returns identical data neither notifies nor writes.
let changed = true;
// A write reaching this instance from inside its own subtree is deferred.
entityInstance._applying = true;
@@ -168,11 +168,18 @@ function applyEntity(
created,
);
if ((persist === true && rawKeys === undefined) || fillsPartial) {
+ // A full payload (a fetch), or the record's fields merged in, makes
+ // the data a complete record again.
+ if (entityInstance._partial && !fillsPartial) checkStoredRecord(entityInstance, entityShape, queryClient);
entityInstance._partial = false;
entityInstance._partialKeys = undefined;
} else if (entityInstance._partial && rawKeys !== undefined) {
- // Stays partial: another class sharing the typename may hold undeclared fields.
- for (const k of rawKeys) entityInstance._partialKeys!.add(k);
+ const partialKeys = entityInstance._partialKeys!;
+ for (const k of rawKeys) partialKeys.add(k);
+ if (eventsBuiltWholeRecord(partialKeys, entityShape, queryClient)) {
+ entityInstance._partial = false;
+ entityInstance._partialKeys = undefined;
+ }
}
} else {
initFields(shapeFields, data, entityInstance, data, seen, queryClient, persist, childRefs, appendMode, created);
@@ -181,15 +188,43 @@ function applyEntity(
entityInstance._partialKeys = new Set(eventKeys);
if (entityShape.typenameField !== undefined) entityInstance._partialKeys.add(entityShape.typenameField);
if (typeof entityShape.idField === 'string') entityInstance._partialKeys.add(entityShape.idField);
+ if (eventsBuiltWholeRecord(entityInstance._partialKeys, entityShape, queryClient)) {
+ entityInstance._partial = false;
+ entityInstance._partialKeys = undefined;
+ }
} else if (persist === false) {
entityInstance._recorded = true;
+ } else if (persist === true && record === undefined) {
+ checkStoredRecord(entityInstance, entityShape, queryClient);
}
if (persist === 'existing') created?.add(entityInstance);
}
} finally {
entityInstance._applying = false;
}
+ // Fields only another class declares stay in the data. Count their refs or
+ // they get released while that class's consumers still show them.
+ const heldRefs = entityInstance.entityRefs;
+ let keepsHeldRefs = false;
+ if (
+ isUpdate &&
+ rawKeys === undefined &&
+ heldRefs !== undefined &&
+ queryClient.hasForeignFieldDefs &&
+ queryClient.mayMissForeignFields(entityShape as unknown as ValidatorDef)
+ ) {
+ if (!changed && !appendMode && entityInstance._refsCounted) {
+ keepsHeldRefs = true;
+ } else {
+ const fields = queryClient.foreignRefFields(entityShape as unknown as ValidatorDef);
+ for (let i = 0; i < fields.length; i++) countHeldRefs(existingData[fields[i]], heldRefs, childRefs, queryClient);
+ }
+ }
+ entityInstance._refsCounted = rawKeys === undefined && !appendMode;
if (isUpdate && changed) entityInstance.notify();
+ if (record !== undefined) {
+ entityInstance.recordHoldsOtherFields(record, queryClient.entityMap.readEntity !== undefined);
+ }
if (appendMode && entityInstance.liveCollections.length > 0) {
for (const binding of entityInstance.liveCollections) {
@@ -207,7 +242,7 @@ function applyEntity(
}
}
- const newRefs = childRefs.size > 0 ? childRefs : undefined;
+ const newRefs = keepsHeldRefs ? heldRefs : childRefs.size > 0 ? childRefs : undefined;
const refsChanged = !sameRefs(entityInstance.entityRefs, newRefs);
// An entity hydrated from the store was never written, so there is no write to skip.
// A queued store deletion could drop the record this apply trusts.
@@ -227,6 +262,54 @@ function applyEntity(
return proxy;
}
+function countHeldRefs(
+ value: unknown,
+ held: Map,
+ childRefs: Map,
+ queryClient: QueryClient,
+): void {
+ if (typeof value !== 'object' || value === null || value instanceof FormattedValue) return;
+ const key = PROXY_ID.get(value);
+ if (key !== undefined) {
+ const child = queryClient.entityMap.getEntity(key);
+ if (child !== undefined && held.has(child)) childRefs.set(child, (childRefs.get(child) ?? 0) + 1);
+ return;
+ }
+ if (value instanceof LiveCollectionBinding) {
+ countHeldRefs(value.instance.getRawValue(), held, childRefs, queryClient);
+ } else if (Array.isArray(value)) {
+ for (let i = 0; i < value.length; i++) countHeldRefs(value[i], held, childRefs, queryClient);
+ } else if (Object.getPrototypeOf(value) === ObjectProto) {
+ const obj = value as Record;
+ for (const k of Object.keys(obj)) countHeldRefs(obj[k], held, childRefs, queryClient);
+ }
+}
+
+/** The fetch replaces the whole record, so the first write must read it to keep other classes' fields. */
+function checkStoredRecord(instance: EntityInstance, shape: EntityDef, queryClient: QueryClient): void {
+ if (
+ queryClient.hasForeignFieldDefs &&
+ !instance.keepsRecordFields() &&
+ queryClient.mayMissForeignFields(shape as unknown as ValidatorDef)
+ ) {
+ instance._checkStoredRecord = true;
+ }
+}
+
+/**
+ * Whether streamed fields make a whole record that can be written instead of
+ * merged. Without `storeKnowsTypenameFields` an unregistered class may own
+ * fields in the record, so it stays partial.
+ */
+function eventsBuiltWholeRecord(keys: Set, entityShape: EntityDef, queryClient: QueryClient): boolean {
+ for (const k in entityShape.shape) if (!keys.has(k)) return false;
+ if (!queryClient.storeKnowsTypenameFields) return false;
+ return (
+ !queryClient.hasForeignFieldDefs ||
+ !queryClient.mayMissForeignFields(entityShape as unknown as ValidatorDef)
+ );
+}
+
// ======================================================
// Field merge (update path) — reify + merge in one loop
// ======================================================
diff --git a/packages/fetchium/src/parseEntities.ts b/packages/fetchium/src/parseEntities.ts
index 5dbec38..6a5597e 100644
--- a/packages/fetchium/src/parseEntities.ts
+++ b/packages/fetchium/src/parseEntities.ts
@@ -54,6 +54,8 @@ export interface ParsedEntity {
eventKeys: Set | undefined;
/** A cached record filling in fields an entity built from events lacks. */
fillsPartial: boolean;
+ /** Hydration only: the raw record, when it holds fields another class wrote. */
+ record?: Record;
}
/** `'pending'` while a check is in progress, so a cycle counts as satisfied. */
@@ -497,6 +499,8 @@ function parseEntityData(
parsedData[entityShape.idField] = id;
}
+ let record: Record | undefined;
+
if (preloadedEntities !== undefined) {
const existing = queryClient.entityMap.getEntity(key);
@@ -507,31 +511,33 @@ function parseEntityData(
// Built from events: parse only the fields the record adds.
fillKeys = existing._partialKeys;
obj = preloaded;
- } else if (existing !== undefined) {
- // A live entity is newer than the cache, and re-parsing parsed values
- // corrupts them. Merge nothing, but check it satisfies this shape.
- ctx.trusted ??= new Map();
- if (!dataSatisfiesDef(existing.data, entityShape as unknown as ValidatorDef, queryClient, ctx.trusted)) {
- throw new CachedEntityMismatchError(
- `Cached entity ${entityShape.typenameValue}:${String(existing.id)} in memory does not satisfy the query's shape`,
- );
+ // Rare path, so check every record: older stores may not know the other class.
+ const shape = entityShape.shape;
+ for (const k in preloaded) {
+ if (!(k in shape)) {
+ record = preloaded;
+ break;
+ }
}
- const entry: ParsedEntity = {
- key,
- shape: entityShape,
- data: parsedData,
- rawKeys: new Set(),
- eventKeys: undefined,
- fillsPartial: false,
- };
- ctx.seen!.set(parsedData, entry);
- ctx.seenByKey!.set(key, entry);
- return parsedData;
+ } else if (existing !== undefined) {
+ return hydrateFromMemory(existing, preloaded, key, id, parsedData, entityShape, ctx);
} else {
if (preloaded === undefined) {
throw new Error(`Cached entity ${key} not found in preloaded map`);
}
obj = preloaded;
+ if (
+ !queryClient.storeKnowsTypenameFields ||
+ queryClient.mayMissForeignFields(entityShape as unknown as ValidatorDef)
+ ) {
+ const shape = entityShape.shape;
+ for (const k in preloaded) {
+ if (!(k in shape)) {
+ record = preloaded;
+ break;
+ }
+ }
+ }
}
if (fillKeys !== undefined) {
@@ -546,6 +552,7 @@ function parseEntityData(
eventKeys: undefined,
fillsPartial: true,
};
+ if (record !== undefined) entry.record = record;
ctx.seen!.set(parsedData, entry);
ctx.seenByKey!.set(key, entry);
const entityDesc = `[[${entityShape.typenameValue}:${id}]]`;
@@ -575,6 +582,7 @@ function parseEntityData(
eventKeys,
fillsPartial: false,
};
+ if (record !== undefined) entry.record = record;
ctx.seen!.set(parsedData, entry);
ctx.seenByKey!.set(key, entry);
@@ -592,6 +600,76 @@ function parseEntityData(
return parsedData;
}
+// ======================================================
+// Hydrating an entity that is already in memory
+// ======================================================
+
+/**
+ * Memory is at least as fresh as the cache, so it is never overwritten from it.
+ * Re-parsing would also corrupt already-parsed values. Only fields no applied
+ * class declares are filled from the record.
+ */
+function hydrateFromMemory(
+ existing: EntityInstance,
+ preloaded: Record | undefined,
+ key: number,
+ id: string | number,
+ parsedData: Record,
+ entityShape: EntityDef,
+ ctx: ParseContext,
+): Record {
+ const queryClient = ctx.queryClient!;
+ const shape = entityShape.shape as Record;
+ const typenameField = entityShape.typenameField;
+ const data = existing.data;
+ const trusted = (ctx.trusted ??= new Map());
+ let fillKeys: Set | undefined;
+
+ for (const fieldKey of Object.keys(shape)) {
+ if (fieldKey === typenameField) continue;
+ const value = data[fieldKey];
+ if (
+ value === undefined &&
+ preloaded !== undefined &&
+ preloaded[fieldKey] !== undefined &&
+ !Object.hasOwn(data, fieldKey)
+ ) {
+ (fillKeys ??= new Set()).add(fieldKey);
+ continue;
+ }
+ if (valueSatisfiesDef(value, shape[fieldKey], queryClient, trusted)) continue;
+ throw new CachedEntityMismatchError(
+ `Cached entity ${entityShape.typenameValue}:${String(existing.id)} in memory does not satisfy the query's shape at ${fieldKey}`,
+ );
+ }
+
+ // An empty key set merges nothing and hands the query the existing proxy.
+ const entry: ParsedEntity = {
+ key,
+ shape: entityShape,
+ data: parsedData,
+ rawKeys: fillKeys ?? new Set(),
+ eventKeys: undefined,
+ fillsPartial: false,
+ };
+ if (fillKeys !== undefined) entry.record = preloaded;
+ ctx.seen!.set(parsedData, entry);
+ ctx.seenByKey!.set(key, entry);
+
+ if (fillKeys !== undefined) {
+ const entityDesc = `[[${entityShape.typenameValue}:${id}]]`;
+ for (const fieldKey of fillKeys) {
+ parsedData[fieldKey] = parseData(
+ preloaded![fieldKey],
+ shape[fieldKey] as unknown as TypeDef,
+ ctx,
+ `${entityDesc}.${fieldKey}`,
+ );
+ }
+ }
+ return parsedData;
+}
+
// ======================================================
// entitySatisfiesShape
// ======================================================
diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts
index e3eec41..8c399b2 100644
--- a/packages/fetchium/src/query-types.ts
+++ b/packages/fetchium/src/query-types.ts
@@ -97,7 +97,14 @@ export interface QueryStore {
refIds?: Set,
): void;
- saveEntity(entityKey: number, value: unknown, refIds?: Set): void;
+ /**
+ * `rest`: JSON object body (`"a":1,"b":{…}`) of fields another class of the
+ * typename wrote. Write it ahead of `value`'s fields. `refIds` covers it.
+ *
+ * `ownedKeys`: sent instead of `rest` by a client without `readEntity`. The
+ * stored record's fields outside these keys are kept, and their references counted.
+ */
+ saveEntity(entityKey: number, value: unknown, refIds?: Set, rest?: string, ownedKeys?: string[]): void;
/**
* Writes the fields streamed events supplied, merged over the stored record
@@ -147,6 +154,28 @@ export interface QueryStore {
* merge unless this returns `false`. Without `mergeEntity`, only when `true`.
*/
hasEntity?(key: number): boolean | undefined;
+
+ /**
+ * Synchronous stores only: the parsed stored record, or `undefined`. Read
+ * before an entity's first write when its record may hold another class's fields.
+ */
+ readEntity?(entityKey: number): Record | undefined;
+
+ /**
+ * Top-level field names `typename`'s classes declared, as recorded by
+ * `addEntityFieldNames` in this or an earlier session.
+ */
+ getEntityFieldNames?(typename: string): readonly string[] | undefined;
+ /**
+ * Called once per entity class and client with its top-level fields. A store
+ * may forget names undeclared for a while, so removed fields stop being kept.
+ */
+ addEntityFieldNames?(typename: string, fields: readonly string[]): void;
+ /**
+ * Whether `getEntityFieldNames` covers every class that wrote a held record.
+ * Asked once at client creation. Only then are streamed entities written whole.
+ */
+ entityFieldNamesComplete?(): boolean;
}
export type MaybePromise = T | Promise;
diff --git a/packages/fetchium/src/react/__tests__/hydrate-in-memory-shape.test.tsx b/packages/fetchium/src/react/__tests__/hydrate-in-memory-shape.test.tsx
new file mode 100644
index 0000000..16e04c7
--- /dev/null
+++ b/packages/fetchium/src/react/__tests__/hydrate-in-memory-shape.test.tsx
@@ -0,0 +1,149 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { render } from 'vitest-browser-react';
+import { ContextProvider } from 'signalium/react';
+import { watcher, withContexts } from 'signalium';
+import React from 'react';
+import { MemoryPersistentStore, SyncQueryStore } from '../../stores/sync.js';
+import { QueryClient, QueryClientContext } from '../../QueryClient.js';
+import { t } from '../../typeDefs.js';
+import { Entity } from '../../proxy.js';
+import { RESTQuery, RESTQueryAdapter } from '../../rest/index.js';
+import { fetchQuery } from '../../query.js';
+import { sleep } from '../../__tests__/utils.js';
+import { useQuery } from '../index.js';
+
+// Sync store: a cached query sharing an entity with an in-memory query paints
+// its cached value on the first commit, not a loading frame.
+
+let clients: QueryClient[] = [];
+afterEach(() => {
+ for (const c of clients) c.destroy();
+ clients = [];
+});
+
+function makeFetch() {
+ const routes = new Map();
+ const fetch = (url: string) => {
+ const path = new URL(url).pathname;
+ const route = routes.get(path);
+ if (route === undefined) return Promise.reject(new Error(`no route for ${path}`));
+ const body = JSON.parse(JSON.stringify(route.body));
+ return new Promise(resolve =>
+ setTimeout(
+ () =>
+ resolve({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ headers: new Headers(),
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ }),
+ route.delay,
+ ),
+ );
+ };
+ return {
+ fetch,
+ set(path: string, body: unknown, delay = 0) {
+ routes.set(path, { body, delay });
+ },
+ };
+}
+
+function makeClient(kv: MemoryPersistentStore, f: ReturnType, warnings: string[] = []) {
+ const client = new QueryClient({
+ store: new SyncQueryStore(kv),
+ adapters: [new RESTQueryAdapter({ fetch: f.fetch as any, baseUrl: 'http://localhost' })],
+ log: { warn: (m: string) => warnings.push(m), error: (m: string) => warnings.push(m) },
+ } as any);
+ clients.push(client);
+ return client;
+}
+
+async function fetchIn(client: QueryClient, Q: new () => RESTQuery) {
+ await withContexts([[QueryClientContext, client]], () => {
+ const q = fetchQuery(Q as any);
+ const w = watcher(() => q.value);
+ w.addListener(() => {});
+ return q;
+ });
+}
+
+class ProductSummary extends Entity {
+ __typename = t.typename('RxProduct');
+ id = t.id;
+ name = t.string;
+}
+class ProductDetail extends Entity {
+ __typename = t.typename('RxProduct');
+ id = t.id;
+ name = t.string;
+ details = t.object({ rating: t.number });
+ updatedAt = t.format('date-time');
+}
+class GetProductList extends RESTQuery {
+ path = '/products';
+ result = { items: t.array(t.entity(ProductSummary)) };
+}
+class GetProductDetail extends RESTQuery {
+ path = '/product';
+ result = { product: t.entity(ProductDetail), label: t.string };
+}
+class GetProductStamp extends RESTQuery {
+ path = '/product-stamp';
+ result = { product: t.entity(ProductDetail) };
+}
+
+const product = (rating: number) => ({
+ __typename: 'RxProduct',
+ id: 'p1',
+ name: 'P',
+ details: { rating },
+ updatedAt: '2026-01-01T00:00:00.000Z',
+});
+
+describe('cached first paint over an in-memory entity', () => {
+ for (const scenario of ['nothing in memory', 'summary in memory', 'format field in memory'] as const) {
+ it(`first commit shows the cached detail (${scenario})`, async () => {
+ const kv = new MemoryPersistentStore();
+ const f = makeFetch();
+ f.set('/product', { product: product(5), label: 'cached' });
+ const seedClient = makeClient(kv, f);
+ await fetchIn(seedClient, GetProductDetail);
+ seedClient.destroy();
+ clients = [];
+
+ const warnings: string[] = [];
+ const client = makeClient(kv, f, warnings);
+ if (scenario === 'summary in memory') {
+ f.set('/products', { items: [{ __typename: 'RxProduct', id: 'p1', name: 'P' }] });
+ await fetchIn(client, GetProductList);
+ } else if (scenario === 'format field in memory') {
+ f.set('/product-stamp', { product: product(5) });
+ await fetchIn(client, GetProductStamp);
+ }
+ f.set('/product', { product: product(6), label: 'net' }, 150);
+
+ const renders: string[] = [];
+ function Detail(): React.ReactNode {
+ const q = useQuery(GetProductDetail);
+ const label = q.isReady
+ ? `${q.value.label}:${q.value.product.details.rating}:${q.value.product.updatedAt.getUTCFullYear()}`
+ : 'loading';
+ renders.push(label);
+ return {label}
;
+ }
+ const { getByText } = render(
+
+
+ ,
+ );
+ await expect.element(getByText('net:6:2026')).toBeInTheDocument();
+ await sleep(10);
+ expect(warnings).toEqual([]);
+ expect(renders[0]).toBe('cached:5:2026');
+ expect(renders).not.toContain('loading');
+ });
+ }
+});
diff --git a/packages/fetchium/src/stores/async.ts b/packages/fetchium/src/stores/async.ts
index 2c6518c..45988d4 100644
--- a/packages/fetchium/src/stores/async.ts
+++ b/packages/fetchium/src/stores/async.ts
@@ -9,6 +9,7 @@ import {
mergeStoredRecord,
notifyListeners,
queueKeyFor,
+ recordRestOutside,
refCountKeyFor,
refIdsKeyFor,
updatedAtKeyFor,
@@ -63,6 +64,10 @@ export type StoreMessage =
refIds?: number[];
/** `value` holds only some fields, merged over the stored record. */
merge?: boolean;
+ /** See `QueryStore.saveEntity`. */
+ rest?: string;
+ /** See `QueryStore.saveEntity`. Ignored by older writers. */
+ ownedKeys?: string[];
/** With `merge`: dropped, and reported deleted, if no record is stored. */
ifStored?: boolean;
}
@@ -99,6 +104,20 @@ function isStoreMessage(msg: unknown): msg is StoreMessage {
);
}
+/** The stored record's fields outside `ownedKeys`, or undefined if there are none. */
+function storedRecordOutside(stored: string, ownedKeys: string[]) {
+ let record: unknown;
+ try {
+ record = JSON.parse(stored);
+ } catch {
+ return undefined;
+ }
+ if (typeof record !== 'object' || record === null || Array.isArray(record)) return undefined;
+ const owned: Record = Object.create(null);
+ for (const key of ownedKeys) owned[key] = true;
+ return recordRestOutside(record as Record, owned);
+}
+
function subscribe(listeners: Array<(key: number) => void>, listener: (key: number) => void): () => void {
listeners.push(listener);
return () => {
@@ -322,7 +341,15 @@ export class AsyncQueryStore implements QueryStore {
break;
case StoreMessageType.SaveEntity:
if (
- await this.writerSaveEntity(msg.entityKey, msg.value, msg.refIds, msg.merge === true, msg.ifStored === true)
+ await this.writerSaveEntity(
+ msg.entityKey,
+ msg.value,
+ msg.refIds,
+ msg.merge === true,
+ msg.ifStored === true,
+ msg.rest,
+ msg.ownedKeys,
+ )
) {
notifyListeners(this.persistedListeners, msg.entityKey);
}
@@ -419,13 +446,16 @@ export class AsyncQueryStore implements QueryStore {
this.dispatch(message);
}
- saveEntity(entityKey: number, value: unknown, refIds?: Set): void {
- this.dispatch({
+ saveEntity(entityKey: number, value: unknown, refIds?: Set, rest?: string, ownedKeys?: string[]): void {
+ const message: Extract = {
type: StoreMessageType.SaveEntity,
entityKey,
value,
refIds: refIds ? Array.from(refIds) : undefined,
- });
+ };
+ if (rest !== undefined && rest !== '') message.rest = rest;
+ if (ownedKeys !== undefined) message.ownedKeys = ownedKeys;
+ this.dispatch(message);
}
mergeEntity(entityKey: number, fields: unknown, refIds?: Set, ifStored?: boolean): void {
@@ -482,6 +512,8 @@ export class AsyncQueryStore implements QueryStore {
refIds: number[] | undefined,
merge: boolean,
ifStored: boolean,
+ rest?: string,
+ ownedKeys?: string[],
): Promise {
if (merge) {
const stored = await this.delegate!.getString(valueKeyFor(entityKey));
@@ -496,7 +528,18 @@ export class AsyncQueryStore implements QueryStore {
return true;
}
}
- await this.setValue(entityKey, value, refIds ? new Set(refIds) : undefined);
+ const refs = refIds ? new Set(refIds) : undefined;
+ if (ownedKeys !== undefined) {
+ const stored = await this.delegate!.getString(valueKeyFor(entityKey));
+ const kept = stored !== undefined ? storedRecordOutside(stored, ownedKeys) : undefined;
+ if (kept !== undefined) {
+ const keptRefs = refs ?? new Set();
+ for (const id of kept.refIds) keptRefs.add(id);
+ await this.setValue(entityKey, value, keptRefs, kept.json);
+ return true;
+ }
+ }
+ await this.setValue(entityKey, value, refs, rest);
return true;
}
@@ -603,10 +646,12 @@ export class AsyncQueryStore implements QueryStore {
}
}
- private async setValue(id: number, value: unknown, refIds?: Set): Promise {
+ private async setValue(id: number, value: unknown, refIds?: Set, rest?: string): Promise {
const delegate = this.delegate!;
- await delegate.setString(valueKeyFor(id), JSON.stringify(value));
+ let json = JSON.stringify(value);
+ if (rest !== undefined && rest !== '') json = json.length === 2 ? `{${rest}}` : `{${rest},${json.slice(1)}`;
+ await delegate.setString(valueKeyFor(id), json);
this.noteHeld(id);
const refIdsKey = refIdsKeyFor(id);
diff --git a/packages/fetchium/src/stores/shared.ts b/packages/fetchium/src/stores/shared.ts
index afe188c..8b13291 100644
--- a/packages/fetchium/src/stores/shared.ts
+++ b/packages/fetchium/src/stores/shared.ts
@@ -1,3 +1,4 @@
+export const DOC_PREFIX = 'sq:doc:';
// Query Instance keys
export const VALUE_PREFIX = 'sq:doc:value:';
export const valueKeyFor = (id: number) => `${VALUE_PREFIX}${id}`;
@@ -11,6 +12,9 @@ export const lastUsedKeyFor = (queryDefId: string) => `sq:doc:lastUsed:${queryDe
export const cacheTimeKeyFor = (queryDefId: string) => `sq:doc:cacheTime:${queryDefId}`;
export const LAST_USED_PREFIX = 'sq:doc:lastUsed:';
+export const fieldNamesKeyFor = (typename: string) => `sq:meta:fields:${typename}`;
+// 0: field names have covered every record since the store was emptied
+export const FIELD_NAMES_SINCE_KEY = 'sq:meta:fieldsSince';
// Default values
export const DEFAULT_MAX_COUNT = 50;
@@ -67,7 +71,27 @@ export function storedRecordRest(
return { json: JSON.stringify(rest).slice(1, -1), refIds: [...refIds] };
}
-/** Adds the `__entityRef` ids in a record's JSON to `into`. */
+/** The fields of a parsed stored record that `data` does not hold as own keys. */
+export function recordRestOutside(
+ record: Record,
+ data: Record,
+): { keys: string[]; json: string; refIds: number[] } | undefined {
+ let values: Record | undefined;
+ let keys: string[] | undefined;
+ for (const key in record) {
+ const value = record[key];
+ if (value !== undefined && !Object.hasOwn(data, key)) {
+ (values ??= {})[key] = value;
+ (keys ??= []).push(key);
+ }
+ }
+ if (values === undefined) return undefined;
+ const refIds = new Set();
+ collectEntityRefs(values, refIds);
+ return { keys: keys!, json: JSON.stringify(values).slice(1, -1), refIds: [...refIds] };
+}
+
+/** Adds the `__entityRef` ids in a record's JSON to `into`, as `collectEntityRefs` would for the parsed record. */
export function entityRefsInJson(json: string, into: Set): void {
if (json.indexOf('"__entityRef":') === -1) return;
const re = /"__entityRef":(\d+)/g;
diff --git a/packages/fetchium/src/stores/sync.ts b/packages/fetchium/src/stores/sync.ts
index dd48dcb..14a1171 100644
--- a/packages/fetchium/src/stores/sync.ts
+++ b/packages/fetchium/src/stores/sync.ts
@@ -11,6 +11,10 @@ import {
refIdsKeyFor,
updatedAtKeyFor,
valueKeyFor,
+ VALUE_PREFIX,
+ DOC_PREFIX,
+ fieldNamesKeyFor,
+ FIELD_NAMES_SINCE_KEY,
storedRecordRest,
entityRefsInJson,
notifyListeners,
@@ -88,10 +92,21 @@ const mergeRestsByKv = new WeakMap>(
/** LRU bound. An evicted record's next merge reads it again. */
const MAX_MERGE_RESTS = 1024;
+const fieldNamesByKv = new WeakMap>>();
+const FIELD_NAME_TTL = 30 * 24 * 60 * 60 * 1000;
+const FIELD_NAME_REFRESH = 24 * 60 * 60 * 1000;
+/** `at` 0: since `clear()` emptied the kv. */
+interface FieldNamesSince {
+ at: number;
+ stored: boolean;
+}
+const fieldNamesSinceByKv = new WeakMap();
+
export class SyncQueryStore implements QueryStore {
queues: Map = new Map();
private readonly deleteListeners: Array<(key: number) => void>;
private readonly mergeRests: Map;
+ private readonly fieldNames: Map>;
constructor(private readonly kv: SyncPersistentStore) {
let listeners = deleteListenersByKv.get(kv);
@@ -106,6 +121,12 @@ export class SyncQueryStore implements QueryStore {
mergeRestsByKv.set(kv, rests);
}
this.mergeRests = rests;
+ let fieldNames = fieldNamesByKv.get(kv);
+ if (fieldNames === undefined) {
+ fieldNames = new Map();
+ fieldNamesByKv.set(kv, fieldNames);
+ }
+ this.fieldNames = fieldNames;
}
onDelete(listener: (key: number) => void): () => void {
@@ -186,8 +207,126 @@ export class SyncQueryStore implements QueryStore {
this.activateQuery(queryDef, queryKey);
}
- saveEntity(entityKey: number, value: unknown, refIds?: Set): void {
- this.setValue(entityKey, value, refIds);
+ saveEntity(entityKey: number, value: unknown, refIds?: Set, rest?: string): void {
+ if (rest === undefined || rest === '') {
+ this.setValue(entityKey, value, refIds);
+ return;
+ }
+ // Kept fields go first so a repeated key parses as `value`'s.
+ const json = JSON.stringify(value);
+ const rests = this.mergeRests;
+ if (rests.size !== 0) rests.delete(entityKey);
+ this.writeValue(entityKey, json.length === 2 ? `{${rest}}` : `{${rest},${json.slice(1)}`, refIds);
+ }
+
+ readEntity(entityKey: number): Record | undefined {
+ const stored = this.kv.getString(valueKeyFor(entityKey));
+ if (stored === undefined) return undefined;
+ let record: unknown;
+ try {
+ record = JSON.parse(stored);
+ } catch {
+ return undefined;
+ }
+ return typeof record === 'object' && record !== null && !Array.isArray(record)
+ ? (record as Record)
+ : undefined;
+ }
+
+ getEntityFieldNames(typename: string): readonly string[] | undefined {
+ const names = this.fieldNamesOf(typename);
+ return names.size === 0 ? undefined : [...names.keys()];
+ }
+
+ /**
+ * `false` for `FIELD_NAME_TTL` after this version first opens the store,
+ * since older records may hold fields of an unregistered class. `clear()`
+ * makes it `true`.
+ */
+ entityFieldNamesComplete(): boolean {
+ const at = this.fieldNamesSince().at;
+ return at === 0 || Date.now() - at >= FIELD_NAME_TTL;
+ }
+
+ private fieldNamesSince(): FieldNamesSince {
+ const kv = this.kv;
+ let since = fieldNamesSinceByKv.get(kv);
+ if (since !== undefined) return since;
+ const at = kv.getNumber(FIELD_NAMES_SINCE_KEY);
+ if (at !== undefined) {
+ since = { at, stored: true };
+ } else {
+ // Count from now. Telling an empty store apart would need a startup key scan.
+ since = { at: Date.now(), stored: false };
+ }
+ fieldNamesSinceByKv.set(kv, since);
+ return since;
+ }
+
+ /**
+ * Each name keeps when it was last declared, refreshed at most daily. A name
+ * undeclared for `FIELD_NAME_TTL` is forgotten.
+ */
+ addEntityFieldNames(typename: string, fields: readonly string[]): void {
+ const names = this.fieldNamesOf(typename);
+ const now = Date.now();
+ let changed = false;
+ for (let i = 0; i < fields.length; i++) {
+ const declaredAt = names.get(fields[i]);
+ if (declaredAt === undefined || now - declaredAt > FIELD_NAME_REFRESH) {
+ names.set(fields[i], now);
+ changed = true;
+ }
+ }
+ if (!changed) return;
+ const since = this.fieldNamesSince();
+ if (!since.stored) {
+ this.kv.setNumber(FIELD_NAMES_SINCE_KEY, since.at);
+ since.stored = true;
+ }
+ let stored = '';
+ for (const [name, declaredAt] of names) stored += `${stored === '' ? '' : '\n'}${name}\t${declaredAt}`;
+ this.kv.setString(fieldNamesKeyFor(typename), stored);
+ }
+
+ private fieldNamesOf(typename: string): Map {
+ let names = this.fieldNames.get(typename);
+ if (names !== undefined) return names;
+ names = new Map();
+ const stored = this.kv.getString(fieldNamesKeyFor(typename));
+ if (stored !== undefined && stored !== '') {
+ const now = Date.now();
+ for (const line of stored.split('\n')) {
+ const tab = line.lastIndexOf('\t');
+ if (tab <= 0) continue;
+ const declaredAt = Number(line.slice(tab + 1));
+ if (now - declaredAt <= FIELD_NAME_TTL) names.set(line.slice(0, tab), declaredAt);
+ }
+ }
+ this.fieldNames.set(typename, names);
+ return names;
+ }
+
+ /**
+ * Deletes all cached data and reports each record to `onDelete`. Use this,
+ * not the kv's own clear: a client unaware of the deletion would write back
+ * fields it kept from deleted records. Field names are kept.
+ */
+ clear(): void {
+ const kv = this.kv;
+ const deleted: number[] = [];
+ for (const key of kv.getAllKeys()) {
+ if (!key.startsWith(DOC_PREFIX)) continue;
+ if (key.startsWith(VALUE_PREFIX)) deleted.push(Number(key.slice(VALUE_PREFIX.length)));
+ kv.delete(key);
+ }
+ kv.setNumber(FIELD_NAMES_SINCE_KEY, 0);
+ fieldNamesSinceByKv.set(kv, { at: 0, stored: true });
+ this.queues.clear();
+ this.mergeRests.clear();
+ for (let i = 0; i < deleted.length; i++) {
+ for (const listener of this.deleteListeners.slice()) listener(deleted[i]);
+ }
}
mergeEntity(entityKey: number, fields: unknown, refIds?: Set): void {