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.
spec, _ := whitewater.NewStateStore("accounts.employees")
spec.AddKey("passport", true). // unique: one row per value
AddKey("department", false) // non-unique: value -> many rows
store, err := client.StateStores.Define(ctx, spec, "")
// source "" = manual storeAddKey records declaration intent used by Define — named keys are
declared at creation; at most whitewater.MaxNamedKeys (10). Adding a
key to an existing store has no effect server-side.
The raw facade carries []byte keys/payloads; Store[K, V] binds a
key codec and a payload codec once so every Put/Get/Iterate
serializes through them — matching the Java SDK's StateStore<K,V>:
type Profile struct {
ID string `json:"id"`
Name string `json:"name"`
}
// String keys + JSON payloads — codecs inferred from the types.
profiles, _ := whitewater.StoreFor[Profile](client, "accounts.employees")
// …or explicit codecs — protobuf/msgpack/custom via FuncCodec:
// StoreOf(client, "accounts.employees", StringCodec(), proto)
_, err = profiles.Put(ctx, "user-1", profile,
map[string]any{"passport": "P111"}, "")
row, _ := profiles.Get(ctx, "user-1", true, 500) // row.Value is Profile
_ = profiles.Iterate(ctx, 256, func(r whitewater.Row[string, Profile]) error {
return export(r)
})
// Rebind under different codecs — no wire call:
raw := whitewater.RebindStore(profiles, whitewater.StringCodec(),
whitewater.BytesCodec())client.Store(name) binds the default Store[string, []byte]; the
byte-level client.StateStore(name) facade below remains for raw
access and declaration.
_, err = store.Put(ctx, []byte("user-1"), []byte("profile-1"),
map[string]any{"passport": "P111", "department": "eng", "score": 42},
"") // "" auto-generates mutation_id
_, err = store.Delete(ctx, []byte("user-1"), "stable-mutation-uuid")Putcommits one journaledput_rowthrough the store's RF3 quorum.- Named values are typed:
string-> text, signed/unsigned ints -> i64,bool-> bool,[]byte-> bytes,nil-> null. A[]anyvalue carries one typed value per posting component. Floats are refused — the wire carries exact i64/text/bytes. - Named values must match the store's declared keys — the server
refuses undeclared names.
uniqueclaims arbitrate first-committed-wins by journal order. mutationIDis the idempotency identity: retry a timed-out Put with the same ID and it dedupes; reuse it with different contents and it conflicts.
row, err := store.Get(ctx, []byte("user-1"), false, 0) // *StoreRow | nil
row.Payload // []byte
row.Freshness // AppliedSeq / CommittedSeq / Behind / ServedBy
rows, err := store.GetBy(ctx, "department", "eng", 0, false, 0)
rows.Rows // []StoreRow — every match
rows.Freshness.ServedBy // which Node answeredGetBy is the group/list lookup: one non-unique named value resolves
to every primary row carrying it. strict + waitMs bounded-waits
for the serving replica to apply its committed frontier rather than
answering stale; non-strict reads still report Freshness.Behind
honestly.
err = store.Iterate(ctx, 256, func(row StoreRow) error {
export(row)
return nil
})
page, err := store.Page(ctx, afterKey, 256) // one bounded page
page.NextKey // continuation
page.PinnedSeq // journal position pinned toIterate pages /v1/state-stores/iterate — the same bounded, ordered
row set replica catch-up serves — so PinnedSeq is the iteration's
freshness evidence.
_, err = store.Clear(ctx, "") // one journaled ClearAll
_, err = client.StateStores.Drop(ctx, name) // or store.Drop(ctx)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.StateStores.Define(ctx, spec, "accounts.events")materializes committed Feed history.
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, _ := whitewater.NewStateStore("accounts.tagged")
spec.AddKey("passport", true, whitewater.MetadataExtractor("pp"))
spec.AddKey("department", false, whitewater.JsonExtractor("dept"))
store, err := client.StateStores.Define(ctx, spec, "accounts.events")whitewater.KeyExtractor() reads the record's key,
MetadataExtractor("pp") a Metadata entry, JsonExtractor("dept") a
dotted JSON-path over the payload. Missing fields and unparseable
payloads project a Null posting rather than stalling replay. Manual
stores omit the 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):
c.WCL(ctx, "DEFINE STATESTORE accounts.employees KEYS department, passport UNIQUE")
c.WCL(ctx, "DEFINE STATESTORE accounts.tagged FROM FEED accounts.events KEYS passport = METADATA 'pp' UNIQUE, department = JSON 'dept'")
c.WCL(ctx, "SHOW STATESTORES")
c.WCL(ctx, "DESCRIBE STATESTORE accounts.employees") // stage incl. "dropped"
c.WCL(ctx, "DROP STATESTORE accounts.employees")Commands posts the typed command the facade builds for you:
c.Commands(ctx, []map[string]any{{
"command": "define_state_store", "name": "accounts.employees",
"source": map[string]any{"kind": "manual"},
"named_keys": []map[string]any{{"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 —
GetByalways returns a list.
| Where | Type | Meaning / remedy |
|---|---|---|
AddKey #11 / duplicate / blank |
panic |
Declaration contract (10-key bound, distinct names). |
Define spec + source + bare named keys |
error |
Feed-sourced keys need an Extractor — checked before the call. |
Define manual spec + Extractor |
error |
Extractors apply to feed-sourced stores only. |
| Data op on unbound spec | error |
NewStateStore builds a spec; bind via Define or client.StateStore(name). |
| Undeclared named key / missing store | *Error 400 |
Server-side contract — fix the declaration. |
mutationID reused differently |
*Error 409 |
Retry with the original contents or a fresh ID. |
| No quorum / replica down | *Error 503 |
Retry — the journal needs a quorum to commit. |
strict read can't catch up |
*Error 409 index_behind |
Bounded wait expired; retry or drop strict. |