Skip to content
9 changes: 4 additions & 5 deletions .changeset/keep-cached-first-paint.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
'fetchium': patch
---

Cached data now shows on the first render when two entity classes share a typename, such as a list class and a detail class with more fields. Before, the detail query waited for the network if the list had already loaded the entity.
Fixed cached data not showing on first render when two entity classes share a typename, such as a list class and a detail class.

- Writing an entity through one class keeps the other class's fields in the store, so the detail cache survives a restart. Loading the list from the cache now also parses those fields.
- Refetching the list no longer drops entities that only the detail references, such as its nested `t.entity` field. Before, the detail kept the old value, stopped getting updates for it, and its cache failed to load after a restart. This bug was in every earlier version.
- To wipe the cache, call the new `SyncQueryStore.clear()`, not the storage's own clear (such as MMKV `clearAll()`), which can bring deleted fields back.
- Custom stores can add the new optional `QueryStore` methods `readEntity`, `getEntityFieldNames`, `addEntityFieldNames` and `entityFieldNamesComplete`. Without them nothing changes.
- Writes through one class keep the other class's fields, so the cache survives a restart. With `AsyncQueryStore`, only for classes registered in the session.
- New `SyncQueryStore.clear()`. Use it to wipe the cache instead of the storage's own clear.
- Custom stores can implement the new optional `readEntity`, `getEntityFieldNames`, `addEntityFieldNames` and `entityFieldNamesComplete`.
4 changes: 3 additions & 1 deletion docs/src/app/api/stores-async/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ new AsyncQueryStore(config: AsyncQueryStoreConfig)
| ------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): Promise<CachedQuery \| undefined>` | 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<number>): 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<number>): void` | Dispatches a save-entity message. |
| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set<number>, rest?: string, ownedKeys?: string[]): void` | Dispatches a save-entity message. |
| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set<number>, 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. |
Expand Down Expand Up @@ -141,6 +141,8 @@ type StoreMessage =
refIds?: number[];
merge?: boolean; // merge `value` over the stored record
ifStored?: boolean; // with `merge`: skip if no record is stored
rest?: string; // stored fields to keep
ownedKeys?: string[]; // keep stored fields outside these keys
}
| {
type: 2; // ActivateQuery
Expand Down
32 changes: 16 additions & 16 deletions docs/src/app/api/stores-sync/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,22 @@ new SyncQueryStore(kv: SyncPersistentStore)

#### Methods

| Method | Signature | Description |
| -------------------------- | -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): CachedQuery \| undefined` | Loads a cached query by key. Returns `undefined` if the cache entry has expired (beyond `cacheTime`) or does not exist. Also preloads referenced entities. |
| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set<number>): 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<number>, 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<number>): 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<string, unknown> \| 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:<typename>`). A name no class has declared for 30 days is forgotten. |
| `addEntityFieldNames` | `(typename: string, fields: readonly string[]): void` | Records that a class of `typename` declares `fields`. Called once per class and client; writes only when a name is new or its time is over a day old. |
| `entityFieldNamesComplete` | `(): boolean` | Whether the remembered field names cover every record in the store. `false` for 30 days after this version first opens the store, since records an earlier version wrote may hold fields of a class not registered since. `true` after `clear()`. While `false`, an entity built from streamed events is merged into its stored record instead of replacing it. |
| `clear` | `(): void` | Deletes every cached query, entity record and queue (`sq:doc:` keys) and reports each record to `onDelete` listeners, so a client over the store drops the fields it kept from those records. Wipe the cache with this, not on the `kv` directly: a client is not told about records deleted under it, and its next write of an entity of a typename two classes share puts the other class's deleted fields back. Field names (`sq:meta:`) are kept. |
| Method | Signature | Description |
| -------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): CachedQuery \| undefined` | Loads a cached query by key. Returns `undefined` if the cache entry has expired (beyond `cacheTime`) or does not exist. Also preloads referenced entities. |
| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set<number>): 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<number>, rest?: string): void` | Persists an entity and its child references. `rest` holds stored fields to keep that the entity's class doesn't declare. |

@jimmy-phantom jimmy-phantom Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

“Stored fields to keep” doesn’t explain what this string contains. The implementation expects a JSON object body without enclosing braces, such as:

"description":"Example"

Passing a normal JSON object string produces malformed stored JSON. The signatures are updated correctly, but this input-format requirement shouldn’t be trimmed.

| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set<number>): 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<string, unknown> \| undefined` | The stored record for `entityKey`, parsed, or `undefined`. |
| `getEntityFieldNames` | `(typename: string): readonly string[] \| undefined` | Field names the classes of `typename` declared in the last 30 days. |
| `addEntityFieldNames` | `(typename: string, fields: readonly string[]): void` | Records the field names a class of `typename` declares. |
| `entityFieldNamesComplete` | `(): boolean` | Whether the remembered field names cover every stored record. `false` for 30 days after upgrading, `true` after `clear()`. |

@jimmy-phantom jimmy-phantom Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation says the field-name completeness window starts “after upgrading.” It also applies to a fresh store’s first opening.

| `clear` | `(): void` | Deletes all cached queries and records and reports them to `onDelete`. Use it instead of clearing the `kv` directly. |

---

Expand Down