Caching strategies
dcupl caches query results per list. This page explains what is cached, when a cache is dropped, and which options control it. dcupl keeps all data in memory; for keeping data across page loads, see Caching Outside dcupl.
Understanding dcupl caching
flowchart LR
Q[Read on a list] --> C{Cached for this query?}
C -->|Yes| R[Return Cached]
C -->|No| E[Execute Query]
E --> S[Store in the list's cache]
S --> R2[Return Results]
U["dcupl.update()"] --> I[Drop caches of affected lists]
dcupl caches query results per list. Each list keeps the results of the queries it has executed, keyed by the query, together with the facets and aggregations computed on them. Global reads (dcupl.query.execute(), dcupl.fn.*) are cached the same way, per model, and refreshed on every dcupl.update() that affects that model. A list's cache is dropped when:
dcupl.update()(orinit()) applies a data change to the list's model, to a model it depends on through a reference or a derived or resolved attribute, or to a model the list reads through a dotted path such ascustomer.city- you call
list.invalidate()orlist.update()
Changing the query does not drop the cache: the new query gets its own entry, and switching back to an earlier query reuses its cached result. The cache key holds the full query, so two queries share an entry only when they are equal.
Key concepts:
- Automatic caching - Query results, facets, aggregations, groupBy, pivot and suggest results are cached by default
- Cache invalidation - Every
dcupl.update()invalidates the affected lists - Manual control -
list.invalidate()drops a list's caches - List-level caching - Each list maintains its own cache
Automatic query caching
How it works
const productList = dcupl.lists.create({ modelKey: 'Product' });
productList.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Electronics',
});
// First read - the result is computed and stored in the list's cache
const results1 = productList.catalog.query.items();
// Second read - served from the cache, the query is not evaluated again
const results2 = productList.catalog.query.items();
// Every read returns a deep copy by default
console.log(results1 === results2); // false
console.log(results1.length === results2.length); // trueBy default every read returns a deep copy (performance.clone.enabled: true), so changing a returned item cannot corrupt the data inside dcupl. The copy is made on every read, cache hit or not. If your application never mutates returned items, you can turn cloning off; repeated reads then return the same cached array:
const dcupl = new Dcupl({ performance: { clone: { enabled: false } } });
// ... models, data, await dcupl.init(), list and query as above
const results1 = productList.catalog.query.items();
const results2 = productList.catalog.query.items();
console.log(results1 === results2); // trueCache scope
Each list maintains its own query cache:
const list1 = dcupl.lists.create({ modelKey: 'Product' });
const list2 = dcupl.lists.create({ modelKey: 'Product' });
list1.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Electronics',
});
list2.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Clothing',
});
// Each list has independent cache
const electronics = list1.catalog.query.items(); // Cached for list1
const clothing = list2.catalog.query.items(); // Cached for list2Cache invalidation
Automatic invalidation
Data changes are staged and take effect on the next await dcupl.update(). That call invalidates the caches of every affected list:
const productList = dcupl.lists.create({ modelKey: 'Product' });
productList.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Electronics',
});
productList.catalog.query.items().map((p) => p.key); // ['p1', 'p2'] - cached
// Stage a change - nothing is applied yet
dcupl.data.update([{ key: 'p1', category: 'Clothing' }], { model: 'Product' });
productList.catalog.query.items().map((p) => p.key); // ['p1', 'p2']
// Apply it - the list's caches are dropped
await dcupl.update();
productList.catalog.query.items().map((p) => p.key); // ['p2'] - re-computedOn each dcupl.update() every affected list drops its query and result caches and recomputes lazily on the next read; lists nobody reads do no work. An unscoped list like this one therefore always shows current data, with or without autoUpdate. Scoped lists are different, see list.update().
Manual cache invalidation
You rarely need this, because dcupl.update() already invalidates every affected list. Call list.invalidate() when a list's results depend on something dcupl cannot see, for example a custom operator that reads application state:
import type { DcuplQuery } from '@dcupl/common';
let maxPrice = 1000;
dcupl.query.registerCustomOperator('belowMaxPrice', (query, item) => item.price < maxPrice);
const productList = dcupl.lists.create({ modelKey: 'Product' });
// DcuplQuery.operator only lists the built-in operators, so cast the condition
productList.catalog.query.addCondition({
attribute: 'price',
operator: 'belowMaxPrice',
value: true,
} as unknown as DcuplQuery);
productList.catalog.query.items(); // p1, p2, p3 - cached
maxPrice = 100;
productList.catalog.query.items(); // p1, p2, p3 - still the cached result
// Drop the list's caches; the next read recomputes
productList.invalidate();
productList.catalog.query.items(); // p3query.clear() is not a way to invalidate: it removes the conditions, but re-applying the same query afterwards returns the cached result again.
registerCustomOperator still works but is deprecated; for new code, register an operator script instead (see Scripts).
Partial cache invalidation
Invalidation is selective per list, not per attribute. dcupl.update() only touches the lists affected by the change: lists on a changed model, lists on a model that depends on it (through a reference, or a derived or resolved attribute), and lists whose dotted paths (such as customer.city) read through a changed model. An affected list drops all of its cached results, even if the changed attribute is not part of its query:
const productList = dcupl.lists.create({ modelKey: 'Product' });
productList.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Electronics',
});
const categoryList = dcupl.lists.create({ modelKey: 'Category' });
productList.catalog.query.items(); // Cached
categoryList.catalog.query.items(); // Cached
// Update a property the query does not use
dcupl.data.update([{ key: 'p1', stock: 100 }], { model: 'Product' });
await dcupl.update();
productList.catalog.query.items(); // Re-computed: Product changed
categoryList.catalog.query.items(); // Still cached: Category is not affectedThe cacheInvalidationThreshold option in performance.partialUpdates was removed in SDK 2.0: passing it is a type error, and at runtime it is ignored. The per-list caches and the indices dcupl builds per model are bounded by performance.cache.maxSize instead (default 64 MiB; least recently used entries are dropped and rebuilt on their next use; see DcuplInitPerformanceConfig).
List update and cache
list.update()
dcupl.update() already gives every unscoped list fresh results. list.update() re-derives a scoped list, one created with a seed query or with listItemKeys. A scoped list keeps its key set (the listItemKeys you pass, or the keys the query matched at creation) but always serves the model's current items: after any dcupl.update(), partial or full rebuild, changed values show, removed items drop out, and an item added under one of the keys is served. For a listItemKeys list that is all there is, so list.update() and autoUpdate change nothing for its items. For a query-scoped list without autoUpdate, items that start matching later only join when you call list.update().
const electronics = dcupl.lists.create({
modelKey: 'Product',
query: {
modelKey: 'Product',
queries: [{ attribute: 'category', operator: 'eq', value: 'Electronics' }],
},
});
electronics.catalog.query.items().map((p) => p.key); // ['p1', 'p2']
// Add a new Electronics product
dcupl.data.upsert([{ key: 'p5', name: 'Tablet', category: 'Electronics', price: 399 }], { model: 'Product' });
await dcupl.update();
electronics.catalog.query.items().map((p) => p.key); // ['p1', 'p2'] - scope unchanged
// Re-run the seed query: new matches join (current values already show)
electronics.update();
electronics.catalog.query.items().map((p) => p.key); // ['p1', 'p2', 'p5']list.update() is synchronous and emits a list_updated event on the list.
autoUpdate option
With autoUpdate: true, a query-scoped list re-runs its seed query on every dcupl.update() that affects it, so new matches join and items that stop matching leave (current values show either way; a listItemKeys list serves the same items with or without it):
const electronics = dcupl.lists.create({
modelKey: 'Product',
query: {
modelKey: 'Product',
queries: [{ attribute: 'category', operator: 'eq', value: 'Electronics' }],
},
autoUpdate: true, // Re-run the seed query on every update
});
dcupl.data.upsert([{ key: 'p5', name: 'Tablet', category: 'Electronics', price: 399 }], { model: 'Product' });
await dcupl.update();
electronics.catalog.query.items().map((p) => p.key); // ['p1', 'p2', 'p5']On an unscoped list, autoUpdate makes no visible difference: it already sees every change after dcupl.update(). To enable it for all lists, use new Dcupl({ performance: { autoUpdateLists: { enabled: true } } }) (default false); for a single update, use dcupl.update({ autoUpdateLists: true }). The per-update value overrides the list's setting, which overrides the global one.
autoUpdate on lists.create() works at runtime, but the TypeScript type of create() does not declare it, so TypeScript reports an error for it.
Note: Invalidation is lazy, but autoUpdate re-runs the seed query eagerly on every update that affects the list. Use it only on query-scoped lists that must pick up new matches.
Facet caching
Facets are also cached automatically:
const productList = dcupl.lists.create({ modelKey: 'Product' });
// First call - computed and cached
const facets1 = productList.catalog.fn.facets({ attribute: 'category' });
// Second call with the same options - served from the list's cache
const facets2 = productList.catalog.fn.facets({ attribute: 'category' });As with items(), each call returns a deep copy unless performance.clone.enabled is false.
Facet cache invalidation
const productList = dcupl.lists.create({ modelKey: 'Product' });
productList.catalog.fn.facets({ attribute: 'category' }).map((f) => `${f.value}: ${f.size}`);
// ['Electronics: 2', 'Clothing: 1']
// Update data - the facet cache is dropped by dcupl.update()
dcupl.data.update([{ key: 'p1', category: 'Computers' }], { model: 'Product' });
await dcupl.update();
productList.catalog.fn.facets({ attribute: 'category' }).map((f) => `${f.value}: ${f.size}`);
// ['Computers: 1', 'Electronics: 1', 'Clothing: 1'] - re-computedAggregation caching
Aggregations are cached like queries:
const orderList = dcupl.lists.create({ modelKey: 'Order' });
orderList.catalog.query.addCondition({
attribute: 'status',
operator: 'eq',
value: 'completed',
});
// First call - computed
const stats1 = orderList.catalog.fn.aggregate({
attribute: 'total',
types: ['sum', 'avg', 'count'],
});
// Second call with the same options - cached
const stats2 = orderList.catalog.fn.aggregate({
attribute: 'total',
types: ['sum', 'avg', 'count'],
});Cache configuration
The built-in caches are controlled by the performance options of the Dcupl constructor:
const dcupl = new Dcupl({
performance: {
cache: {
enabled: true, // Per-list query, facet and aggregation caches
maxSize: 64 * 1024 * 1024, // Bound in estimated bytes per list cache and per model's indices; Infinity = none
maxProjectionSize: 256 * 1024 * 1024, // Bound per list for the projected copies of deep facets and deep filters; Infinity = none
},
clone: { enabled: true }, // Return a deep copy on every read
autoUpdateLists: { enabled: false }, // true: re-run query-scoped lists' seed queries on every update
},
});cache.enabled applies to every list of the instance; there is no per-list switch. See Dcupl options for the other performance options.
The built-in cache has no TTL. Its size is bounded by performance.cache.maxSize (default 64 MiB, estimated; Infinity for no bound): when a new entry takes a list's cache over the bound, the least recently used entries are dropped and rebuilt on their next use, so results don't change, only repeated queries may recompute. The same bound applies, separately, to the indices dcupl builds per model (or per scoped list), also with the cache disabled. Within the bound, cached results live until the next dcupl.update() that affects the list (or list.invalidate()), and they are released together with the list when you call list.destroy(). Every distinct query adds entries (its result and its filtered data, plus any facets and aggregations computed on it), so a list driven by free text, such as search-as-you-type over data that rarely changes, fills its cache up to the bound. Lower maxSize to cap that memory, call list.invalidate() on such a list from time to time (for example when the search field loses focus), or turn the cache off with performance.cache.enabled: false.
Deep facets and deep filters (a dotted path such as product.color in a facet attribute, a list's query or an fn.* query) project a copy of the list per distinct set of dotted attributes. These copies have their own bound, performance.cache.maxProjectionSize (default 256 MiB per list, estimated; Infinity for no bound), separate from maxSize so that broad query results don't push a projection out: the least recently used copies are dropped and projected again on their next use, and a copy larger than the bound is built and served but not kept. Like the result cache, projections are dropped on every update that affects the list, and with cache.enabled: false every deep facet or deep filter projects again.
Keeping your own caches in sync
dcupl.on() receives every event of the instance. dcupl_partial_update is emitted after every dcupl.update() that applied staged changes, including a full rebuild after data.set() or data.reset(); msg.args[0].changedModels lists the models that changed. The next dcupl.update() after a model definition change (models.set(), models.update() or models.remove() after init()) emits it too, even with no data staged. It is not emitted by init() or by an update() with nothing staged and no model definition changed since the last build. Use it to drop entries of your own caches:
// Your own cache of derived results, keyed by model
const appCache = new Map<string, unknown>();
dcupl.on((msg) => {
if (msg.type !== 'dcupl_partial_update') return;
for (const model of msg.args[0].changedModels) {
appCache.delete(model);
}
});Caching outside dcupl
dcupl holds its data in memory only, so nothing survives a page reload. To start faster on the next visit, store the raw data you loaded (IndexedDB for large datasets, localStorage for small settings) and stage it before init(); then fetch fresh data in the background and apply it with dcupl.update(). Expiry, versioning and offline handling are up to your application.
// loadStored / saveStored / fetchProducts are your own storage and API helpers
const stored = await loadStored('products');
dcupl.data.set(stored ?? (await fetchProducts()), { model: 'Product' });
await dcupl.init();
const fresh = await fetchProducts();
dcupl.data.set(fresh, { model: 'Product' });
if (await dcupl.update()) {
await saveStored('products', fresh);
}init() and update() don't throw when processing fails: they log the error and resolve to false, so check the result before you rely on the new data.
Best practices
1. Leverage automatic caching
// Good: Reuse lists
const list = dcupl.lists.create({ modelKey: 'Product' });
function getProducts(category: string) {
list.catalog.query.clear();
list.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: category,
});
return list.catalog.query.items(); // Cached
}
// Bad: Create new lists
function getProducts(category: string) {
const list = dcupl.lists.create({ modelKey: 'Product' });
// ... query
// No caching benefit, and every list stays registered (and is checked
// on every dcupl.update()) until you call list.destroy()
}2. Use autoUpdate sparingly
Unscoped lists see every data change after dcupl.update(). autoUpdate re-runs a query-scoped list's seed query, eagerly, on every update that affects it; current values show without it, and a listItemKeys list serves the same items with or without it.
// Good: Scoped list that must pick up newly matching items
const alertList = dcupl.lists.create({
modelKey: 'Metric',
query: {
modelKey: 'Metric',
queries: [{ attribute: 'status', operator: 'eq', value: 'alert' }],
},
autoUpdate: true,
});
// Bad: Unscoped list - no visible effect, it is always up to date
const categoryList = dcupl.lists.create({
modelKey: 'Category',
autoUpdate: true, // Unnecessary
});3. Clear data when needed
// Good: Clear on logout
async function logout() {
// Destroys every list. dcupl.update() no longer refreshes a destroyed list,
// so re-create the lists your UI reads instead of keeping the old objects
dcupl.lists.destroy();
for (const model of ['Order', 'Customer']) {
dcupl.data.reset({ model }); // Staged - applied by dcupl.update()
}
await dcupl.update();
// Also clear what your application stored outside dcupl
}
// Bad: Never clear
function logout() {
// Stale data remains
}Troubleshooting
Stale data
Problem: Cache returns outdated data
Solution: Apply staged changes with dcupl.update() (it invalidates every affected list), and invalidate your own caches:
dcupl.data.update([{ key: 'p1', price: 999 }], { model: 'Product' });
// Apply the change - affected lists recompute on the next read
await dcupl.update();
// Query-scoped lists without autoUpdate: re-run the seed query to pick up
// new matches (current values and listItemKeys lists need nothing)
list.update();
// Caches outside dcupl: drop them yourself, for example on dcupl_partial_updateCache growing too large
Problem: Memory usage increases although the data doesn't change
Solution: A list keeps one cache entry per distinct query until the next update that affects it, up to performance.cache.maxSize (default 64 MiB; see Cache Configuration). Lower the bound to cap memory, drop the caches of lists driven by free text from time to time, destroy lists you no longer read, or turn caching off:
// A smaller bound: 16 MiB per list cache
const dcupl = new Dcupl({ performance: { cache: { enabled: true, maxSize: 16 * 1024 * 1024 } } });
searchList.invalidate(); // drop this list's cached results
tempList.destroy(); // release a list and its caches
// No caching at all: new Dcupl({ performance: { cache: { enabled: false } } })Inconsistent results
Problem: Different lists show different data
Solution: Ensure single source of truth:
// Good: Single dcupl instance
const dcupl = new Dcupl();
const list1 = dcupl.lists.create({ modelKey: 'Product' });
const list2 = dcupl.lists.create({ modelKey: 'Product' });
// Bad: Multiple instances
const dcupl1 = new Dcupl();
const dcupl2 = new Dcupl();