Skip to main content

Documentation Index

Fetch the complete documentation index at: https://iii.dev/docs/llms.txt

Use this file to discover all available pages before exploring further.

Distributed key-value state storage with scope-based organization and reactive triggers that fire on any state change.
iii-state

Architecture

State is server-side key-value storage with trigger-based reactivity. Unlike streams, state does not push updates to WebSocket clients — it fires triggers that workers handle server-side.

Sample Configuration

- name: iii-state
  config:
    adapter:
      name: kv
      config:
        store_method: file_based
        file_path: ./data/state_store
        save_interval_ms: 5000

Configuration

adapter
Adapter
The adapter to use for state persistence and distribution. Defaults to kv when not specified.

Adapters

kv

Built-in key-value store. Supports both in-memory and file-based persistence.
name: kv
config:
  store_method: file_based
  file_path: ./data/state_store
  save_interval_ms: 5000

Configuration

store_method
string
Storage method. Options: in_memory (lost on restart) or file_based (persisted to disk).
file_path
string
Directory path for file-based storage. Each scope is stored as a separate file.
save_interval_ms
number
Interval in milliseconds between automatic disk saves. Defaults to 5000.

redis

Uses Redis as the state backend.
name: redis
config:
  redis_url: ${REDIS_URL:redis://localhost:6379}

Configuration

redis_url
string
The URL of the Redis instance to use.

bridge

Forwards state operations to a remote III Engine instance via the Bridge Client.
name: bridge

Functions

state::set
function
Set a value in state. Fires a state:created trigger if the key did not exist, or state:updated if it did.
scope
string
required
The scope (namespace) to organize state within.
key
string
required
The key to store the value under.
value
any
required
The value to store. Can be any JSON-serializable value. Also accepted as data (backward-compatible alias).
old_value
any
The previous value, or null if the key did not exist.
new_value
any
The value that was stored.
state::get
function
Get a value from state.
scope
string
required
The scope to read from.
key
string
required
The key to retrieve.
value
any
The stored value, or null if the key does not exist.
state::delete
function
Delete a value from state. Fires a state:deleted trigger.
scope
string
required
The scope to delete from.
key
string
required
The key to delete.
value
any
The deleted value, or null if the key did not exist.
state::update
function
Atomically update a value using one or more operations. Fires state:created or state:updated depending on whether the key existed.
scope
string
required
The scope to update within.
key
string
required
The key to update.
ops
UpdateOp[]
required
Array of update operations applied in order. Each operation is a tagged object with a type field and a path. Use path: "" (or omit path) to target the root value.
OperationShapeDescription
set{ "type": "set", "path": "status", "value": "active" }Set a field or replace the root value.
merge{ "type": "merge", "path": ["sessions", "abc"], "value": { "ts": "chunk" } }Shallow-merge an object at the root or at any nested path.
increment{ "type": "increment", "path": "count", "by": 1 }Add by to a numeric field.
decrement{ "type": "decrement", "path": "count", "by": 1 }Subtract by from a numeric field.
append{ "type": "append", "path": ["sessions", "abc", "events"], "value": { "kind": "chunk" } }Push one element to an array (or concatenate a string) at the root, a first-level field, or any nested path.
remove{ "type": "remove", "path": "status" }Remove a field from the current object.
For set, increment, decrement, and remove, paths are first-level field names. For example, user.name updates the field named user.name; it does not traverse into { "user": { "name": ... } }.For merge and append, path accepts either a single string (legacy / first-level field) or an array of literal segments for nested traversal:
// Root merge / append (existing behavior, unchanged).
{ "type": "merge", "path": "", "value": { "status": "active" } }
{ "type": "append", "path": "", "value": "first" }

// First-level merge / append into the field named "session-abc".
{ "type": "merge", "path": "session-abc", "value": { "author": "alice" } }
{ "type": "append", "path": "events", "value": { "kind": "chunk" } }

// Nested merge / append: walks the segments, auto-creating
// missing or non-object intermediates along the way.
{ "type": "merge", "path": ["sessions", "abc"], "value": { "ts": "chunk" } }
{ "type": "append", "path": ["sessions", "abc", "events"], "value": { "kind": "chunk" } }
Each array element is a literal key. ["a.b"] writes a single key named "a.b", not a → b.For root operations (no path), the SDK encoders omit the path field from the wire payload entirely (e.g. { "type": "append", "value": "first" }) rather than emitting "path": null. Servers accept either form on input — null, missing, and empty string all route to the root.Append at a nested missing leaf is always an array. When append walks to a missing leaf at the end of an array-form path, it creates [value] regardless of the value’s type — including string values, which would be kept as a string under the legacy single-string path’s string-concat tier. This is the core fix for issue #1552.Append walks through array intermediates by replacing them with objects. When the path traverses an existing non-object intermediate (array, scalar, or null), the engine replaces it with a fresh {} and continues — mirroring merge’s walk_or_create semantics. So {"a": [1,2,3]} + append(["a", "b"], 42) yields {"a": {"b": [42]}} (the prior array at a is dropped). Callers that need to preserve the array should pre-check with state.get rather than relying on append to error.Note on append.type_mismatch for nested paths: the structured append.type_mismatch error for object/scalar leaves shipped in #1555 for the single-string-path case. The nested-path form added here returns the same error code with the same shape, so consumers parsing errors[] need no new branches. Callers using path: "" or path: "field" against array, string, null, or missing-field leaves are unaffected.Validation: invalid update inputs are rejected with a structured error in the response’s errors array. Reasons include path depth > 32 segments, segment > 256 bytes, value depth > 16, > 1024 top-level keys, type mismatches, non-object targets, or any segment / top-level key matching __proto__ / constructor / prototype. Successfully applied ops still reflect in new_value.
old_value
any
The value before the operations were applied, or null if the key did not exist.
new_value
any
The value after all operations were applied.
errors
UpdateOpError[]
Per-op validation errors. Field is omitted when empty. Each entry has op_index, code, message, and an optional doc_url.

Error codes

Each state::update op may add an entry to the response errors array. Operations are best-effort: successfully applied ops still reflect in new_value, and failed ops are skipped.
CodeTriggered whenFix
set.target_not_objectset tried to write a field while the current value is not an objectSet the root to an object first, or use path: "" to replace the root.
append.target_not_objectappend used a field path while the current value is not an objectSet the root to an object first, or append at path: "".
append.type_mismatchappend targeted an incompatible existing value, such as appending to a number or appending a non-string to a stringMatch the appended value to the existing field type, or initialize the field to an array, string, or null.
increment.target_not_objectincrement used a field path while the current value is not an objectSet the root to an object first.
increment.not_numberincrement targeted an existing field that is not a numberInitialize the field as a number first, for example with set to 0.
decrement.target_not_objectdecrement used a field path while the current value is not an objectSet the root to an object first.
decrement.not_numberdecrement targeted an existing field that is not a numberInitialize the field as a number first, for example with set to 0.
remove.target_not_objectremove used a field path while the current value is not an objectSet the root to an object first. Removing a missing field from an object remains silent.
<op>.path.proto_pollutedA path segment is __proto__, constructor, or prototypeUse a different field name.
<op>.path.segment_too_longA path segment is longer than 256 bytesShorten the field name or merge path segment.
merge.path.too_deepA nested merge path has more than 32 segmentsReduce the nested path depth.
merge.path.empty_segmentA nested merge path array contains an empty segmentRemove the empty segment.
append.path.too_deepA nested append path has more than 32 segmentsReduce the nested path depth.
append.path.empty_segmentA nested append path array contains an empty segmentRemove the empty segment.
merge.value.not_an_objectmerge value is not a JSON objectPass an object as the merge value.
merge.value.too_deepmerge value has JSON nesting deeper than 16 levelsFlatten the value.
merge.value.too_many_keysmerge value has more than 1024 top-level keysSplit the write into smaller updates.
merge.value.proto_pollutedA top-level key in the merge value is __proto__, constructor, or prototypeUse a different key name.
Each error includes op_index, code, and message; doc_url is optional.
{
  "old_value": { "name": "Ada" },
  "new_value": { "name": "Ada" },
  "errors": [
    {
      "op_index": 0,
      "code": "increment.not_number",
      "message": "Expected number at path 'name', got string.",
      "doc_url": "https://iii.dev/docs/workers/iii-state#error-codes"
    }
  ]
}
{
  "old_value": {},
  "new_value": {},
  "errors": [
    {
      "op_index": 0,
      "code": "set.path.proto_polluted",
      "message": "Path segment '__proto__' is not allowed (prototype pollution).",
      "doc_url": "https://iii.dev/docs/workers/iii-state#error-codes"
    }
  ]
}
state::list
function
List all values within a scope.
scope
string
required
The scope to list entries from.
A flat JSON array of all stored values within the scope: any[].
state::list_groups
function
List all scopes that contain state data.
An object with a single groups field:
groups
string[]
A sorted, deduplicated array of all scope names that contain at least one key.

Trigger Type

This worker adds a new Trigger Type: state. When a state value is created, updated, or deleted, all registered state triggers are evaluated and fired if they match.

State Event Payload

When the trigger fires, the handler receives a state event object:
type
string
Always "state".
event_type
string
The kind of change: "state:created", "state:updated", or "state:deleted".
scope
string
The scope where the change occurred.
key
string
The key that changed.
old_value
any
The previous value before the change, or null for newly created keys.
new_value
any
The new value after the change. null for deleted keys.

Sample Code

const fn = iii.registerFunction(
  { id: 'state::onUserUpdated' },
  async (event) => {
    console.log('State changed:', event.event_type, event.key)
    console.log('Previous:', event.old_value)
    console.log('Current:', event.new_value)
    return {}
  },
)

iii.registerTrigger({
  type: 'state',
  function_id: fn.id,
  config: { scope: 'users', key: 'profile' },
})

Usage Example: User Profile with Reactive Sync

Store user profiles in state and react when they change:
await iii.trigger({
  function_id: 'state::set',
  payload: {
    scope: 'users',
    key: 'user-123',
    value: { name: 'Alice', email: 'alice@example.com', preferences: { theme: 'dark' } },
  },
  action: TriggerAction.Void(),
})

const profile = await iii.trigger({
  function_id: 'state::get',
  payload: { scope: 'users', key: 'user-123' },
})

await iii.trigger({
  function_id: 'state::set',
  payload: {
    scope: 'users',
    key: 'user-123',
    value: { name: 'Alice', email: 'alice@example.com', preferences: { theme: 'light' } },
  },
  action: TriggerAction.Void(),
})

const allUsers = await iii.trigger({
  function_id: 'state::list',
  payload: { scope: 'users' },
})
const scopes = await iii.trigger({
  function_id: 'state::list_groups',
  payload: {},
})

Usage Example: Conditional Trigger

Only process profile updates when the email field changed:
const conditionFn = iii.registerFunction(
  { id: 'conditions::emailChanged' },
  async (event) =>
    event.event_type === 'state:updated' &&
    event.old_value?.email !== event.new_value?.email,
)

const fn = iii.registerFunction('state::onEmailChange', async (event) => {
  await sendVerificationEmail(event.new_value.email)
  return {}
})

iii.registerTrigger({
  type: 'state',
  function_id: fn.id,
  config: {
    scope: 'users',
    key: 'profile',
    condition_function_id: conditionFn.id,
  },
})

State Flow