> Part of the walkerOS documentation. Project overview and full index: <https://www.walkeros.io/llms.txt>

# Store-level cache

Store-level cache wraps any `Flow.Store` with a read-through, write-through cache tier. It absorbs round-trips to slow backings (Google Sheets, HTTP APIs, S3) without changing how components call the store.

The wrapping is transparent: a transformer wired to `$store.crm` does not know whether reads hit a memory cache, a GCS tier, or the underlying Sheets API. The wrapper is a `Store.Instance` itself, so all the rules from [Stores](/preview/pr-749/docs/stores.md) still apply.

tip

Store-level cache is different from the [event-level Cache](/preview/pr-749/docs/collector/cache.md) configured on sources, transformers, and destinations. Event cache short-circuits the pipeline on hit. Store cache memoizes `get`/`set`/`delete` calls on a single store. Both can be used in the same flow.

## Values and TTL[​](#values-and-ttl "Direct link to Values and TTL")

Stores hold one canonical value type: structured data (`StoreValue`), with binary (`Uint8Array`) as a first-class leaf. A shared core serialization codec round-trips that value to and from each backing, so any store can hold structured HTTP responses, events, push results, and binary bodies. JSON-shaped data (strings, numbers, booleans, `null`, arrays, objects) and `Uint8Array` survive a round-trip exactly; a `Date` becomes a string and an `Error` loses its fields.

The cache layer owns TTL, not the store. The store persists values; the cache wraps a store and manages expiry. An expired entry is treated as a miss and re-fetched from the backing. The supported cache backends are the filesystem, S3, GCS, and the in-memory tier. The Sheets store cannot be a cache tier (a `cache.store` target); a cache can still wrap it.

A store's byte-native [`file` mode](/preview/pr-749/docs/stores.md) (`config.file: true`) is for serving raw assets byte-exact, not for caching. The cache wraps structured stores; a store in `file` mode and a `cache` on the same store is flagged by `flow_validate`.

## Minimal example[​](#minimal-example "Direct link to Minimal example")

Enable the built-in in-memory tier (`__cache`) on any store by setting `cache` on the store declaration. No extra store needed:

```json
{
  "stores": {
    "sessions": {
      "package": "@walkeros/server-store-sheets",
      "config": { "settings": { "id": "1AbC...", "sheet": "Sessions" } },
      "cache": { "rules": [{ "ttl": 300 }] }
    }
  }
}
```

Every `sessions.get(K)` now checks the in-memory tier first. On miss, it reads from Sheets and populates the tier with a 300-second TTL. The next 300 seconds of identical reads hit memory and skip the Sheets API entirely.

The same rule applies to writes: `sessions.set(K, V)` writes to Sheets first, then to the tier on a best-effort basis (see [Write-through error policy](#write-through-error-policy) below).

## Cache rule shape[​](#cache-rule-shape "Direct link to Cache rule shape")

Store cache rules use a stricter subset of the rule shape used by event caches:

| Field   | Type              | Notes                                                        |
| ------- | ----------------- | ------------------------------------------------------------ |
| `ttl`   | `number`          | **Required.** Time-to-live in seconds.                       |
| `match` | `MatchExpression` | Optional. Matches against `{ key, value? }`, not event data. |

Store rules **never** accept `key` or `update`. The cache key comes from the caller (`store.get(K)`); there is no event to transform. Rules can match on the store key or value via `match`:

```json
"cache": {
  "rules": [
    { "match": { "key": "key", "operator": "prefix", "value": "session:" }, "ttl": 300 },
    { "ttl": 60 }
  ]
}
```

First rule caches only keys starting with `session:` for five minutes. The second catches everything else for one minute. Rules evaluate top-down, first match wins.

## Multi-tier composition[​](#multi-tier-composition "Direct link to Multi-tier composition")

`cache.store` references another store in the same flow. That store can have its own `cache`, and so on. The wrapper hierarchy is transparent: consumers wire to the top-level store and the wrapping resolves automatically.

```json
{
  "stores": {
    "files": {
      "package": "@walkeros/server-store-gcs",
      "config": { "settings": { "bucket": "my-cache-bucket", "prefix": "cache/" } },
      "cache": { "rules": [{ "ttl": 300 }] }
    },
    "crm": {
      "package": "@walkeros/server-store-sheets",
      "config": { "settings": { "id": "1AbC...", "sheet": "Customers" } },
      "cache": {
        "store": "files",
        "rules": [{ "ttl": 86400 }]
      }
    }
  },
  "transformers": {
    "crmLookup": {
      "code": {
        "type": "crm-lookup",
        "push": "$code:async (event, context) => { const userId = event.user?.id; if (!userId) return; const crm = await context.env.store.get(userId); if (crm) return { event: { ...event, data: { ...event.data, crm } } }; }"
      },
      "env": { "store": "$store.crm" }
    }
  }
}
```

The lookup chain on `crm.get(K)`:

1. Check the `crm` wrapper's tier, the `files` store. The `files` wrapper checks its own tier (the built-in memory `__cache`) first, then GCS. HIT, return. A GCS HIT also populates memory.
2. On MISS in both, call the underlying Sheets store.
3. The value flows back up. Each traversed tier is populated on the unwind: `crm` writes it to `files`, which writes GCS and then memory.

Subsequent reads hit the topmost tier that has a fresh value.

Omitting `cache.store` falls back to the collector's built-in `__cache` tier. There is no separate memory-store package to install.

## Built-in `__cache` tier[​](#built-in-__cache-tier "Direct link to built-in-__cache-tier")

The collector ships a single shared `__cache` instance used as the default cache tier when `cache.store` is omitted. It is an in-memory LRU map:

* **Entry cap:** `maxEntries: 10000` (fixed in v1).
* **LRU access ordering:** reads reorder entries; least-recently-used drop first when full.
* **Batched eviction:** on overflow, evict down to 80% (`8000`) in one pass.
* **Active TTL sweep:** a periodic sweeper drops expired entries every 60 seconds.

Each wrapped store gets an automatic namespace prefix (the store id by default) so multiple stores sharing `__cache` do not collide. Override with `cache.namespace: "myns"` for an explicit prefix. The collector logs the resolved namespace at startup, one line per wrapped store.

## Coherence model[​](#coherence-model "Direct link to Coherence model")

Read this section before relying on the cache for anything correctness-sensitive.

* **Read-your-writes (in-process):** yes. `wrapped.set(K, V)` populates the local cache on success, so a subsequent `wrapped.get(K)` in the same process sees `V`.
* **Cross-process consistency:** eventual, bounded by the longest TTL in the chain. If memory TTL is 60s and GCS TTL is 300s, a value changed in the backing by another writer is served stale from memory for up to 60s, from GCS for up to 300s. There is no invalidation channel.
* **Tier-skipping repopulation:** a MISS in tier N that HITs in tier N+1 repopulates tier N on the unwind. Subsequent reads hit tier N.

Set TTLs accordingly. Use short TTLs (1-60s) for mostly-static lookups behind a fast backing; use long TTLs (minutes-hours) for cold, expensive lookups where staleness is tolerable.

## Write-through error policy[​](#write-through-error-policy "Direct link to Write-through error policy")

`wrapped.set(K, V)` runs two steps with explicit failure handling:

1. **Backing first.** Await `backing.set(K, V)`. If this throws, the wrapper throws. The cache is not touched.
2. **Cache best-effort.** If the backing succeeded and a rule matches, attempt `cache.store.set(...)`. If this throws, the wrapper logs a warning and returns success.

Backing is the source of truth. A failed cache write degrades performance (next read misses) but does not corrupt correctness. A failed backing write is real failure and surfaces to the caller.

`wrapped.delete(K)` follows the same shape: backing first (throws on failure), then best-effort cache delete (logs on failure). A failed cache delete leaves a poisoned entry that serves stale data until TTL; the warning lets operators react.

## Single-flight deduplication[​](#single-flight-deduplication "Direct link to Single-flight deduplication")

Multiple concurrent `wrapped.get(K)` calls on a cold cache produce **exactly one** backing call. The wrapper holds an in-flight promise registry keyed by the namespaced key: subsequent callers receive the same promise until the first resolves.

This eliminates the thundering-herd failure mode that motivates store-level cache in the first place: 50 simultaneous events looking up the same `session:abc` key against a Sheets backend trigger one Sheets read, not 50.

## Observability[​](#observability "Direct link to Observability")

Each wrapped store keeps counters:

| Counter           | Meaning                                                            |
| ----------------- | ------------------------------------------------------------------ |
| `hits`            | Reads served by the cache tier                                     |
| `misses`          | Reads that fell through to the backing                             |
| `populates`       | Cache writes triggered by successful backing reads                 |
| `writes`          | `set` calls, counted whether or not the backing write succeeds     |
| `deletes`         | `delete` calls, counted whether or not the backing delete succeeds |
| `inflight_dedups` | Concurrent calls dedup-merged into one inflight                    |

The eviction counters (`evictions_entries` for entries dropped at `maxEntries`, `evictions_ttl` for expired entries) belong to the built-in `__cache` tier and are read from `collector.stores.__cache.counters`.

Each read through a wrapped store also emits a flow record to the collector's observers, with `meta.op` set to `cache` and `meta.status` set to `hit` or `miss`.

For interactive debugging, the wrapped store instance exposes a `counters` accessor:

```typescript
const { collector } = await startFlow({ /* ... */ });
const sessions = collector.stores.sessions;
if ('counters' in sessions) console.log(sessions.counters);
// { hits: 412, misses: 18, populates: 18, writes: 0, deletes: 0,
//   inflight_dedups: 7 }
```

## Known limitations[​](#known-limitations "Direct link to Known limitations")

* **No negative caching.** A `get(K)` that returns `undefined` from the backing is not populated. Every subsequent call for that key re-hits the backing until the value exists. Workaround: write a sentinel value on the first miss, treat it as "not present" in your transformer logic.
* **No cross-process invalidation.** Writes from one process do not invalidate caches in other processes. TTL is the only mechanism.
* **No `stop` field on store cache.** `stop` is an event-cache concept (halt the pipeline on hit). Store reads always fall through on miss; the field is rejected by the schema.
* **No `update` field on store cache.** `update` mutates events on hit. Stores have no event to mutate.
* **Renaming a store is a breaking change** to anything caching through it (`cache.store: "X"` references break). Document and migrate explicitly.

## Migration from `@walkeros/store-memory`[​](#migration-from-walkerosstore-memory "Direct link to migration-from-walkerosstore-memory")

The dedicated `@walkeros/store-memory` package was removed once the built-in `__cache` reached feature parity. Migration is one-line per occurrence:

* If a memory store was used **only** as a cache target (`cache.store: "memory"`): omit `cache.store`. The wrapper falls back to the built-in `__cache` automatically.
* If the memory store was wired into a component's `env` for non-cache use: replace with a small inline `Map` inside the component, or use one of the persistent stores in [Stores](/preview/pr-749/docs/stores.md).

`flow_validate` rejects `package: "@walkeros/store-memory"` with the replacement instruction.

## Related[​](#related "Direct link to Related")

* [Stores](/preview/pr-749/docs/stores.md): store interface, `$store.` wiring, lifecycle.
* [Collector: Cache](/preview/pr-749/docs/collector/cache.md): event-level cache on sources, transformers, destinations.
* [Reference Syntax](/preview/pr-749/docs/guides/reference-syntax.md): `$store.`, `$env.`, `$var.` references.
