State
State is a built-in config property available on source, transformer, and destination steps. It replaces inline $code: for the two most common store operations: stash a value (set) and fetch a value (get). Both directions reuse the same mapping engine, so any value you can express in a mapping you can stash or fetch.
Configuration
Add state to any step. It accepts a single operation or an array of operations:
"transformers": {
"session": {
"state": { "mode": "set", "key": "event.user.session", "value": "event.data.gclid" }
}
}
Properties
| Property | Type | Description |
|---|---|---|
mode | 'get' | 'set' | Direction. get reads from the store, set writes to it. |
store | string | Optional store id from flow.stores. Defaults to the in-memory cache. |
key | Mapping.Value | Resolves against { event, ingest } to the store key. |
value | Mapping.Value | set: the payload to store. get: where to write the fetched value. |
mapping | Mapping.Value | Optional. Shapes the value read (get) or the payload (set). Merged into the target. |
Paths name their side
key and value resolve against { event, ingest }, so every path starts with the side it reads from:
event.reads the event, for exampleevent.user.sessionorevent.data.gclid.ingest.reads the pipeline context a server source lifted from the request throughconfig.ingest, for example a site id from the URL or an API key header.
A path without one of these prefixes never resolves. walkeros validate rejects it, and at runtime the entry logs a warning and is skipped.
key names the slot in the store. What value means depends on the mode:
setresolvesvalueto the payload to store.valuecan be any mapping value (a path, a constant, anfn, amap).gettreatsvalueas the write target for the fetched value.event.xwrites onto the event.ingest.xwrites into the ingest, where later steps and route matchers can read it but no destination ever receives it. It must be a string path (or aValueConfigwith akey); a pure constant,fn,map,loop, orsetis rejected, and the path may not contain*.
// set: value is the payload (read from the event, written to the store)
{ "mode": "set", "key": "event.user.id", "value": "event.data.token" }
// get: value is the target (fetched from the store, written onto the event)
{ "mode": "get", "key": "event.user.id", "value": "event.data.token" }
An ingest. write is shared by every event in the scope and lands in place. When a step fans one input out into several events, an ingest. target does not keep a separate value per event; use an event. target when each event needs its own.
The default store
When store is omitted, state uses the same built-in in-memory store as cache (__cache). This store is an entry-capped LRU with a TTL sweep, so entries are evictable: treat the default store as best-effort, not durable. For persistence across restarts or instances, point store at a configured backing (filesystem, S3, GCS).
State keys on the default store are prefixed with state: so they cannot collide with cache entries that share the same store. Explicitly named stores use the key as-is.
An explicitly named store that is not declared in flow.stores is a validation error (STATE_STORE_UNKNOWN), reported by walkeros validate and by the deploy preflight. At runtime such an entry logs an error and is skipped; state never silently falls back to the default store for a named miss.
Stores declared with file: true hold raw bytes and are not supported by state. Naming one is a validation error (STATE_STORE_FILE), and at runtime the entry is skipped with a warning. Handle byte stores in a transformer instead.
Execution order
state normalizes to an array and entries run sequentially. Where the operations slot into the pipeline depends on the step type:
- Source:
beforechain → state →nextchain →collector.push. All entries run before the collector receives the event, in array order. The source'snextroute is resolved after state ran, so it can route on a value agetloaded (for exampleingest.tier). State is skipped when the source declares aterminus, which receives the raw event with no pipeline. - Transformer:
beforechain →get→ step mapping →set→nextdispatch.getentries enrich the event before the mapping runs;setentries run after it settles, on every emitting path (straight-through,nextrouting, and per fork of a fan-out). - Destination:
get→ mapping-to-payload push →set(after a successful send). A failed push skips theset. For a batched destination, an event that is enqueued (not yet delivered) does not triggerset; the batchedset-on-flush path is not yet wired.
State is fail-open: if a store call or value resolution throws, the error is logged and the event passes through unchanged. The chain always continues. A key that does not resolve logs a warning and the event still passes through; a get that finds nothing in the store is silent.
Shape what you read or write
mapping runs a full mapping value over the value a get read from the store, or over the payload a set is about to write. One lookup can then fill several fields, with a default for each:
"transformers": {
"loadUser": {
"state": {
"mode": "get",
"store": "customers",
"key": "event.user.id",
"value": "event.user",
"mapping": {
"map": {
"ltv": "ltv",
"segment": { "key": "segment", "value": "unknown" }
}
}
}
}
}
With a stored row { "ltv": 1840, "segment": "loyal", "email": "x@y" }, the event's user keeps its id and gains ltv and segment. The email column is never read onto the event.
- Paths are relative to the value read, not to
{ event, ingest }. Insidemapping,segmentmeans the stored row'ssegment. - Without
mappingthe whole value replaces the target, withmappingthe result merges into it. An object result merges into an object target and keeps the target's other keys. Anything else (a string, a number, an array, or a target that is not an object) replaces the target. - On a miss, only what you declared is written. The mapping runs even when the store has nothing under the key, so declared fallbacks still apply. An empty result writes nothing and stays silent.
| Mapping on a miss | Result |
|---|---|
Fallback: { "key": "segment", "value": "unknown" } | Writes "unknown" |
condition | Receives undefined; a falsy return skips the rule (no fallback either), a truthy return continues |
validate | Receives what the rule resolved: the fallback with a key plus value, {} after a map, otherwise undefined; if it rejects, the fallback (if any) applies |
fn | Receives undefined; a non-empty return is written |
A plain path such as "segment" | Resolves to nothing, nothing is written |
On a miss fn and condition receive undefined, so guard for it. An unguarded v.segment throws and logs an error on every miss.
A few more rules:
- A
nullanywhere inside a picked object drops the whole object. Pickingaddressfrom{ "address": { "street": "x", "zip": null } }writes noaddress, while other picked fields still land. When the stored value containsnullor other non-JSON values, the entry logs a warning. - The event's consent gates a mapping, never a field in the stored value. A
consentkey inside the stored row does not unlock a consent-gated field. Each item of aloopis mapped with the loop's item mapping under the event's consent: a loop item'sconsentkey neither unlocks nor holds back a value. setwithmappingshapes the payload, then reads the stored entry, merges the result into it, and writes it back. This is not atomic: when two instances write the same key at once, the last writer wins. A stored value that is not an object is replaced, with a warning. A result that is not an object is written without a read.
Examples
The first examples come from
flow-complete.json,
the tested example flow. Its enrich sequence calls sessionSave on
session start and sessionLoad on order complete (see
Routing).
Save session data
sessionSave stashes the raw click id and the start time of a session in the
default store:
{
"mode": "set",
"key": [
"event.user.session",
"event.user.hash"
],
"value": {
"map": {
"gclid": "event.data.gclid",
"started": "event.timestamp"
}
}
}state-storesThe key is a list of paths: the session id when the browser has one, else the
cookieless visitor hash. The value is a map, so one entry stores both fields:
[
"event.user.session",
"event.user.hash"
]state-stores{
"map": {
"gclid": "event.data.gclid",
"started": "event.timestamp"
}
}state-storessession start stores gclid and start time under the session and visitor hash in the built-in cache; the event passes unchanged. The later order reads it (flow test).
{
"name": "session start",
"entity": "session",
"action": "start",
"data": {
"id": "s3ss10n",
"isNew": true,
"count": 1,
"marketing": true,
"gclid": "gclid-abc123"
},
"id": "ev-session-start",
"trigger": "load",
"timestamp": 1700000000000,
"consent": {
"functional": true,
"marketing": true
},
"globals": {
"language": "en",
"pageGroup": "content"
},
"user": {
"device": "d3v1c3",
"session": "s3ss10n",
"hash": "5f1c0a9e7d3b2c41",
"botScore": 0,
"botCategory": "human"
},
"source": {
"type": "express",
"platform": "server",
"url": "https://www.example.com/"
}
}return {
"event": {
"name": "session start",
"entity": "session",
"action": "start",
"data": {
"id": "s3ss10n",
"isNew": true,
"count": 1,
"marketing": true,
"gclid": "gclid-abc123"
},
"id": "ev-session-start",
"trigger": "load",
"timestamp": 1700000000000,
"consent": {
"functional": true,
"marketing": true
},
"globals": {
"language": "en",
"pageGroup": "content"
},
"user": {
"device": "d3v1c3",
"session": "s3ss10n",
"hash": "5f1c0a9e7d3b2c41",
"botScore": 0,
"botCategory": "human"
},
"source": {
"type": "express",
"platform": "server",
"url": "https://www.example.com/"
}
}
}sessionStart of step sessionSave. Guide chapter state-storesLoad it on the order
sessionLoad fetches the stored object back onto the order, where a
destination maps data.session.gclid:
{
"mode": "get",
"key": [
"event.user.session",
"event.user.hash"
],
"value": "event.data.session"
}state-storesNo session start was seen for this session in this instance, so nothing is restored and the event passes unchanged. With a stored session data.session carries the gclid (flow test).
{
"name": "order complete",
"entity": "order",
"action": "complete",
"data": {
"id": "ORD-100",
"total": 149.8,
"currency": "EUR",
"coupon": "WELCOME10"
},
"id": "ev-order-complete",
"trigger": "load",
"timestamp": 1700000000000,
"consent": {
"functional": true,
"marketing": true
},
"globals": {
"language": "en",
"pageGroup": "checkout"
},
"user": {
"device": "d3v1c3",
"session": "s3ss10n",
"id": "cust-42",
"email": "jane@example.com",
"hash": "5f1c0a9e7d3b2c41",
"botScore": 0,
"botCategory": "human"
},
"source": {
"type": "express",
"platform": "server",
"url": "https://www.example.com/checkout/thanks"
},
"nested": [
{
"entity": "product",
"data": {
"id": "SKU-1",
"name": "Trail Runner",
"price": 129.9,
"quantity": 1
}
},
{
"entity": "product",
"data": {
"id": "SKU-2",
"name": "Running Socks",
"price": 9.95,
"quantity": 2
}
}
]
}return {
"event": {
"name": "order complete",
"entity": "order",
"action": "complete",
"data": {
"id": "ORD-100",
"total": 149.8,
"currency": "EUR",
"coupon": "WELCOME10"
},
"id": "ev-order-complete",
"trigger": "load",
"timestamp": 1700000000000,
"consent": {
"functional": true,
"marketing": true
},
"globals": {
"language": "en",
"pageGroup": "checkout"
},
"user": {
"device": "d3v1c3",
"session": "s3ss10n",
"id": "cust-42",
"email": "jane@example.com",
"hash": "5f1c0a9e7d3b2c41",
"botScore": 0,
"botCategory": "human"
},
"source": {
"type": "express",
"platform": "server",
"url": "https://www.example.com/checkout/thanks"
},
"nested": [
{
"entity": "product",
"data": {
"id": "SKU-1",
"name": "Trail Runner",
"price": 129.9,
"quantity": 1
}
},
{
"entity": "product",
"data": {
"id": "SKU-2",
"name": "Running Socks",
"price": 9.95,
"quantity": 2
}
}
]
}
}orderWithoutSession of step sessionLoad. Guide chapter state-storesThe default store is per instance. Behind a load balancer, point both steps at a shared store so the order finds the session saved on another instance.
Read from a named store
loadUser reads the customer's record from the customers store, only for
logged-in events. Its mapping picks ltv and segment from the stored row
and merges them into user, so the id and hash already there stay:
{
"state": {
"mode": "get",
"store": "customers",
"key": "event.user.id",
"value": "event.user",
"mapping": {
"map": {
"ltv": "ltv",
"segment": "segment"
}
}
},
"examples": { … }
}state-stores/flows/server/transformers/loadUser/state/store: "customers". store reads from a named store instead of the built-in cache. Guide chapter state-stores
{
"map": {
"ltv": "ltv",
"segment": "segment"
}
}state-storesThe customer id is looked up in the customers store (fs demo data, cached for five minutes) and its lifetime value and segment are merged into user, next to the id and hash already there.
{
"name": "order complete",
"entity": "order",
"action": "complete",
"data": {
"id": "ORD-100",
"total": 149.8,
"currency": "EUR",
"coupon": "WELCOME10"
},
"id": "ev-order-complete",
"trigger": "load",
"timestamp": 1700000000000,
"consent": {
"functional": true,
"marketing": true
},
"globals": {
"language": "en",
"pageGroup": "checkout"
},
"user": {
"device": "d3v1c3",
"session": "s3ss10n",
"id": "cust-42",
"email": "jane@example.com",
"hash": "5f1c0a9e7d3b2c41",
"botScore": 0,
"botCategory": "human"
},
"source": {
"type": "express",
"platform": "server",
"url": "https://www.example.com/checkout/thanks"
},
"nested": [
{
"entity": "product",
"data": {
"id": "SKU-1",
"name": "Trail Runner",
"price": 129.9,
"quantity": 1
}
},
{
"entity": "product",
"data": {
"id": "SKU-2",
"name": "Running Socks",
"price": 9.95,
"quantity": 2
}
}
]
}return {
"event": {
"name": "order complete",
"entity": "order",
"action": "complete",
"data": {
"id": "ORD-100",
"total": 149.8,
"currency": "EUR",
"coupon": "WELCOME10"
},
"id": "ev-order-complete",
"trigger": "load",
"timestamp": 1700000000000,
"consent": {
"functional": true,
"marketing": true
},
"globals": {
"language": "en",
"pageGroup": "checkout"
},
"user": {
"device": "d3v1c3",
"session": "s3ss10n",
"id": "cust-42",
"email": "jane@example.com",
"hash": "5f1c0a9e7d3b2c41",
"botScore": 0,
"botCategory": "human",
"ltv": 420,
"segment": "loyal"
},
"source": {
"type": "express",
"platform": "server",
"url": "https://www.example.com/checkout/thanks"
},
"nested": [
{
"entity": "product",
"data": {
"id": "SKU-1",
"name": "Trail Runner",
"price": 129.9,
"quantity": 1
}
},
{
"entity": "product",
"data": {
"id": "SKU-2",
"name": "Running Socks",
"price": 9.95,
"quantity": 2
}
}
]
}
}knownCustomer of step loadUser. Guide chapter state-storesFetch a product margin before sending
A destination reads a per-product margin from a store and writes it onto the event before mapping it to the vendor payload:
"destinations": {
"warehouse": {
"package": "@walkeros/server-destination-api",
"state": { "mode": "get", "store": "catalog", "key": "event.data.product_id", "value": "event.data.margin" }
}
}
Route by a per-site registry
One endpoint serves many sites, and the URL is the only thing that says which one. The source lifts the site id from the path into ingest. A transformer looks up that site's row in a registry store and keeps it in ingest, so routing can use it without it reaching any destination:
"sources": {
"collect": {
"package": "@walkeros/server-source-express",
"config": {
"settings": { "paths": ["/:site/e"] },
"ingest": {
"map": { "site": { "fn": "$code:(req) => req.path.split('/')[1]" } }
}
},
"next": "registry"
}
},
"transformers": {
"registry": {
"state": { "mode": "get", "store": "registry", "key": "ingest.site", "value": "ingest.tenant" },
"next": {
"one": [
{
"match": { "key": "ingest.tenant.tier", "operator": "eq", "value": "enterprise" },
"next": "enterprise"
},
"shared"
]
}
}
}
The lookup sits on a transformer, which runs its get before its next route. A source also runs its state before its next route resolves, so a source-level get can feed that source's routing too.
Destination mappings resolve against the event only, so ingest.tenant is not readable there. To put a registry field into the payload, target the event instead: "value": "event.data.tenant".