# Cache an expensive lookup

> Cache a note summary, keep account keys separate and invalidate it after a write.

- Package: `@db3.ai/app/cache`
- Canonical page: [https://db3.ai/docs/cache](https://db3.ai/docs/cache)
- Markdown: [https://db3.ai/docs/cache.md](https://db3.ai/docs/cache.md)
- Framework source of truth: `packages/app/src/cache/README.md`

<a id="setup"></a>

## Start with a memory cache

Complete Installation and copy this lab. It uses the real bounded memory driver; its counted lookup stands in for expensive application work without requiring a database.

Use `app().cache` in an application. The standalone lab constructs and closes `Cache` so its ownership is visible.

- [Installation](https://db3.ai/docs/installation.md)

### Copy the shipped example

```bash
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/cache/examples/. examples/
```

<a id="run"></a>

## Run and inspect the lookup count

Expect `firstLoads: 1` for two concurrent reads, `refreshed: { count: 2 }` after invalidation, an unchanged second-owner result and `recovered: "ready"` after a failed factory. `cleared` is true.

The lab closes its cache in `finally`. It only clears the store it created, not a shared application or Redis store.

### Run the lab

```bash
npx tsx examples/runNoteCache.ts
```

<a id="lookup"></a>

## Read through the cache

`getOrSet(key, factory, { ttl })` computes only on a miss. Concurrent misses share one in-flight computation within the same cache instance/process. This is not a cross-process lock or exactly-once business operation.

Build keys from the authorized owner, operation, inputs and a format version. A key is not an authorization check; authorize before looking up private data. Use plain serializable values, not a live ActiveRecord instance or open connection.

### examples/runNoteCache.ts

```typescript
import { pathToFileURL } from 'node:url';
import { Cache } from '@db3.ai/app/cache';

/**
 * Runs cache-through reads, owner-key isolation, invalidation and failure recovery.
 *
 * The counted factory represents an expensive lookup without needing a database.
 * It is deterministic and uses the real bounded memory driver.
 *
 * @returns Observable cache outcomes, after closing the owned store.
 */
export async function runNoteCache() {
	const cache = new Cache({ default: 'memory', stores: { memory: { driver: 'memory', ttl: 60_000, maxEntries: 100 } } });
	let loads = 0;
	/** Computes one example summary when the cache has no value. */
	const loadSummary = async () => ({ count: ++loads });
	try {
		const key = 'notes:v1:owner:ada:summary';
		const concurrent = await Promise.all([cache.getOrSet(key, loadSummary), cache.getOrSet(key, loadSummary)]);
		const firstLoads = loads;
		await cache.set('notes:v1:owner:grace:summary', { count: 9 });
		await cache.forget(key);
		const refreshed = await cache.getOrSet(key, loadSummary);
		let rejectedUndefined = false;
		try { await cache.set('invalid', undefined); } catch { rejectedUndefined = true; }
		let rejectedFactory = false;
		try { await cache.getOrSet('recover', () => { throw new Error('Lookup failed'); }); } catch { rejectedFactory = true; }
		const recovered = await cache.getOrSet('recover', () => 'ready');
		const otherOwner = await cache.get('notes:v1:owner:grace:summary');
		await cache.clear();
		return { concurrent, firstLoads, refreshed, otherOwner, rejectedUndefined, rejectedFactory, recovered, cleared: await cache.get(key) === undefined };
	} finally {
		await cache.close();
	}
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) console.log(JSON.stringify(await runNoteCache(), null, 2));
```

<a id="invalidation"></a>

## Invalidate after changing the source

After a successful database write/commit, call `forget()` for affected cache keys. Deleting a summary does not change the source data; the next lookup recomputes it. TTL is a backstop, not a consistency guarantee.

`forget()` reports acceptance of deletion, not whether a row or value existed. `clear()` removes every key owned by the selected store. Avoid using it for routine single-record changes.

<a id="ttl"></a>

## TTL, refresh and values

TTL is in milliseconds. Configure a positive store default or supply a per-write TTL. Omitting a default leaves no default expiry; the bounded memory store can still evict entries.

`refreshThreshold` returns the current value while refreshing near expiry. Only use this when serving stale data is acceptable. The factory can derive TTL and threshold from its result; those signatures are in the reference.

`undefined` means a miss and cannot be stored. `null`, `false`, zero and empty strings are valid cached values. Generic types do not validate deserialized values.

- [TTL and refresh options](https://db3.ai/docs/cache-api.md#operations)

<a id="configuration"></a>

## Choose one named store

Pass `config: { cache: { default, stores } }` to App. Cache selects one named store; it is not a tiered or automatically failing-over collection. Memory defaults to a maximum of 1,000 entries and cloned values.

Separate API/worker processes do not share memory entries. For shared data configure a Redis store with `driver`, `url` and a nonempty `namespace`; isolate app and environment, for example `notes:staging:cache`.

Redis options include connection timeout, clear batch size and strict error flags. Namespaced clear scans/unlinks that namespace, never flushes the entire database. Keep credentials server-side. This lab does not exercise a live Redis connection.

- [Store configuration](https://db3.ai/docs/cache-api.md#options)
- [App configuration](https://db3.ai/docs/app-config.md)

<a id="failures"></a>

## Handle misses and failures deliberately

A rejected factory is not cached and later callers can retry. An undefined result rejects. Blank keys reject; surrounding whitespace is trimmed.

Cache is an optimization, never the only copy of required state. Decide which operations may fall back to the source when storage is unavailable, and monitor that path. Read/refresh errors follow the selected driver behavior; do not assume every outage throws or silently recovers.

`App.close()` closes its cache; a manually created cache needs `close()`. Do not close the shared app cache at the end of every request.

<a id="testing"></a>

## Testing

Create a `tests/cache` directory and save the test below as `runNoteCache.test.ts`. It imports the example you copied into `examples/`. Keep the same folder layout so the relative import resolves.

Run from the application root with the development dependencies from Installation. These are consumer tests, not commands that assume a framework checkout.

### tests/cache/runNoteCache.test.ts

```typescript
import { afterEach, expect, it, vi } from 'vitest';
import { Cache } from '@db3.ai/app/cache';
import { runNoteCache } from '../../examples/runNoteCache';

afterEach(() => { vi.useRealTimers(); });

it('coalesces reads, invalidates one owner, rejects bad values and recovers failed lookups', async () => {
	expect(await runNoteCache()).toEqual({ concurrent: [{ count: 1 }, { count: 1 }], firstLoads: 1, refreshed: { count: 2 }, otherOwner: { count: 9 }, rejectedUndefined: true, rejectedFactory: true, recovered: 'ready', cleared: true });
});

it('expires values in milliseconds and keeps independent memory stores isolated', async () => {
	vi.useFakeTimers();
	const first = new Cache();
	const second = new Cache();
	try {
		await first.set('same-key', false, { ttl: 100 });
		expect(await first.get('same-key')).toBe(false);
		expect(await second.get('same-key')).toBeUndefined();
		await vi.advanceTimersByTimeAsync(101);
		expect(await first.get('same-key')).toBeUndefined();
		await expect(first.set(' ', 1)).rejects.toThrow('non-empty');
		await first.set('null-value', null);
		expect(await first.get('null-value')).toBeNull();
	} finally {
		await first.close();
		await second.close();
	}
});
```

<a id="run-tests"></a>

## Test cache behavior without sleeping

The copied tests use real stores. They cover concurrent reads, owner keys, invalidation, recovery, valid falsy values, store isolation and millisecond expiry. Only the clock is controlled for the expiry assertion.

For your feature, count source reads and prove a write invalidates the right owner/key. Add live Redis conformance before choosing Redis for a deployment.

### Run your copied test and check types

```bash
npx vitest run tests/cache/runNoteCache.test.ts
npx tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --types node --skipLibCheck examples/*.ts
```

<a id="coverage"></a>

## Coverage and limits

Taught/tested: memory read/write, get-or-set, same-instance coalescing, invalidation, clear, TTL, failed factory recovery, key/value validation, independent stores and close. Configuration, LRU bounds, cloning and background refresh are explained/reference-covered; background refresh and live Redis are not proved by this lab.

There is no cache-tag API, distributed mutex, multi-store failover or durable transaction. Use database/Queue primitives for durable work.

- [Full Cache API](https://db3.ai/docs/cache-api.md)
- [Durable background work](https://db3.ai/docs/queue-overview.md)

## Behavioural verification
Coalesces real memory-cache reads, isolates owner keys, invalidates, expires and recovers a failed lookup.
- Behaviour test: `packages/app/src/cache/tests/examples/runNoteCache.test.ts`
- Repository test command (framework checkout only): `npm run test:service --workspace @platform/app -- cache --maxWorkers=1`
- In an installed application, use the walkthrough commands instead of this repository test.
- Expected outcome: The guide test passes against the real framework components.
- Environment: Node.js 24. Real bounded memory store; tests advance time. No Redis or SQL claim.

## Related documentation
- [Cache API reference](https://db3.ai/docs/cache-api.md): Current emitted signatures and options for @db3.ai/app/cache.
- [Configure the application runtime](https://db3.ai/docs/app-config.md): Keep application settings explicit. Boot one App, give each service its options and leave HTTP and worker startup to the host.
- [React to a saved note](https://db3.ai/docs/events.md): Use typed, in-process events for small reactions while keeping delivery and failure behavior visible.

## Framework-owned source: `packages/app/src/cache/README.md`

This is the exact source document captured by the documentation build. Use it for detailed API and workflow guidance, subject to the public package exports and behavioural evidence identified above.

````markdown
# Cache

`@db3.ai/app/cache` provides the application cache exposed through
`app().cache`. The framework owns the public API, config resolution, validation, and
shutdown lifecycle. Cache Manager currently supplies TTL storage and
process-local request coalescing.

## Run a note-summary cache

From an independent application prepared using [Installation](https://db3.ai/docs/installation):

```sh
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/cache/examples/. examples/
npx tsx examples/runNoteCache.ts
```

The real memory-store lab returns `firstLoads: 1` for two concurrent reads and `refreshed: { count: 2 }` after invalidation. A second owner keeps their own value. A failed factory is retried explicitly, an undefined value is rejected, and the isolated store is cleared and closed. No Redis, SQL or network is needed.

Copy the exact test from the [Cache guide](https://db3.ai/docs/cache#testing) to `tests/cache/runNoteCache.test.ts`, then run:

```sh
npx vitest run tests/cache/runNoteCache.test.ts
npx tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --types node --skipLibCheck examples/*.ts
```

Tests exercise the real store and control only the clock for deterministic expiry. The [complete API](https://db3.ai/docs/cache-api) covers service, driver, store and operation contracts.

## Get Or Set

Use `getOrSet(...)` when a value is expensive to compute:

```ts
const myValue = await app().cache.getOrSet('the_key', async () => {
	const complexFunctionResult = await myComplexFunction();

	return complexFunctionResult;
}, {
	ttl: 300_000,
});
```

The factory runs only after a cache miss. Concurrent misses for the same key in
one process share a single in-flight factory execution.

Coalescing belongs to one cache instance, not a distributed lock. Authorize private data before using an owner-scoped key. Include operation, owner, arguments and a version in the key; the cache never enforces ownership itself. Invalidate affected keys after the source write commits. Do not cache live models or connections.

TTL values are milliseconds. `refreshThreshold` can refresh an expiring entry
in the background while returning its current value:

```ts
const website = await app().cache.getOrSet(
	`website:${websiteId}`,
	() => loadWebsite(websiteId),
	{
		ttl: 300_000,
		refreshThreshold: 30_000,
	},
);
```

## Other Operations

```ts
await app().cache.set('feature:enabled', true, {
	ttl: 60_000,
});

const enabled = await app().cache.get<boolean>('feature:enabled');

await app().cache.forget('feature:enabled');
await app().cache.clear();
```

`undefined` represents a cache miss and cannot be stored. `null`, `false`, zero,
empty strings, arrays, and objects remain valid values. Prefer values that can
be cloned and serialized consistently across memory and Redis stores.

## Configuration

Cache stores are configured under the application config:

```ts
const config = {
	cache: {
		default: 'memory',
		stores: {
			memory: {
				driver: 'memory',
				ttl: 300_000,
				maxEntries: 1000,
			},
		},
	},
};
```

The memory driver uses CacheableMemory behind Cache Manager. It is process-local
and bounded with least-recently-used eviction. Separate API, queue-worker, and
scheduler processes do not share memory cache entries.

Redis uses the official `@keyv/redis` adapter:

```ts
const config = {
	cache: {
		default: 'redis',
		stores: {
			redis: {
				driver: 'redis',
				url: 'redis://127.0.0.1:6379',
				namespace: 'scout:cache',
				ttl: 300_000,
				connectionTimeoutMs: 5000,
				clearBatchSize: 1000,
				throwOnConnectError: true,
				throwOnErrors: true,
			},
		},
	},
};
```

Redis namespaces are mandatory. `clear()` scans and unlinks only keys inside
that namespace; the framework never enables a broad Redis database flush.

Scout currently selects the bounded memory store. Its
`server/config/cache.ts` file includes a commented Redis store that can be
enabled by uncommenting it and setting `CACHE_STORE=redis`.

One configured store is selected; named stores do not mean tiering or automatic failover. The local walkthrough tests memory only. Redis delivery and failure handling need real infrastructure tests before release; a Redis configuration example is not proof of a successful connection.

## Lifecycle And Boundaries

`App.close()` disconnects the configured cache store. Applications should use
the normal framework shutdown lifecycle so Redis connections close cleanly.

Cache is an optimization, not durable storage. Do not rely on it as the only
copy of application state. Cache Manager read misses and refresh behavior follow
the selected Keyv store; write, clear, factory, and strict Redis failures reject
their calling operation.
````

## Guidance for AI tools
Use the documented public import `@db3.ai/app/cache` and its exported types. Prefer the source-backed examples and behavioural outcomes above over invented APIs or source-relative internal imports.
