Skip to content

Tables & Collections

An app’s state is the fields of its #[app::state] struct. Each field is a table or a collection, a handle over the app’s ordered key-value store (crates/sdk/src/table.rs, crates/sdk/src/collections). Loading the state reads nothing; every operation reads or writes only the keys it needs.

One leader applies every write in log order, so these are plain data structures, not CRDTs: there is nothing to merge. The exception is Text, which stays a sequence CRDT so that edits typed against a stale view still land where they were meant (see Collaborative text).

A Table<R> holds records: rows with a primary key, unique constraints, indexes, maintained counts and, optionally, full-text search.

#[derive(Record)]
#[table(
key = "id",
unique(slot = ["room_id", "start"]),
index(by_start = ["checked_in", "start"]),
count(by_room = ["room_id"]),
)]
pub struct Booking {
pub id: String,
pub room_id: String,
pub start: u64,
pub title: String,
pub checked_in: bool,
}
Method Does
insert(record) Inserts; an existing key is Duplicate { index: "key" }, a unique clash Duplicate { index: <name> }.
upsert(record) Inserts or replaces; unique constraints still apply.
get(&key) / contains(&key) The record with this key / whether it exists.
update(&key, |r| ..) Changes the record and re-indexes what changed; false when absent.
remove(&key) Removes the record and its index, unique and count entries; false when absent.
find(index, &prefix, opts) Records whose index fields start with prefix, in index order, one page at a time.
scan(opts) All records in key order, one page at a time.
count(name, &prefix) A maintained count: one read when prefix names every field of the count.
search(query, ..) Full-text search over #[table(search = [..])] fields, best match first (views only).

Index and count names are camelCase (by_start → "byStart"). Prefixes and bounds are KeyParts: Str, Int, Bool, Bytes.

let due = self.bookings.find(
"byStart",
&[KeyPart::Bool(false)],
FindOpts::default().to(vec![KeyPart::Int(i128::from(tick.scheduled))]),
)?;
for b in due.items {
self.bookings.remove(&b.id)?;
}

Every multi-row read returns a Page { items, next }. FindOpts sets limit (100 when unset, at most 1,000), cursor (the previous page’s next), reverse, and from (inclusive) / to (exclusive) bounds after the prefix. A page reads at most limit + 1 entries.

Type Operations
Cell<T> get, set, clear
UnorderedMap<K, V> get, contains, insert / remove (return the old value), len, is_empty, clear; with K: FromKey: page, range, prefix, starts_with, first, last
Set<T> insert, remove (bool), contains, len, is_empty, clear, page, range, prefix, starts_with, first, last
Vector<T> push (returns the position), get, set (OutOfBounds past the end), pop, len, is_empty, page, clear
Counter get, add, increment
Frozen<T, H = CborSha256> Content-addressed and write-once: insert (returns the hex hash and whether it was new), get(hex), contains(hex), hash_of
PerUser<T> get, set, remove on the caller’s own value; get_for(account) reads another’s
Text insert_after(id, str), delete_ids, insert(index, str), delete(index, n), read, chars, id_at, index_of, len, clear
RichText text() / text_mut() (a Text), mark(start, end, key, value, expand), mark_ids, marks, spans, clear

Despite its name, UnorderedMap reads in key order (page, range, first, last), so it also serves as a sorted map; tuple keys make nested maps.

#[app::state(version = 1)]
pub struct Store {
items: UnorderedMap<String, String>,
tags: Set<String>,
visits: Counter,
profile: PerUser<String>,
}
  • Lengths and counts are stored and kept with an atomic add on every write, so len and count are one read, never a scan.
  • clear deletes in batches of at most 1,000 until nothing is left.
  • A key over 1 KiB is refused (KeyTooLong) before any store call.
  • Values are deterministic CBOR; keys are order-preserving tuples, the contract the TypeScript SDK produces the same bytes for.
  • Text reads and edits load every node (1,000 per host read); its len is one read.