Durable data without changing the model API
v2.2.0 promotes the local-first resource work from beta to stable. local() creates a standalone
IndexedDB-backed model field, while local.query() and local.infinite() add optional remote
loading and revalidation to the same durable foundation.
const products = local.collection<Product>({
key: 'products',
version: 1,
scope: ({ state }) => {
const userId = state(userModel).me?.id
return userId ? `user:${userId}` : null
},
})
const productModel = model({
detail: local<Product | null, { id: string }>({
source: products,
initialData: null,
}),
list: local.query<Product[], ProductFilter>({
source: products,
initialData: [],
staleTime: 30_000,
queryFn: api.product.list,
}),
})
Standalone resources can restore exact persisted views and accept server-rendered initialization
without starting a client request. Query-backed resources restore first, then use the normal
staleTime rules to skip a fresh request or revalidate stale data in the background.
Normalized collections
Collections store canonical entities separately from each resource and query argument's ordered view. List and detail fields can therefore share updates without treating a list fragment as a complete detail response.
- Entity IDs default to
entity.id;getIdsupports APIs that use another identity field. mergeandrevisioncustomize fragment merging and reject older server data.- Static or lazily resolved collection scopes isolate public, user, and tenant caches.
- Scope changes discard the previous in-memory query cache and prevent cross-user response commits.
- Direct local edits write through to IndexedDB and fan out to loaded views sharing the entity.
Collection version remains an explicit schema boundary. There are no built-in migrations: bumping
the version invalidates old views and cleans up older rows for that scope and collection.
Server and failure behavior
Server environments simply skip IndexedDB. Standalone resources retain their initialized value, and query adapters continue through the ordinary query driver. Browser storage failures also degrade to in-memory behavior instead of breaking model access.
This release does not add a mutation outbox, offline retry queue, CRDT, or multi-device conflict engine. Use local resources for durable first paint and client-side continuity while keeping the server authoritative for synchronization.
Selector compatibility fix
Selector .load() now works when derived, computed, validation, or history extensions produce a
frozen model snapshot. The selector binding preserves the immutable snapshot and uses a separate
proxy target, avoiding JavaScript Proxy invariant errors.
Upgrade
yarn add @comwit/state@2.2.0
The deprecated comwit compatibility package also tracks v2.2.0 and re-exports @comwit/state.