Collections
Your #[app::state] is built from the collection types in
calimero_storage::collections. Each is a CRDT: concurrent edits on
different nodes merge deterministically, with no coordination, so replicas
converge to the same state. Choosing the right collection is choosing the right
merge behavior.
Type reference
Section titled “Type reference”| Type | Merge semantics | When to use |
|---|---|---|
UnorderedMap<K, V> |
Add-wins union; shared keys merge their values recursively. No iteration order. | Default key→value store; O(1) point lookups. |
UnorderedSet<V> |
Add-wins union of membership. | Default set; O(1) membership tests. |
SortedMap<K, V> |
Same merge as UnorderedMap, plus a node-local ordered index. |
When you need range / prefix / pagination / sorted iteration. |
SortedSet<V> |
Same merge as UnorderedSet, plus a node-local ordered index. |
Ordered membership with range / prefix / pagination. |
IndexedMap<K, V> |
Same merge and stored bytes as UnorderedMap, plus node-local secondary indexes the value type declares. |
List views that filter, count or page by a field: “open issues, newest first”, “notes of deal 7”. |
Vector<V> |
Append-only sequence; push mints a stable element id. |
Ordered lists where you append and index. |
FugueText |
Fugue sequence CRDT over run-length blocks; concurrent runs never interleave. | Collaborative plain text. |
RichText<Sc> |
A FugueText plus write-once formatting marks. |
One formatted body. |
RichDocument<Sc> |
Ordered blocks, each with kind, depth, attributes and a RichText body, merged field by field. |
Block documents: headings, paragraphs, lists. |
ReplicatedGrowableArray |
Text-editing CRDT; characters ordered by neighbor + HLC. Concurrent runs can interleave. | Apps that already store RGA text. |
LwwRegister<T> |
Last-writer-wins by HLC timestamp (ties broken by node id). | A single scalar/field where the latest write should win. |
Counter / GCounter / PNCounter |
Per-executor slots summed at read. | Distributed counts; PNCounter allows decrements. |
Authored<C> |
Any keyed collection C (UnorderedMap, SortedMap, IndexedMap) where each entry is owned by its inserter. |
Shared data where users own what they wrote, read however C reads. |
AuthoredMap<K, V> |
Authored<UnorderedMap<K, V>>. |
Shared map where users own their own entries. |
AuthoredSortedMap<K, V> |
Authored<SortedMap<K, V>>. |
Shared map with hierarchical keys where reads are range / prefix slices. |
WriteOnce<C> |
Authored<C> whose entries can never be edited or deleted, even by their owner. |
Signed messages, votes, an audit log. |
Moderated<C> / ModeratedOnce<C> |
Authored<C> / WriteOnce<C> whose entries a rotatable set of moderators may also delete. |
Posts or messages where moderators remove spam. |
AuthoredVector<V> |
Vector where each slot is owned by its author. |
Append-only log where authors control their entries. |
UserStorage<T> |
Per-user slots; each executor writes only its own. | Per-user state; anyone reads, only the owner writes their slot. |
SharedStorage<T> |
Value guarded by a writer set; writes verified at merge. | Group-writable shared state with rotatable writers. |
Ownable<T> |
Single-owner cell (a SharedStorage with one writer). |
Single-owner resource with transfer. |
PermissionedStorage<T, A> |
Writer-set cell with a custom authorization policy. | Fine-grained per-operation access (read/write/delete/admin). |
FrozenStorage<T> |
Content-addressed, immutable; key is SHA256(value). |
Immutable, de-duplicated blobs/documents. |
ContentAddressed<C> |
Any keyed collection C with [u8; 32] keys, content-addressed and immutable. |
An immutable log you also filter or page, e.g. ContentAddressed<IndexedMap<[u8; 32], Event>>. |
Frozen<T> |
One value, written once by whoever created it; no node accepts a change or a delete. | A charter, a founding date, configuration fixed at init. |
AccessControl |
Role registry (admins + named roles) backed by SharedStorage. |
Multi-tier authorization (admins, editors, …). |
Registry<K, V, A> |
Claims owned by their claimants; verdicts written only by the authority A, each its own entry, the highest epoch then lowest order standing. |
Usernames, seats, slugs: at most one owner per name. |
The everyday types
Section titled “The everyday types”-
UnorderedMap/UnorderedSet— your defaults. Both are add-wins: concurrent inserts all survive a merge. Values in a map merge recursively, so aUnorderedMap<String, LwwRegister<String>>gives you per-key last-writer-wins (the patternapps/kv-storeuses). -
SortedMap/SortedSet— same CRDT as the unordered pair, but they also maintain a node-local ordered index, unlockingrange(a..b),prefix(p),page(offset, limit),first(), andlast(). The index is not synchronized — each node keeps its own and rebuilds it after sync if stale — so you pay a little write overhead. They are theBTreeMap/BTreeSetto the unordered types’HashMap/HashSet. Seeapps/sorted-kv-store. -
IndexedMap— anUnorderedMapwhose value type declares what it is looked up by, so a list view is a seek instead of a scan over every entry. Derive the declaration and query by index name:#[derive(BorshSerialize, BorshDeserialize, AbiType, app::Mergeable, app::Indexed)]#[borsh(crate = "calimero_sdk::borsh")]#[index(status_created(status, created_at))]pub struct Issue {#[index] pub status: LwwRegister<String>,#[index] pub labels: LwwRegister<Vec<String>>, // one row per labelpub created_at: LwwRegister<u64>,}let newest_open = self.issues.query("status_created").eq("open").desc().limit(20).entries()?;let open = self.issues.query("status").eq("open").count()?; // loads no entryeqpins the index key’s next component andrangebounds the one after; on a compound index the free components order the result. AVecfield puts the entry in once per element and anOption::Noneleaves it out. Change an indexed field withupdate(key, |v| ...), which keeps the indexes in step. The indexes are not synchronized and not in the root hash — the map reports itself as anUnorderedMapand stores exactly its bytes, so switching a field fromUnorderedMaptoIndexedMapneeds no migration. A validity marker makes each node rebuild its own indexes on the first query after anything changed the entries behind them (a sync, a merge, a new index declaration); that first query isO(n), every one after itO(log n + k). Seeapps/indexed-issue-tracker, andapps/indexed-forumfor a compound index over a list andIndexedMapbeside authored and set collections. -
Vector— append + index.pushassigns each element a stable id, so iteration order is preserved across reloads. -
LwwRegister<T>— wrap any scalar that should follow last-writer-wins. This is how you store aString, number, or enum inside CRDT state (bare scalars are rejected by the state lint).setstamps a fresh timestamp;get_mutedits in place and re-stamps on drop. -
Counterfamily — increments are tracked per executor and summed on read, so concurrent increments on different nodes all count.GCounteris increment-only;PNCounteralso decrements.
ReplicatedGrowableArray converges when two people type into the
same position at once. Each character is an immutable node keyed by a CharId
(HLC + sequence); merge is just the union of those nodes, and read order is a
deterministic walk — so both editors land on the same text without coordination:
Worked examples
Section titled “Worked examples”A different real scenario for each everyday type — pick the one whose merge behavior matches what you are modeling.
SortedMap — a live leaderboard. Keys sort ascending by their raw bytes,
so to show highest scores first, store u64::MAX - score big-endian; the index
then puts top scores at the front and page seeks instead of scanning:
// Leaderboard, highest score first. Big-endian is mandatory: otherwise 255// sorts after 256, and `u64::MAX - score` inverts ascending order into a ranking.let mut board: SortedMap<[u8; 8], LwwRegister<String>> = SortedMap::new();board.insert((u64::MAX - score).to_be_bytes(), player.into())?;let top_10 = board.page(0, 10)?; // O(log n + 10) via the indexThe same shape backs a time-ordered activity feed — key on a big-endian
timestamp and range(since.to_be_bytes()..now.to_be_bytes())? to scan a window.
UnorderedMap — an asset registry. The default key→value store when every
access is a point lookup by id and iteration order never matters:
// id -> asset metadata; O(1) lookups, no ordering overhead.let mut assets: UnorderedMap<String, LwwRegister<String>> = UnorderedMap::new();assets.insert(asset_id.clone(), metadata_uri.into())?;let uri = assets.get(&asset_id)?; // O(1), independent of sizeUnorderedSet — a moderation blocklist. Add-wins membership is exactly what
a blocklist (or a tag set) wants: if two moderators block the same account on
different nodes at once, the merge keeps it blocked rather than dropping an add:
let mut blocked: UnorderedSet<String> = UnorderedSet::new();let was_new = blocked.insert(account_id)?; // false if already presentif blocked.contains(&caller)? { return; } // reject the actionVector — an append-only audit log. push mints a stable element id, so
insertion order survives reloads; read the whole log with a single iter pass:
let mut audit: Vector<LwwRegister<String>> = Vector::new();audit.push(format!("{caller} granted role: admin").into())?;for line in audit.iter()? { /* render oldest-first */ } // one O(n) pass — never get(i) in a loopCounter — like / view counts. Increments are tracked per executor and
summed on read, so two nodes liking the same post concurrently both count — no
lost updates, unlike a LwwRegister<u64> where one write would clobber the
other. Nest per id:
let mut likes: UnorderedMap<String, Counter> = UnorderedMap::new();likes.entry(post_id)?.or_default().increment()?; // every concurrent like survives mergeReplicatedGrowableArray — a collaborative document. A shared notepad where
two people type at once; build text with one bulk insert_str, never a
per-character loop (each call re-materializes the whole document):
let mut doc: ReplicatedGrowableArray = ReplicatedGrowableArray::new();doc.insert_str(0, "Hello, world")?; // bulk — materializes order oncelet text = doc.get_text()?; // deterministic walk over all charsKey types
Section titled “Key types”Map and set entries are addressed by the raw bytes of their key, so a key type
must implement AsRef<[u8]> (the SDK’s StorageKey requirement, alongside being
borsh-(de)serializable, Eq, and 'static). A bare u64 or an arbitrary struct has
no canonical byte form, so it is rejected at compile time — insert / get simply
won’t accept it:
// Does not compile: `u64` is not a `StorageKey` — it has no `AsRef<[u8]>`.let mut scores: UnorderedMap<u64, LwwRegister<String>> = UnorderedMap::new();The fix is a thin newtype that wraps an already-byte-encodable representation and
forwards AsRef<[u8]> to it. This is the apps/custom-key-store pattern — and it is
also where you put key normalization or validation:
#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]#[borsh(crate = "calimero_sdk::borsh")]pub struct Slug(String);
impl AsRef<[u8]> for Slug { // the impl that makes it a valid key fn as_ref(&self) -> &[u8] { self.0.as_bytes() }}
// Now compiles: `Slug: StorageKey`, so every key op is available.let mut pages: UnorderedMap<Slug, LwwRegister<String>> = UnorderedMap::new();In an owned collection (Authored, WriteOnce, Moderated, ModeratedOnce) a
key’s as_ref() bytes must also be the tail of its borsh encoding, because
every node recovers the key from the stored entry’s bytes. Slug(String) is
(borsh writes a String as its length, then its bytes), and so are Vec<u8>,
[u8; N] and account ids; a key whose bytes are not is refused on insert.
The same shape works for an id newtype over [u8; 32]. For a numeric key, wrap the
big-endian bytes (u64::to_be_bytes()) so that — in a SortedMap — byte order
matches numeric order; see Modeling your state for why
little-endian keys sort incorrectly.
Access-controlled collections
Section titled “Access-controlled collections”These add an authorization model on top of a CRDT. The crucial property:
authorization is enforced at merge time, not just locally. A node’s
fail-fast local check (e.g. only_admin(), owner_of()) is a UX convenience;
the security boundary is the merge, where every replica re-verifies signed writes
against the writer set / owner. A forged write does not survive convergence.
-
Authored<C>— per-entry ownership over any keyed collection. The collection decides how entries are read, andAuthoreddecides who may change them:posts: Authored<IndexedMap<String, Post>>, // filter, sort, count by fieldscomments: Authored<SortedMap<String, Comment>>, // threads as key rangesinsertstamps the caller as owner, andupdate/modify/removeare refused for anyone else, by every node that applies the write. Reads are the inner collection’s own (query,range,prefix,entries), withget,owner_ofandowned_by_meadded.Authored<C>isGuarded<C, Owner>: one entry carries one stamp, so the write policy is a type parameter rather than wrappers that nest.Authored<IndexedMap>stores exactly anAuthoredMap’s bytes, so switching a field between them needs no migration. Seeapps/indexed-forum. -
AuthoredMap<K, V>/AuthoredVector<V>— per-entry ownership.AuthoredMapisAuthored<UnorderedMap<K, V>>under its original name. Anyone can insert a new key (or push a new slot); the inserter is recorded as owner. Only the owner mayupdateorremove(vector entries are tombstoned, not physically removed, so concurrent merges stay sound). Anyone can read. Use for shared maps/logs where users own what they contributed. -
AuthoredSortedMap<K, V>— the same ownership model with an ordered view, so a reader canprefix/range/pageinstead of walking everything. Identical on the wire (it reportsCrdtType::UserStoragelikeAuthoredMap; the ordering is a node-local derived index, not replicated state), and it costs whatSortedMapcosts: an index write perinsert/remove, extra disk per key, and oneO(n)rebuild on the first ordered read after a sync. -
UserStorage<T>— per-user slots keyed by identity. Each executor can only write its own slot (inserttargetsenv::account_id()); reads are unrestricted (getfor the current user,get_for_user(key)for any). Use for per-user preferences or state. Seeapps/kv-store-with-user-and-frozen-storage. -
SharedStorage<T>/Ownable<T>/PermissionedStorage<T, A>— writer-set guarded. The value lives behind a set of authorized writer identities.SharedStoragelets any current writer write;Ownableis the single-writer case withowner/transfer_ownership;PermissionedStoragetakes a policyA(e.g.OwnerAcl,WriterSetAcl,ProtocolAuthorizer) for per-operation granularity (read / write / delete / admin). Writers are rotatable (rotate_writers) and rotation is itself authenticated. Seeapps/kv-store-with-shared-storage. -
AccessControl— a role registry built onSharedStoragewhose writer set is the admin tier. Admins grant/revoke named roles (grant(role, who),has_role,grant_admin, …); roles are LWW booleans enforced at merge. Use for multi-tier authorization. -
Registry<K, V, A>— one owner per name. Membersclaima name into their ownAuthoredentry; only the authorityAwrites verdicts: a TEE through aTeeOnlycell (Tee, the default), the admins through aSharedStoragecell (Admin), or nobody (NoAuthority, which reports contests).statusisFree,Pending,Owned,LostorContested, andowner_ofisSomeonly once a verdict names an owner. See choosing a collection,apps/name-registry(Tee) andapps/name-registry-admin(Admin).
Immutable / content-addressed
Section titled “Immutable / content-addressed”-
FrozenStorage<T>— a map whose key is theSHA256of the value. Entries are immutable: no overwrite, no remove.insert(value)returns the hash; concurrent inserts of identical content de-duplicate automatically. Use for archives, static assets, or any content-addressed immutable data. Seeapps/kv-store-with-user-and-frozen-storage. -
ContentAddressed<C>— the same rule over any keyed collection with[u8; 32]keys:ContentAddressed<IndexedMap<[u8; 32], Event>>is an immutable, de-duplicated log you can still filter and page.insert(value)returns the hash, every node refuses an entry whose key is not its hash, and nothing can update or remove one.ContentAddressed<UnorderedMap<[u8; 32], T>>stores exactly aFrozenStorage<T>’s bytes. A content-addressed value should be plain data, since its hash is taken over the bytes it is written with. -
WriteOnce<C>— owned and immutable: the inserter owns the entry, and nobody, the owner included, can change or delete it. Use it when the key is the app’s own (a message id) rather than a hash. -
Frozen<T>— one value rather than a collection: written ininit, read withget, and there is noset. Its only writer holds a write-once capability, so every node refuses a changed value or a removal.
See Choosing state for how these compare, and for what protects the collections nested inside a guarded entry.