skills/walkeros-using-store-cache/SKILL.md
Use when adding read-through caching to a walkerOS store, memoizing a slow API/Sheets backing, composing multi-tier cache chains, or deduplicating concurrent store reads. Covers recipes, TTL choice, error policy, and observability counters.
npx skillsauth add elbwalker/walkeros walkeros-using-store-cacheInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Flow.Store.cache wraps any store with a read-through, write-through cache
tier. Reads check the cache first, fall through to the backing on miss, and
populate every tier on the unwind. Writes go to the backing first, then to the
cache best-effort.
The wrapping is transparent: a transformer wired to $store.crm does not know
whether reads hit a memory cache, a Redis tier, or the underlying API.
Core principle: the cache is advisory. Backing is the source of truth. Failed cache operations degrade performance, never correctness.
TTL is owned by the cache layer, not the store. The store persists
structured values; the cache wrapper manages expiry via the per-rule ttl. An
expired entry is treated as a miss and re-fetched from the backing. Caching
works over any structured backing.
A store's byte-native file mode (config.file: true) is for serving raw
assets byte-exact, not for caching. The cache wraps structured stores, so
flow_validate warns when a store sets both file: true and cache. Drop one:
serve bytes with file: true and no cache, or cache a structured store with no
file.
@walkeros/store-memory (removed in favor of the
built-in tier)The talk-demo use case. A sessions lookup runs on every event but most events
in a session share the same key.
{
"stores": {
"sessions": {
"package": "@walkeros/server-store-sheets",
"config": {
"credentials": "$var.sheetsCredentials",
"settings": {
"id": "1AbC...",
"sheet": "Sessions"
}
},
"cache": { "rules": [{ "ttl": 300 }] }
}
},
"transformers": {
"enrich": {
"package": "@walkeros/transformer-enrich",
"env": { "store": "$store.sessions" }
}
}
}
The first lookup hits Sheets and populates the built-in in-memory tier with a 300-second TTL. The next 300 seconds of identical reads hit memory and skip the Sheets API.
Without the cache: 60 events in 60 seconds = 60 Sheets reads = quota tripped (60 req/min limit) in one minute. With the cache: 60 events in 60 seconds = 1 Sheets read.
Same shape, longer TTL because the API is the cold backing:
{
"stores": {
"users": {
"package": "@walkeros/server-store-api",
"config": { "settings": { "endpoint": "$env.USER_API_URL" } },
"cache": { "rules": [{ "ttl": 3600 }] }
}
}
}
One-hour TTL is reasonable when user records change rarely. Use flow_validate
to verify the config; use flow_simulate with a representative event to confirm
the cache hit rate.
Different keys can have different TTLs. Rules evaluate top-down, first match
wins. The match context is { key, value? }, not event data:
"cache": {
"rules": [
{ "match": { "key": "key", "operator": "prefix", "value": "session:" }, "ttl": 300 },
{ "match": { "key": "key", "operator": "prefix", "value": "user:" }, "ttl": 3600 },
{ "ttl": 60 }
]
}
session:* keys cache for 5 minutesuser:* keys cache for 1 hourA rule without match always matches. Place it last as a fallback.
When the working set exceeds the memory tier's capacity, add a Redis layer
between memory and the cold backing. The consumer still wires to $store.api;
the tiers resolve automatically.
{
"stores": {
"redis": {
"package": "@walkeros/server-store-redis",
"config": { "settings": { "url": "$env.REDIS_URL" } },
"cache": { "rules": [{ "ttl": 300 }] }
},
"api": {
"package": "@walkeros/server-store-api",
"config": { "settings": { "endpoint": "$env.API_URL" } },
"cache": {
"store": "redis",
"rules": [{ "ttl": 86400 }]
}
}
},
"transformers": {
"enrich": {
"env": { "store": "$store.api" }
}
}
}
Lookup chain on api.get(K):
api's tier (Redis) — HIT, return.__cache). If
memory HIT, return up and Redis populates.TTL ordering: shortest at the top (memory 300s), longest at the cold end (API 86400s). The bound on staleness is the longest TTL in the chain.
Async-safe by design. Whether your cache store's get is synchronous (the
built-in __cache, an in-memory store) or asynchronous
(@walkeros/server-store-fs, Redis, the cache wrapper itself), the collector
reads through with an await internally. You can mix sync and async stores
freely in a multi-tier chain without any extra configuration — the same HIT/MISS
semantics apply.
Single-flight deduplication is on by default. 50 concurrent
store.get('session:abc') calls on a cold cache produce one backing call,
not 50. All callers receive the same promise.
This is what makes store-level cache useful on a slow backing under high
concurrency. No configuration needed; just set cache on the store.
Verify it works with the inflight_dedups counter (see Observability below).
cache rules cannot do (compared to event cache)Store rules are a stricter subset:
key field. The cache key comes from the caller (store.get(K));
there is no event path to compose.update field. Stores have no event to mutate on hit.stop field. Stores always fall through on miss; halting the pipeline
is an event-cache concept.namespace: "" is rejected by the schema (re-introduces the
collision footgun across stores sharing __cache).Use the event-level Cache on sources,
transformers, or destinations when you need key, update, or stop.
__cacheOmitting cache.store falls back to the collector's built-in __cache. It is
an in-memory LRU map with:
maxEntries: 10000 (fixed in v1)Each wrapped store gets an automatic namespace prefix (the store id) so multiple
stores sharing __cache do not collide. Override with cache.namespace: "myns"
if you want explicit control.
The collector logs one line per wrapped store at startup:
store "sessions" caches with namespace "sessions:" via __cache
wrapped.set(K, V) runs two steps:
backing.set(K, V). If this throws, the wrapper
throws. The cache is not touched.wrapped.delete(K) follows the same shape. A failed cache delete leaves a
poisoned entry that serves stale data until TTL; the warning lets operators
react.
Backing is the source of truth. Code that wraps set / delete should assume
the cache may be lagging.
Read this before relying on the cache for anything correctness-sensitive:
wrapped.set(K, V), a
subsequent wrapped.get(K) in the same process returns V.Pick TTLs accordingly. Short TTLs (1-60s) for mostly-static lookups behind a fast backing; long TTLs (minutes-hours) for cold, expensive lookups where staleness is tolerable.
Each wrapped store exposes counters. Per-store telemetry keys:
walkeros.store_cache.<store_id>.<counter>.
| Counter | Use this to detect |
| ------------------- | ----------------------------------- |
| hits | Cache is actually working |
| misses | Working-set size, cold start |
| populates | New keys being added to cache |
| writes | Set call volume |
| deletes | Delete call volume |
| evictions_entries | maxEntries cap being hit |
| evictions_ttl | TTL sweeper finding expired entries |
| inflight_dedups | Concurrent reads on a cold key |
For interactive debugging at runtime:
const { collector } = await startFlow({
/* ... */
});
const snapshot = collector.stores.sessions.counters;
console.log(snapshot);
// { hits: 412, misses: 18, populates: 18, writes: 0, deletes: 0,
// evictions_entries: 0, evictions_ttl: 0, inflight_dedups: 7 }
Healthy cache: hits / (hits + misses) rises over time. inflight_dedups
proves the herd prevention worked.
get(K) that returns undefined from the backing
is not populated. Every subsequent call for a missing key re-hits the backing
until the value exists. Workaround: write a sentinel value on the first miss
and treat it as "not present" in transformer logic.A.cache.store = B and
B.cache.store = A throws during init. The collector logs the cycle path
before exiting.cache.store: "X" references break). Migrate explicitly.$code:When a step only needs to read a value out of a store or write one into it, you
do not need to wire $store into the step's env and hand-write a $code:
push. The declarative state block on a source, transformer, or destination
does both directions through the mapping engine.
"transformers": {
"stashGclid": {
"state": { "mode": "set", "store": "sessions", "key": "user.session", "value": "data.gclid" }
},
"restoreGclid": {
"state": { "mode": "get", "store": "sessions", "key": "user.session", "value": "data.gclid" }
}
}
key is always the store side; value is always the event side. mode sets
the direction:
set resolves value against the event (a path, constant, fn, or
map) and writes that payload to the store under key.get reads key from the store and writes the fetched value onto the
event at the value path. For a get, value must be a bare string path (or
a ValueConfig with key), not a constant or operator.Omit store to use the built-in __cache tier; state keys there are prefixed
with state: so they never collide with cache entries. State is fail-open:
a store error is logged and the event passes through unchanged. Use state for
simple fetch/stash; reach for $code: only when the logic is genuinely
non-declarative.
Full reference: Website: State.
@walkeros/store-memoryThe dedicated @walkeros/store-memory package was deleted once the built-in
__cache reached feature parity. One-line migration per occurrence:
cache.store: "memory"): drop the store
declaration and omit cache.store. The wrapper falls back to __cache
automatically.env for non-cache use: replace with a small
inline Map inside the component, or use one of the persistent stores
(@walkeros/server-store-fs, -s3, -gcs, -sheets).flow_validate rejects package: "@walkeros/store-memory" and points at the
replacement.
$store. wiring, lifecycleDocumentation:
Flow.Store.cacheSource files:
Cache, StoreCacheRule, EventCacheRule typestesting
Use when wiring `@walkeros/transformer-ga4` into a server flow, overriding default GA4 event mappings, dropping events, adding custom event keys, or troubleshooting GA4 Measurement Protocol decoding. Covers the `before`-chain wiring contract, configuration recipes, and per-field patching with extend/remove.
testing
Use when writing, simulating, validating, or testing with walkerOS step examples. Covers the complete lifecycle from authoring examples to CI integration.
tools
Use when bundling walkerOS flows, testing events with simulate/push, running local servers, validating configs, or configuring Flow JSON files.
data-ai
Use when working with walkerOS sources, understanding event capture, or learning about the push interface. Covers browser, dataLayer, and server source patterns.