diff --git a/.changeset/keep-cached-first-paint.md b/.changeset/keep-cached-first-paint.md index 7069e74..c0856f6 100644 --- a/.changeset/keep-cached-first-paint.md +++ b/.changeset/keep-cached-first-paint.md @@ -2,9 +2,8 @@ 'fetchium': patch --- -Cached data now shows on the first render when two entity classes share a typename, such as a list class and a detail class with more fields. Before, the detail query waited for the network if the list had already loaded the entity. +Fixed cached data not showing on first render when two entity classes share a typename, such as a list class and a detail class. -- Writing an entity through one class keeps the other class's fields in the store, so the detail cache survives a restart. Loading the list from the cache now also parses those fields. -- Refetching the list no longer drops entities that only the detail references, such as its nested `t.entity` field. Before, the detail kept the old value, stopped getting updates for it, and its cache failed to load after a restart. This bug was in every earlier version. -- To wipe the cache, call the new `SyncQueryStore.clear()`, not the storage's own clear (such as MMKV `clearAll()`), which can bring deleted fields back. -- Custom stores can add the new optional `QueryStore` methods `readEntity`, `getEntityFieldNames`, `addEntityFieldNames` and `entityFieldNamesComplete`. Without them nothing changes. +- Writes through one class keep the other class's fields, so the cache survives a restart. 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`. diff --git a/docs/src/app/api/stores-async/page.md b/docs/src/app/api/stores-async/page.md index 0dee90b..74d907c 100644 --- a/docs/src/app/api/stores-async/page.md +++ b/docs/src/app/api/stores-async/page.md @@ -45,7 +45,7 @@ new AsyncQueryStore(config: AsyncQueryStoreConfig) | ------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `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. | +| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set, rest?: string, ownedKeys?: string[]): void` | Dispatches a save-entity message. | | `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set, ifStored?: boolean): void` | Dispatches a merge of `fields` over the stored record. With `ifStored`, the writer skips it when no record is stored. | | `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Dispatches an activate-query message to update the LRU queue. | | `deleteQuery` | `(queryKey: number): void` | Dispatches a delete-query message. | @@ -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 diff --git a/docs/src/app/api/stores-sync/page.md b/docs/src/app/api/stores-sync/page.md index f32b20c..9e29c7f 100644 --- a/docs/src/app/api/stores-sync/page.md +++ b/docs/src/app/api/stores-sync/page.md @@ -32,22 +32,22 @@ new SyncQueryStore(kv: SyncPersistentStore) #### Methods -| Method | Signature | Description | -| -------------------------- | -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): CachedQuery \| undefined` | Loads a cached query by key. Returns `undefined` if the cache entry has expired (beyond `cacheTime`) or does not exist. Also preloads referenced entities. | -| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Persists a query result, its timestamp, and entity reference IDs. Activates the query in the LRU queue. | -| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set, rest?: string): void` | Persists an entity's serialized data and its child entity references. Manages reference counts for nested entities. `rest`, when given, is a JSON object body of fields the record already held that no class applied to the in-memory entity declares; it is written with the value so the write does not delete them. | -| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set): void` | 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. | +| Method | Signature | Description | +| -------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): CachedQuery \| undefined` | Loads a cached query by key. Returns `undefined` if the cache entry has expired (beyond `cacheTime`) or does not exist. Also preloads referenced entities. | +| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Persists a query result, its timestamp, and entity reference IDs. Activates the query in the LRU queue. | +| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set, rest?: string): void` | Persists an entity and its child references. `rest` holds stored fields to keep that the entity's class doesn't declare. | +| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set): void` | Merges `fields` over the stored record, or writes them as the record if none is stored. | +| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Moves a query to the front of its class's LRU queue, evicting the oldest entry when full. | +| `deleteQuery` | `(queryKey: number): void` | Deletes a query's stored value, reference IDs, and decrements reference counts for all referenced entities. | +| `purgeStaleQueries` | `(): void` | Scans all query classes and removes those whose `lastUsedAt` timestamp exceeds their `cacheTime`. Called automatically on `QueryClient` construction. | +| `onDelete` | `(listener: (key: number) => void): () => void` | Calls `listener` with each record the store deletes on its own (eviction, cascade, purge). Returns an unsubscribe function. | +| `hasEntity` | `(key: number): boolean` | Whether a record is stored for `key`. | +| `readEntity` | `(entityKey: number): Record \| undefined` | The stored record for `entityKey`, parsed, or `undefined`. | +| `getEntityFieldNames` | `(typename: string): readonly string[] \| undefined` | Field names the classes of `typename` declared in the last 30 days. | +| `addEntityFieldNames` | `(typename: string, fields: readonly string[]): void` | Records the field names a class of `typename` declares. | +| `entityFieldNamesComplete` | `(): boolean` | Whether the remembered field names cover every stored record. `false` for 30 days after upgrading, `true` after `clear()`. | +| `clear` | `(): void` | Deletes all cached queries and records and reports them to `onDelete`. Use it instead of clearing the `kv` directly. | ---