Skip to main content
Ask your AI

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​

PropertyTypeDescription
mode'get' | 'set'Direction. get reads from the store, set writes to it.
storestringOptional store id from flow.stores. Defaults to the in-memory cache.
keyMapping.ValueResolves against { event, ingest } to the store key.
valueMapping.Valueset: the payload to store. get: where to write the fetched value.
mappingMapping.ValueOptional. 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 example event.user.session or event.data.gclid.
  • ingest. reads the pipeline context a server source lifted from the request through config.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:

  • set resolves value to the payload to store. value can be any mapping value (a path, a constant, an fn, a map).
  • get treats value as the write target for the fetched value. event.x writes onto the event. ingest.x writes 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 a ValueConfig with a key); a pure constant, fn, map, loop, or set is 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: before chain → state → next chain → collector.push. All entries run before the collector receives the event, in array order. The source's next route is resolved after state ran, so it can route on a value a get loaded (for example ingest.tier). State is skipped when the source declares a terminus, which receives the raw event with no pipeline.
  • Transformer: before chain → get → step mapping → set → next dispatch. get entries enrich the event before the mapping runs; set entries run after it settles, on every emitting path (straight-through, next routing, and per fork of a fan-out).
  • Destination: get → mapping-to-payload push → set (after a successful send). A failed push skips the set. For a batched destination, an event that is enqueued (not yet delivered) does not trigger set; the batched set-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 }. Inside mapping, segment means the stored row's segment.
  • Without mapping the whole value replaces the target, with mapping the 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 missResult
Fallback: { "key": "segment", "value": "unknown" }Writes "unknown"
conditionReceives undefined; a falsy return skips the rule (no fallback either), a truthy return continues
validateReceives what the rule resolved: the fallback with a key plus value, {} after a map, otherwise undefined; if it rejects, the fallback (if any) applies
fnReceives 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 null anywhere inside a picked object drops the whole object. Picking address from { "address": { "street": "x", "zip": null } } writes no address, while other picked fields still land. When the stored value contains null or other non-JSON values, the entry logs a warning.
  • The event's consent gates a mapping, never a field in the stored value. A consent key inside the stored row does not unlock a consent-gated field. Each item of a loop is mapped with the loop's item mapping under the event's consent: a loop item's consent key neither unlocks nor holds back a value.
  • set with mapping shapes 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:

/flows/server/transformers/sessionSave/state
{
  "mode": "set",
  "key": [
    "event.user.session",
    "event.user.hash"
  ],
  "value": {
    "map": {
      "gclid": "event.data.gclid",
      "started": "event.timestamp"
    }
  }
}
state set remembers the gclid and start time of a session in the built-in cache. Guide chapter state-stores

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

/flows/server/transformers/sessionSave/state/key
[
  "event.user.session",
  "event.user.hash"
]
The key falls back from the session id to the visitor hash. Guide chapter state-stores
/flows/server/transformers/sessionSave/state/value
{
  "map": {
    "gclid": "event.data.gclid",
    "started": "event.timestamp"
  }
}
value maps what is stored. Guide chapter state-stores

session 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).

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/"
  }
}
Out
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/"
    }
  }
}
Remember the session: example sessionStart of step sessionSave. Guide chapter state-stores

Load it on the order​

sessionLoad fetches the stored object back onto the order, where a destination maps data.session.gclid:

/flows/server/transformers/sessionLoad/state
{
  "mode": "get",
  "key": [
    "event.user.session",
    "event.user.hash"
  ],
  "value": "event.data.session"
}
state get restores it on the order, per instance. Guide chapter state-stores

No 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).

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
      }
    }
  ]
}
Out
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
        }
      }
    ]
  }
}
Order without a stored session: example orderWithoutSession of step sessionLoad. Guide chapter state-stores

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

/flows/server/transformers/loadUser
{
  "state": {
    "mode": "get",
    "store": "customers",
    "key": "event.user.id",
    "value": "event.user",
    "mapping": {
      "map": {
        "ltv": "ltv",
        "segment": "segment"
      }
    }
  },
  "examples": { … }
}
loadUser puts the customer lifetime value and segment on logged-in events. Guide chapter 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

/flows/server/transformers/loadUser/state/mapping
{
  "map": {
    "ltv": "ltv",
    "segment": "segment"
  }
}
mapping picks fields from the stored row and merges them into the target. Guide chapter state-stores

The 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.

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
      }
    }
  ]
}
Out
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
        }
      }
    ]
  }
}
Known customer: example knownCustomer of step loadUser. Guide chapter state-stores

Fetch 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".

  • Cache short-circuits the pipeline on a hit; state always reads or writes and continues.
  • Stores provide the backing that state reads from and writes to.
  • Mapping defines the key and value resolution.
💡 Need implementation support?
elbwalker offers hands-on support: setup review, measurement planning, destination mapping, and live troubleshooting. Book a 2-hour session (€399)