Skip to content

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 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.
  • UnorderedMap / UnorderedSet — your defaults. Both are add-wins: concurrent inserts all survive a merge. Values in a map merge recursively, so a UnorderedMap<String, LwwRegister<String>> gives you per-key last-writer-wins (the pattern apps/kv-store uses).

  • SortedMap / SortedSet — same CRDT as the unordered pair, but they also maintain a node-local ordered index, unlocking range(a..b), prefix(p), page(offset, limit), first(), and last(). 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 the BTreeMap/BTreeSet to the unordered types’ HashMap/HashSet. See apps/sorted-kv-store.

  • IndexedMap — an UnorderedMap whose 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 label
    pub 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 entry

    eq pins the index key’s next component and range bounds the one after; on a compound index the free components order the result. A Vec field puts the entry in once per element and an Option::None leaves it out. Change an indexed field with update(key, |v| ...), which keeps the indexes in step. The indexes are not synchronized and not in the root hash — the map reports itself as an UnorderedMap and stores exactly its bytes, so switching a field from UnorderedMap to IndexedMap needs 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 is O(n), every one after it O(log n + k). See apps/indexed-issue-tracker, and apps/indexed-forum for a compound index over a list and IndexedMap beside authored and set collections.

  • Vector — append + index. push assigns 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 a String, number, or enum inside CRDT state (bare scalars are rejected by the state lint). set stamps a fresh timestamp; get_mut edits in place and re-stamps on drop.

  • Counter family — increments are tracked per executor and summed on read, so concurrent increments on different nodes all count. GCounter is increment-only; PNCounter also 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:

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 index

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

UnorderedSet — 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 present
if blocked.contains(&caller)? { return; } // reject the action

Vector — 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 loop

Counter — 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 merge

ReplicatedGrowableArray — 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 once
let text = doc.get_text()?; // deterministic walk over all chars

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.

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, and Authored decides who may change them:

    posts: Authored<IndexedMap<String, Post>>, // filter, sort, count by fields
    comments: Authored<SortedMap<String, Comment>>, // threads as key ranges

    insert stamps the caller as owner, and update / modify / remove are refused for anyone else, by every node that applies the write. Reads are the inner collection’s own (query, range, prefix, entries), with get, owner_of and owned_by_me added. Authored<C> is Guarded<C, Owner>: one entry carries one stamp, so the write policy is a type parameter rather than wrappers that nest. Authored<IndexedMap> stores exactly an AuthoredMap’s bytes, so switching a field between them needs no migration. See apps/indexed-forum.

  • AuthoredMap<K, V> / AuthoredVector<V> — per-entry ownership. AuthoredMap is Authored<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 may update or remove (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 can prefix / range / page instead of walking everything. Identical on the wire (it reports CrdtType::UserStorage like AuthoredMap; the ordering is a node-local derived index, not replicated state), and it costs what SortedMap costs: an index write per insert / remove, extra disk per key, and one O(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 (insert targets env::account_id()); reads are unrestricted (get for the current user, get_for_user(key) for any). Use for per-user preferences or state. See apps/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. SharedStorage lets any current writer write; Ownable is the single-writer case with owner / transfer_ownership; PermissionedStorage takes a policy A (e.g. OwnerAcl, WriterSetAcl, ProtocolAuthorizer) for per-operation granularity (read / write / delete / admin). Writers are rotatable (rotate_writers) and rotation is itself authenticated. See apps/kv-store-with-shared-storage.

  • AccessControl — a role registry built on SharedStorage whose 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. Members claim a name into their own Authored entry; only the authority A writes verdicts: a TEE through a TeeOnly cell (Tee, the default), the admins through a SharedStorage cell (Admin), or nobody (NoAuthority, which reports contests). status is Free, Pending, Owned, Lost or Contested, and owner_of is Some only once a verdict names an owner. See choosing a collection, apps/name-registry (Tee) and apps/name-registry-admin (Admin).

  • FrozenStorage<T> — a map whose key is the SHA256 of 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. See apps/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 a FrozenStorage<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 in init, read with get, and there is no set. 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.