There is exactly one store: the node-side StateStore — durable,
RF3-replicated, Fjall-backed, and queryable from any Node. The SDK's
StateStore is a thin facade over it: it holds no rows, keeps no local
projection, and every operation is an HTTP call with freshness evidence.
store = (StateStore.named("accounts.employees")
.add_key("passport", unique=True) # one row per value
.add_key("department")) # value -> many rows
client.state_stores.define(store) # declares on the Control Plane
# and returns it boundadd_key records declaration intent used by define — named keys are
declared at creation; at most StateStore.MAX_NAMED_KEYS (10), unique
or not. A named-key declaration only affects define; adding a key to
an existing store has no effect server-side.
store.put(b"user-1", b"profile-1",
named={"passport": "P111", "department": "eng"})
store.put(b"user-2", b"profile-2",
named={"department": "eng"},
mutation_id=uuid4()) # caller-owned idempotency
store.delete(b"user-1")put(key, payload, named=...)commits one journaledput_rowthrough the store's RF3 quorum.key/payloadacceptbytesorstr(UTF-8).namedvalues are typed:str-> text,int-> i64,bool-> bool,bytes-> bytes,None-> null. A list value carries one typed value per posting component. You never hand-encodegetBytes()for ordinary strings/numbers.- Named values must match the store's declared keys — the server
refuses undeclared names.
uniqueclaims arbitrate first-committed-wins by journal order. mutation_idis the idempotency identity: retry a timed-out put with the same id and it dedupes; reuse it with different contents and it conflicts.
row = store.get(b"user-1") # StoreRow | None
row.payload # bytes
row.freshness # applied_seq / committed_seq
# / behind / served_by
rows = store.get_by("department", "eng") # StoreRows — every match
rows.rows # [StoreRow(key, payload, ...)]
rows.freshness.served_by # which Node answeredget_by(named_key, value) is the group/list lookup: one non-unique
named value resolves to every primary row carrying it. Pass
strict=True (with optional wait_ms) on get/get_by and the
serving replica bounded-waits for its applied journal to reach the
committed frontier rather than answering stale; non-strict reads still
report freshness.behind honestly.
for row in store.iterate(): # every row, primary-key order
...
page = store.page(limit=256) # one bounded page
page.next_key # continuation
page.pinned_seq # journal position the page
# was pinned toiterate pages /v1/state-stores/iterate — the same bounded,
ordered row set replica catch-up serves — so pinned_seq is the
iteration's freshness evidence.
store.clear() # one journaled ClearAll
client.state_stores.drop(store.name) # or store.drop()clearempties the store as one durable, idempotent mutation; replicas sweep rows and postings in bounded batches.droptombstones the definition — the name cannot be recreated and every data path refuses. Rows already applied on replicas remain until a bounded reclaim pass.- Feed-sourced stores:
client.state_stores.define(spec, source= "accounts.events")materializes committed Feed history;sync/rebuildare server-side operations, not SDK methods.
Named keys on a feed-sourced store declare a projection — how each
committed source record maps to typed lookup values. Every key carries
an Extractor:
spec = (StateStore.named("accounts.tagged")
.add_key("passport", unique=True, extractor=Extractor.metadata("pp"))
.add_key("department", extractor=Extractor.json("dept")))
store = client.state_stores.define(spec, source="accounts.events")Extractor.record_key() reads the record's key,
Extractor.metadata("pp") a Metadata entry, Extractor.json("dept") a
dotted JSON-path over the payload. Missing fields and unparseable
payloads project a Null posting rather than stalling replay. Manual
stores omit extractor — put carries the values per row.
Everything above is a convenience over the Control Plane's two doors.
WCL spells DEFINE, DROP, DESCRIBE, and SHOW for stores (data ops
stay typed):
client.wcl("DEFINE STATESTORE accounts.employees KEYS department, passport UNIQUE")
client.wcl("DEFINE STATESTORE accounts.tagged FROM FEED accounts.events KEYS passport = METADATA 'pp' UNIQUE, department = JSON 'dept'")
client.wcl("SHOW STATESTORES")
client.wcl("DESCRIBE STATESTORE accounts.employees") # stage incl. "dropped"
client.wcl("DROP STATESTORE accounts.employees")commands posts the typed command the facade builds for you:
client.commands([
{"command": "define_state_store", "name": "accounts.employees",
"source": {"kind": "manual"},
"named_keys": [{"name": "passport", "unique": True}]},
])- Not a local store — there is no in-process copy to drift from the Nodes. Every read goes to a replica; freshness evidence tells you how current it was.
- Not unique on non-unique keys —
get_byalways returns a list.
| Where | Type | Meaning / remedy |
|---|---|---|
add_key #11 / duplicate / blank |
ValueError |
Declaration contract (10-key bound, distinct names). |
define spec + source + bare named keys |
ValueError |
Feed-sourced keys need an extractor — checked before the call. |
define manual spec + extractor |
ValueError |
Extractors apply to feed-sourced stores only. |
| Data op on unbound spec | ValueError |
StateStore.named builds a spec; bind via define or client.state_store(name). |
| Undeclared named key / missing store | Refused (400) |
Server-side contract — fix the declaration. |
mutation_id reused differently |
Conflict (409) |
Retry with the original contents or a fresh id. |
| No quorum / replica down | Unavailable (503) |
Retry — the journal needs a quorum to commit. |
strict read can't catch up |
Conflict (index_behind) |
Bounded wait expired; retry or drop strict. |