Skip to content

CRDT Collections

Calimero provides conflict-free replicated data types (CRDTs) for automatic state synchronization. Values are encoded with Calimero’s Borsh encoder, so data written from JavaScript matches the bytes produced by Rust services as long as both sides share the same Borsh schema.

Import the collection classes from the /collections entry point, and the factory helpers from the package root:

import { UnorderedMap, UnorderedSet, Vector } from '@calimero-network/calimero-sdk-js/collections';
import { createUnorderedMap, createCounter } from '@calimero-network/calimero-sdk-js';

Every collection exposes the same identity/serialization members:

  • id(): string — the collection’s id as hex.
  • idBytes(): Uint8Array — a copy of the id bytes.
  • toJSON() — a { __calimeroCollection, id } handle (how a collection is stored inside another collection and rehydrated).

Most collections also have a static fromId(id) that reopens an existing collection at a known id without a fresh host allocation — available on UnorderedMap, SortedMap, AuthoredMap, AuthoredVector, UserStorage, FrozenStorage, SharedStorage (and Vector.fromArray / Rga.fromText seed new collections from data).

Last-write-wins map for key/value storage.

const map = new UnorderedMap<string, string>();
map.set('key', 'value');
const value = map.get('key'); // 'value' | null
const exists = map.has('key'); // boolean
map.remove('key'); // void
const entries = map.entries(); // Array<[K, V]>
const keys = map.keys(); // K[]
const values = map.values(); // V[]

Last-write-wins set for unique membership.

const set = new UnorderedSet<string>();
// or seed values: new UnorderedSet<string>({ initialValues: ['alice', 'bob'] })
set.add('alice'); // true on first insert, false if already present
const present = set.has('alice'); // boolean
set.delete('alice'); // boolean — true if it was present
set.clear(); // void
const count = set.size(); // number
const all = set.toArray(); // T[]

Ordered list that maintains insertion order.

const vec = new Vector<string>();
vec.push('first'); // void
vec.push('second');
const item = vec.get(0); // 'first' | null
const len = vec.len(); // number
const last = vec.pop(); // 'second' | null
const all = vec.toArray(); // T[]
// For initialization only:
const seeded = Vector.fromArray(['a', 'b', 'c']);

Grow-only counter (G-Counter) for distributed counting. Each node tracks its own sub-count; the total is the sum across all nodes, so concurrent increments never conflict.

const counter = new Counter();
counter.increment(); // +1
counter.incrementBy(5); // +5 (accepts number | bigint)
const total = counter.value(); // bigint
// Optional: read a single node's contribution (hex executor id)
const mine = counter.getExecutorCount();

Positive-negative counter (PN-Counter) — like Counter, but it also supports decrement. Each node keeps a per-node increment and decrement tally; the value is the signed sum, so concurrent up/down changes from different nodes never conflict or lose updates.

const votes = new PNCounter();
votes.increment(); // +1
votes.incrementBy(5); // +5
votes.decrement(); // −1
votes.decrementBy(2); // −2 (accepts number | bigint)
const net = votes.value(); // bigint — can be negative

Last-write-wins register for a single value.

const register = new LwwRegister<string>();
// or seed an initial value: new LwwRegister<string>({ initialValue: 'default' })
register.set('hello');
const value = register.get(); // 'hello' | null
const ts = register.timestamp(); // number | null — when it was set
register.clear(); // void

Same last-write-wins semantics and API as UnorderedMap, but iteration is in sorted key order instead of arbitrary order. Ordering is maintained by a node-local index, so entries()/keys()/values() are deterministic.

const scores = new SortedMap<string, number>();
scores.set('charlie', 3);
scores.set('alice', 1);
scores.set('bob', 2);
const ordered = scores.keys(); // ['alice', 'bob', 'charlie'] — always sorted
scores.get('bob'); // 2 | null
scores.has('bob'); // boolean
scores.remove('bob'); // void

Sorted counterpart to UnorderedSet — union (add-wins) membership, but toArray() returns values in sorted order.

const tags = new SortedSet<string>();
tags.add('c'); // true on first insert, false if already present
tags.add('a');
tags.add('b');
tags.toArray(); // ['a', 'b', 'c'] — always sorted
tags.has('a'); // boolean
tags.delete('a'); // boolean
tags.size(); // number

Replicated Growable Array — a sequence CRDT for collaborative text. Concurrent inserts and deletes from different nodes interleave deterministically by position, so two editors typing at once converge without losing characters.

const doc = new Rga();
doc.insert(0, 'Hello'); // insert text at an index (Unicode codepoint offset)
doc.insert(5, ' world'); // → 'Hello world'
doc.delete(0); // remove one element at an index
const text = doc.getText(); // current string
const length = doc.len(); // number of elements
// Seed a new RGA from an existing string:
const seeded = Rga.fromText('Hello world');

Attributed (authored) collections record the owner of each entry — the executor that created it — and enforce that only that owner may update or remove it. Ownership travels with the entry and is verified on every node, so one member cannot overwrite another member’s data.

Like UnorderedMap, but each key is owned by its first inserter.

const claims = new AuthoredMap<string, string>();
claims.insert('seat-1', 'alice'); // owned by the current executor
claims.update('seat-1', 'alice-2'); // ok only if you own 'seat-1' (throws otherwise)
claims.set('seat-1', 'alice-2'); // insert-or-update, owner-checked on update
claims.get('seat-1'); // V | null
claims.has('seat-1'); // boolean (contains() is an alias)
claims.ownerOf('seat-1'); // Uint8Array (owner public key) | null
claims.ownedByMe('seat-1'); // boolean
claims.remove('seat-1'); // owner-checked

Like Vector, but each slot is owned by whoever pushed it; updates and removals are owner-gated and removal is a tombstone (the slot index stays stable).

const log = new AuthoredVector<string>();
const index = log.push('entry'); // returns the slot index; owned by the caller
log.update(index, 'edited'); // owner-only
log.tombstone(index); // owner-only soft-delete
log.get(index); // T | null (null once tombstoned)
log.ownerOf(index); // Uint8Array | null
log.ownedByMe(index); // boolean
log.len(); // number of slots (including tombstones)
log.toArray(); // live values

For last-write-wins types (UnorderedMap, UnorderedSet, LwwRegister, SortedMap, SortedSet, and SharedStorage’s value) a concurrent write to the same key is resolved by timestamp — the later write wins:

Node A: map.set('key', 'A') at t=1000
Node B: map.set('key', 'B') at t=1001
Result: key = 'B' (higher timestamp)

The other types resolve differently and never lose concurrent updates:

  • Counter / PNCounter — per-node tallies are summed (additive).
  • Rga — concurrent inserts/deletes interleave by position (union of edits).
  • AuthoredMap / AuthoredVector — each entry is owner-gated, so only its owner can change it; there is no cross-writer conflict on a single entry.
  • FrozenStorage — first write wins; entries are immutable.

Collections can be stored inside other collections, and the SDK tracks changes to the inner collection automatically — you do not need to re-set the parent after mutating a child.

const reactions = new UnorderedMap<string, UnorderedMap<string, UnorderedSet<string>>>();
function addReaction(messageId: string, emoji: string, userId: string) {
let byEmoji = reactions.get(messageId);
if (!byEmoji) {
byEmoji = new UnorderedMap<string, UnorderedSet<string>>();
reactions.set(messageId, byEmoji);
}
let users = byEmoji.get(emoji);
if (!users) {
users = new UnorderedSet<string>();
byEmoji.set(emoji, users);
}
users.add(userId); // propagates automatically — no manual re-serialization
}

Supported patterns include any combination such as UnorderedMap<K, UnorderedSet<V>>, UnorderedMap<K, Vector<V>>, and Vector<UnorderedMap<K, V>>.

When a map value is itself a CRDT, the host stores a lightweight handle — a small JSON wrapper of the form {"__calimeroCollection":"Vector","id":"…hex…"} — not the full contents. Fetching that value rehydrates a handle that carries the CRDT ID; mutating the handle issues an incremental host call against that ID rather than replaying the whole structure. The full structure is only materialized in JS when you explicitly read it (toArray(), or returning the entire map from a view).

User-owned, signed storage keyed by the owner’s 32-byte Ed25519 public key. Writes are signed by the executor and verified (with replay protection via a strictly-increasing nonce) on other nodes.

import { createUserStorage } from '@calimero-network/calimero-sdk-js';
interface UserProfile { displayName: string; score: number; }
const profiles = createUserStorage<UserProfile>();
profiles.insert({ displayName: 'Alice', score: 100 }); // key = current executor's PublicKey; returns previous | null
const mine = profiles.get(); // current user's value | null
const other = profiles.getForUser(somePublicKey); // another user's value | null
profiles.setForUser(somePublicKey, { displayName: 'Bob', score: 5 }); // write for a specific user; returns previous | null
const hasMine = profiles.containsCurrentUser(); // boolean
const hasOther = profiles.containsUser(somePublicKey); // boolean
profiles.remove(); // remove current user's value; returns previous | null
const all = profiles.entries(); // Array<[PublicKey, V]>
const users = profiles.keys(); // PublicKey[]
const vals = profiles.values(); // V[]
const count = profiles.size(); // number

Use it for per-user settings, user-owned game data, or any value that must be verifiably owned by a specific user.

Immutable, content-addressable storage. Each value is keyed by the SHA-256 hash of its serialized bytes; once inserted, values cannot be updated or deleted.

import { FrozenStorage } from '@calimero-network/calimero-sdk-js/collections';
import { createFrozenStorage } from '@calimero-network/calimero-sdk-js';
const documents = createFrozenStorage<Document>();
const hash = documents.add({ title: 'Report', body: '…' }); // returns the Hash
const doc = documents.get(hash); // T | null
const exists = documents.has(hash); // boolean
const all = documents.entries(); // Array<[Hash, T]>
const hashes = documents.hashes(); // Hash[]
// Compute a hash without storing (useful for deduplication)
const wouldBe = FrozenStorage.computeHash(myValue);
// documents.remove(hash); // throws — FrozenStorage is append-only

Values are wrapped in FrozenValue<T> (new FrozenValue(value), .value), whose merge is a no-op so frozen entries never change. Use it for audit logs, document versioning, certificates, and content-addressable sharing.

A single value that any member of a rotatable writer set may overwrite. Writes from different writers converge last-write-wins; the writer set is managed at runtime and enforced by the host — a non-writer’s set/rotateWriters is rejected. Writers are 32-byte public keys (raw or hex).

import { SharedStorage } from '@calimero-network/calimero-sdk-js/collections';
import { executorId } from '@calimero-network/calimero-sdk-js/env';
// The creator is the sole initial writer.
const config = new SharedStorage<string>({ writers: [executorId()] });
// Pass `frozen: true` to create an immutable cell: new SharedStorage({ writers, frozen: true })
config.set('hello'); // ok — caller is a writer (throws for a non-writer)
config.get(); // 'hello' | null
config.writers(); // Uint8Array[] — current writer set
config.writableByMe(); // boolean
config.isFrozen(); // boolean — a frozen cell is immutable
// Grant another member write access (writer-gated):
config.rotateWriters([...config.writers(), otherKey]);

Use it for shared configuration, group-editable records, or any value a defined set of members may change while everyone else reads. It differs from AuthoredMap/UserStorage (which gate per entry by the entry’s own owner): SharedStorage gates the whole cell by a single, rotatable writer set.

  • Pick the right type: UnorderedMap/SortedMap for key/value data (sorted when you need ordered iteration), Vector for ordered lists, Counter for grow-only totals and PNCounter when it also decreases, LwwRegister for a single value, Rga for collaboratively-edited text, AuthoredMap/AuthoredVector when each entry has a single owner, UserStorage for per-user ownership, FrozenStorage for immutable records, and SharedStorage for one value a rotatable set of writers may change.
  • Mutate the handle, don’t replace it. Fetch the existing entry (const v = map.get(key) ?? new …) and mutate it. Assigning a brand-new CRDT instance replaces the stored ID and falls back to whole-value last-write-wins.
  • Mark selectors @View(). Read-only methods (get, list, len) tagged with @View() skip persistence, keeping the storage DAG compact.
  • Avoid plain fields for shared state. A plain count: number = 0 is not a CRDT and loses concurrent updates — use Counter instead.