# 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](#caching-outside-dcupl).

## Understanding dcupl caching

```mermaid
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()` (or `init()`) 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 as `customer.city`
- you call `list.invalidate()` or `list.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

```typescript
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); // true
```

By 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:

```typescript title="Without cloning"
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); // true
```

### Cache scope

Each list maintains its own query cache:

```typescript
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 list2
```

## Cache 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:

```typescript
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-computed
```

On 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()](#listupdate).

### 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:

```typescript
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(); // p3
```

`query.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](/sdk/guides/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:

```typescript title="Only affected lists are invalidated"
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 affected
```

> **Note:** The `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](/sdk/api/dcupl#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()`.

```typescript
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):

```typescript
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.

> **Watch out:** `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:

```typescript
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

```typescript
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-computed
```

## Aggregation caching

Aggregations are cached like queries:

```typescript
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:

```typescript title="Cache-related options (defaults shown)"
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](/sdk/api/dcupl) 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:

```typescript title="Invalidate an application cache per model"
// 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.

```typescript title="Start from stored data, refresh in the background"
// 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

```typescript
// 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.

```typescript
// 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

```typescript
// 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:

```typescript
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_update
```

### Cache 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](#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:

```typescript
// 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:

```typescript
// 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();
```

## Next steps

- [Performance](/sdk/advanced/performance) — Performance optimization
- [Data management](/sdk/core-concepts/data-management) — Loading and updating data
- [Lists](/sdk/core-concepts/lists) — Working with lists
- [Queries](/sdk/queries/query-filter) — Query optimization
