multiindex

    A record store with N secondary indexes (unique / non-unique / sorted) over stable generational IDs - the N-index generalization of a BiMap

    multi-index
    secondary-index
    record-store
    data-structure
    indexmap
    Download zip
    Version
    0.3.0
    License
    Apache-2.0
    Last updated
    4 days ago
    Downloads
    61

    No dependencies

    #moonbit-multiindex

    一张记录表,N 个二级索引。用稳定 ID 定位记录,按任意索引 O(1)/O(log n) 查找。

    aurasuisui/multiindex 是 MoonBit 的多级索引记录表容器:Table[K, V] 存储记录 V, 并在其上维护任意多条命名索引。每条索引把记录投影成键 K,可以是:

    • 唯一索引 —— 一个键对应至多一条记录(冲突在变更前上报,不静默覆盖);
    • 非唯一索引 —— 一个键对应多条记录;
    • 有序索引 —— 按键有序,支持范围查询。

    记录用 RowId(槽位 + 代际号) 定位:槽位会被复用,但代际号保证陈旧 ID 永远不会 静默命中"另一个"记录(ABA 防护)。

    RowId 只在创建它的那张 Table 内有效:跨表使用未定义 —— 同一个 (0,0) 在另一张同类表上可能命中其首条记录,而不是返回 None;要跨表引用请自己带上表标识。

    #它解决什么问题

    BiMap 是「两个互为逆的唯一索引」的特例。本项目把它泛化成 N 个彼此独立的索引。

    结构索引数键唯一性记录身份
    indexmap1(主键)唯一键本身
    bimap2(互为逆)两个方向都唯一左键
    multiindexN(任意组合)每索引独立稳定 RowId

    典型场景:内存表按 email 唯一查、按城市非唯一查、按年龄范围查;ORM 结果集缓存; ECS 式实体索引;配置项多路检索。

    #安装

    工具链:本文实测于 moon 0.1.20261104 (940aa18 2026-11-04),moon 0.1.20261104 或更新均可 (moon version 自查)。新建模块并加入依赖:

    moon new hello-multiindex cd hello-multiindex moon add aurasuisui/multiindex@0.3.0

    moon add 只写 moon.mod。还要在 moon.pkg 里声明包入口,否则第一次写 @multiindex. 就会报 [4020] Package "multiindex" not found in the loaded packages.:

    import { "aurasuisui/multiindex" }

    pkgtype(kind: "executable") // 本页的程序放在模块根包;`moon run .` 需要一个可执行包

    #快速开始

    完整可运行版本见 cmd/leaderboard(独立模块,import 已发布的包); 签名以 docs/SPEC.md 为准(唯一真源)。

    把下面整段粘进模块根目录的 main.mbt,再 moon run .:

    struct Player {
    id : Int32
    name : String
    team : Int32
    score : Int32
    }

    fn main {
    let t : @multiindex.Table[Int32, Player] = @multiindex.Table::new()
    t.register_unique("id", fn(p) { p.id }).unwrap()
    t.register_non_unique("team", fn(p) { p.team }).unwrap()
    t.register_non_unique_ordered("score", fn(p) { p.score }).unwrap()

    let roster : Array[Player] = [
    { id: 7, name: "Alice", team: 1, score: 120 },
    { id: 9, name: "Bob", team: 1, score: 95 },
    { id: 11, name: "Carol", team: 2, score: 150 },
    ]
    match t.insert_many(roster) {
    Ok(ids) => println("inserted \{ids.length()} records, len = \{t.len()}")
    Err(rejected) => println("refused \{rejected.record.name}: \{rejected.conflict}")
    }

    let (_, alice) = t.get_by("id", 7).unwrap()
    println("id=7 -> \{alice.name}")

    for (_, p) in t.iter_by("team", 1) {
    println("team=1 -> \{p.name}")
    }
    for (_, p) in t.range_by("score", 100, 130) {
    println("score in [100, 130] -> \{p.name}")
    }
    for (_, p) in t.iter_desc_by("score") {
    println("\{p.name}: \{p.score}")
    }
    }

    输出:

    inserted 3 records, len = 3 id=7 -> Alice team=1 -> Alice team=1 -> Bob score in [100, 130] -> Alice Carol: 150 Alice: 120 Bob: 95

    四象限索引:唯一性(unique / non-unique)× 有序性(hashed / ordered),四个注册方法,不做含糊的三合一。

    #运行示例

    仓库自带的完整示例是「一张表、三种看法」:按 id 唯一查、按 team 非唯一遍历、按 score 有序排行。它是独立模块(自带 moon.mod),依赖已发布的包 —— 仓库根有 moon.work 时 改用本地工作区模块,程序首行会注明:

    cd cmd/leaderboard moon run .

    #API(与 docs/SPEC.md 同源)

    构造与注册(索引的键类型都是同一个 K)

    方法说明
    Table::new() / Table::with_capacity(cap)空表;后者预留槽位
    register_unique(name, project)哈希唯一(Map[K, Int])
    register_unique_ordered(name, project) (需 K : Compare)有序唯一(SortedMap[K, Int])
    register_non_unique(name, project)哈希非唯一(Map[K, SortedSet[Int]])
    register_non_unique_ordered(name, project) (需 K : Compare)有序非唯一
    unregister_index(name)删除索引注册:返回 Bool,未注册 false 不 abort;只摘注册表,记录 / 其它索引 / 插入序不变
    has_index(name) / index_names() / index_entries()注册表查询(名字升序);index_entries() 额外给出每个索引的种类(IndexKind:HashUnique / SortedUnique / HashMulti / SortedMulti)

    所有 register_* 返回 Result[Unit, RegisterError[K]]:重名 Err(DuplicateName)(不覆盖); 对非空表注册会回填扫描已有记录,回填唯一索引遇冲突则 Err(UniqueViolation) 且完全不注册。

    写入(冲突一律在任何变更之前检查;失败时表零变更,记录原样归还)——对值类型 / 未被 modify 闭包就地修改的记录成立;共享容器的 Err 边界见 docs/SPEC.md §3.7。

    方法说明
    insert(v)Result[RowId, Rejected[K, V]]
    insert_many(vs)Result[Array[RowId], Rejected[K, V]];整批原子,含批内互相冲突
    insert_or_replace(v)Result[(RowId, V?), Rejected[K, V]];覆盖式 upsert:恰好一条记录在新值的某个唯一索引上撞键 → 原地替换、保留它的 RowId,返回被顶替的旧值;无冲突则等价于 insert;两条不同记录分别撞键 → abort(表零变更)
    update(id, v) / modify(id, f)Result[Unit, UpdateRejected[K, V]];排除自身的唯一性预检
    remove(id) / swap_remove(id) / clear()删除单条(旧句柄失效):remove 保插入序、O(len);swap_remove 末位换位、O(1) 但打乱插入序 / 清空记录与索引条目(保留注册)

    什么时候用哪个:要"按某个新值找到那条记录并原地换掉、但句柄不能失效" → insert_or_replace (它复用 update 的排除自身预检,所以单条命中时不报冲突);remove + insert 会换掉 RowId, 只在明确想要新身份时才用。要删索引 → unregister_index(name);之后同名重注册会从现有记录回填(可换投影)。 下一个 iter() 必须是插入序 → remove;只关心"删掉、还要快"(队列式消费、批量清理) → swap_remove。两者对索引、len、version、槽位复用的作用完全相同,被移动的只是插入序里的槽位, 所有存活 RowId 继续有效。

    查询

    方法说明
    get(id) / contains(id)按句柄取记录;陈旧句柄 None / false
    len() / is_empty()记录数
    iter()插入序,Iter2[RowId, V]
    get_by(name, key)(RowId, V)?;非唯一索引返回槽位号最小者
    iter_by(name, key)该键的全部记录,槽位号升序
    range_by(name, lo, hi) / range_half_open_by(name, lo, hi)双闭 / 半开区间,仅有序索引
    iter_asc_by(name) / iter_desc_by(name)按键升 / 降序,仅有序索引

    诊断与序列化

    方法说明
    verify_invariants()Result[Unit, String]:首个违反点,消息含不变量编号与槽位/键
    index_stats(name)IndexStats { buckets, entries, longest_bucket, average_bucket }
    to_json() / Table::from_json(json){"records": [...]},按插入序;索引不参与序列化

    #限制(已知边界)

    • RowId 不可持久化:跨进程 / 跨版本不保证稳定;to_json / from_json 只保记录与插入序,不序列化索引, 载入后要重新 register_*、重新取句柄。跨表引用要自己带上表标识(见上文)。
    • from_json 没有行数上限:读多大就解析多大;超大文件请自行分批,避免一次性内存峰值。
    • 约束擦除(异构枚举)不做:K 必须满足方法所需的 Hash + Eq + Compare,不能在注册时擦成闭包 —— 否则擦除后无法继续逐步校验 I3 / I4 / I5 / I7(理由见 docs/SPEC.md §11)。
    • 可变记录契约(读写两侧):值类型随便改;V 内含 Array / Ref 时,get / get_by / iter* / range_* 返回的记录与表内 共享容器,这些容器视为只读。insert / update / modify 存进去的 V 也与调用方共享容器:插入后再改自己手里的对象, 同样会写脏索引。改被索引字段必须走 update / modify;否则旧键命中 / 新键漏查,verify_invariants 会报 I4 / I7。 恢复三步:unregister_index → 用 update / remove 拆开重复投影 → 重新 register_*(见 docs/SPEC.md §3.7)。
    • 脏表的残余边界:上述修复只针对被操作的那条记录;其他脏条目仍由 verify_invariants 指出。脏表上 insert 的唯一性预检也可能被绕过(干净表没有这个问题)——verify 一报错就按恢复路径重建索引。

    #与生态中已有项目的关系

    • 不重复 Milky2018/slotmap:slotmap 提供稳定键与槽复用(无索引投影、无唯一性、无按键查找); 本库的记录身份机制与它同源,核心价值在多索引。
    • 不重复 kesmeey/IndexMap / wqbcs/moon_collections / Hhsqoo/moon-collections: 它们是有序哈希表(单索引、保留插入序);本库是多路查找结构。
    • 不依赖 aurasuisui/bimap 或 aurasuisui/indexmap:同作者作品,思路延续,代码独立。

    分支:main 是里程碑视图(每个阶段一个提交),dev 保留完整的逐节点开发历史。

    #文档

    #Gotchas(实测得来,不是设想)

    • range_by 是双闭区间 [lo, hi](跟随 core 的 SortedMap::range);要半开请用 range_half_open_by。lo > hi 是合法的空区间,返回空迭代器而不是报错。
    • index_names() 的"升序"是 MoonBit 的 String 序(shortlex:先比长度): ["aa", "b"] 会排成 ["b", "aa"]。多索引同时冲突时,报出的也是名称升序的第一个 (与注册顺序无关)。
    • 两类"失败"必须分开看(SPEC §7.1):可预期的运行时条件(唯一冲突 / 陈旧句柄 / 键查不到) 返回 Err / None / 空迭代器,绝不 panic;而未知索引名、在哈希索引上做有序查询 是调用方 bug,一律 abort —— "空结果"只应表示"查得到,但没有"。
    • 查询按值返回记录:值类型是独立副本,V 内含 Array / Ref 时与表内共享容器(见「限制」与 docs/SPEC.md §3.7),要改被索引字段请走 update / modify。所以索引赢在选择性,不在"扫得多": iter_by 在大桶上不比线性扫描快(两边都要走完所有匹配项,索引还多付拷贝),range_by 在 宽区间上同理。真正的优势是点查与选择性遍历(实测表与交叉点见 bench/README.md)。
    • remove 是 O(len + I + Σ log 桶):order 数组按插入序原地压缩,所以删除耗时随表长线性 增长 —— 实测比 insert 贵 k=1 约 56×、k=8 约 6×(k 越大 insert 自身越贵,比值随之收窄)。 要 O(1) 删除就得放弃插入序稳定性,那是另一个取舍(见 SPEC §6);原始数字见 bench/README.md。
    • JSON 不携带索引:from_json 返回的表注册表为空,载入后需自己 register_* 回填 (§3.1 的"对非空表注册即回填"会顺势把索引建好,不需要第二个机制)。
    • 冲突不会让调用方丢数据:insert / insert_many / update / modify 失败时,记录随 Err 归还;但 modify 的闭包若已就地修改共享容器,返回的已是改后记录,无法回滚(见 docs/SPEC.md §3.7)。
    • clear() 保留索引注册:清空的是记录与索引条目,不是"架构";清空后插入仍会被索引。

    #致谢与来源

    主要参考:Rust multi_index_map v0.15.1(MIT 许可) ——同样"记录 + N 索引"的容器,后备存储用 slab::Slab + 每索引一张查找表(HashMap/BTreeMap)。 本库的索引分类(唯一 / 非唯一 / 有序)与"冲突不改变容器"的语义与之一致。 完整的许可证核查与 API 对照见 docs/SPEC.md §0。

    本库的原创新增(这些在主蓝本中不存在):

    1. 代际 RowId(ABA 防护) —— 主蓝本的 Slab 槽位复用不带代际号,陈旧下标会静默命中复用该槽的新元素。
    2. 范围查询 —— 主蓝本的 ordered 索引只暴露点查与全量遍历,没有 range;本库提供 range_by(双闭)/ range_half_open_by(半开)/ iter_asc_by / iter_desc_by,且范围查询保持惰性(一次只物化一个桶)。
    3. 绝不 panic、且冲突时归还记录 —— 主蓝本 insert 冲突直接 panic,modify_by_* 冲突时更是先删旧键再 panic,留下脏索引;本库只有 Result,且 Rejected[K,V] 把记录原样还给调用方。
    4. 代际号溢出即退役槽位 —— 所有 Rust 参考实现都选择静默回绕(bevy 文档明写 "generations can (and do) wrap or alias");本库让陈旧句柄永不复活。
    5. 运行时注册索引(闭包投影)—— 主蓝本依赖 derive 宏;MoonBit 无等价机制,故采用运行时注册,代价是索引名运行时校验,收益是可动态增删/枚举索引。
    6. 插入序语义 —— 主蓝本按槽位序遍历;本库额外维护 order。
    7. 公开 verify_invariants() —— 主蓝本没有一致性自检 API;本库返回 Result[Unit, String], 首个违反点带上"哪条不变量 + 哪个槽位/键",而不是只说 false。
    8. 整批原子 insert_many —— 主蓝本 FromIterator 是 first-wins(静默的胜出规则);本库 预检批内互相冲突,任一条冲突则整批零变更,并用 DuplicateInBatch 指明是批次里哪个成员。
    9. 诊断与序列化 —— index_stats(桶数/最长桶/平均桶长)与 JSON 往返({"records": [...]}) 在主蓝本中都没有。

    其他参考:slotmap(Zlib)/ thunderdome(MIT OR Apache-2.0)/ hecs(MIT OR Apache-2.0) 提供"槽位 + 代际号"稳定键方案的先例;generational-arena(MPL-2.0)仅借鉴思路、不复制代码。

    文档与测试体系:沿用同作者的 moonbit-bimap (文档枢纽/SSOT 纪律、SPEC 唯一真源、分层测试、差分夹具生成器)。

    哈希引擎:如需自研哈希表,将适配自 aurasuisui/indexmap(Apache-2.0),不引入依赖。

    #License

    Apache-2.0

    Conflict

    pub enum Conflict[K] {
    UniqueViolation(String, K, RowId)
    DuplicateInBatch(String, K, Int)
    } derive(Eq,
    Debug
    )

    A uniqueness violation reported before any mutation happens.

    Deliberately has no "no conflict" variant — the absence of a conflict is spelled None at the call site. (A redundant nullary variant would force every caller to match it and collides with Option::None in pattern position; see docs/SPEC.md §Design lessons.)

    • UniqueViolation(index, key, holder) — the unique index named index already maps key to the live record holder.
    • DuplicateInBatch(index, key, at) — one insert_many batch contains two records that project to the same key on the unique index index; at is the position of the earlier member in that batch. No record is live at key: the whole batch is rejected atomically (docs/SPEC.md §11), so a handle would have been a lie — the batch position is the honest answer.
    impl Show for Conflict[K]

    Conflict::equal

    fn[K : Eq] Conflict::equal(Conflict[K], Conflict[K]) -> Bool

    IndexKind

    pub(all) enum IndexKind {
    HashUnique
    SortedUnique
    HashMulti
    SortedMulti
    } derive(
    Debug
    )

    Which of the four quadrants a registered index lives in — the public shadow of the private Index[K, V] enum (src/index.mbt).

    Table::index_entries hands these out next to the index names so a caller can tell a conflict-checked index (HashUnique / SortedUnique) from a fan-out one (HashMulti / SortedMulti) before reasoning about what a write may hit.

    Deliberately not folded into IndexStats: that struct's fields are public, so adding a name or a kind to it would break every external construction site (a MAJOR break — Issue #4). A separate enum keeps IndexStats at MINOR.
    impl Show for IndexKind

    IndexStats

    pub struct IndexStats {
    buckets : Int
    entries : Int
    longest_bucket : Int
    average_bucket : Double
    } derive(Eq,
    Debug
    )

    How the buckets of one index are distributed (docs/SPEC.md §3.4).

    A degenerating projection shows up here: longest_bucket climbing while average_bucket stays near 1 means many records pile into one bucket. An index with no entries reports zero for everything (including the average).

    IndexStats::equal

    fn IndexStats::equal(IndexStats, IndexStats) -> Bool

    RegisterError

    pub enum RegisterError[K] {
    DuplicateName(String)
    UniqueViolation(String, K)
    } derive(Eq,
    Debug
    )

    Why an index registration was refused (docs/SPEC.md §2.4).

    Registration never half-succeeds: on Err the registry and the records are exactly what they were.

    • DuplicateName(name) — an index of that name is already registered; the existing one is not overwritten.
    • UniqueViolation(name, key) — backfilling the unique index name found two live records projecting to the same key. The key travels as itself, so registering a unique index adds no Show bound on K (it mirrors Conflict[K]).

    RegisterError::equal

    fn[K : Eq] RegisterError::equal(RegisterError[K], RegisterError[K]) -> Bool

    Rejected

    pub struct Rejected[K, V] {
    conflict : Conflict[K]
    record : V
    } derive(
    Debug
    )

    A write that was refused because a unique index already holds the key — carrying the record back to the caller.

    MoonBit passes records by value, so an insert(v) that reported only Conflict[K] would consume v for good. Returning the record itself inside the error keeps the caller's data intact; the idiomatic retry is:

    match table.insert(record) {
    Ok(id) => id
    Err(rejected) => retry_with(rejected.record)
    }
    impl Show for Rejected[K, V]

    RowId

    pub struct RowId {
    slot : Int
    gen : Int
    } derive(Eq, Hash,
    Debug
    )

    A stable handle to a stored record: a slot index plus a generation counter.

    The generation is bumped every time a slot is recycled, so a handle that outlives its record can never silently resolve to a different record (the ABA problem). Operations on a stale handle return None / false rather than touching the new occupant of the slot.
    impl Show for RowId

    RowId::equal

    fn RowId::equal(RowId, RowId) -> Bool

    Table

    pub struct Table[K, V] {
    // private fields
    }

    A record store with N secondary indexes over stable generational handles.

    Records live in slots; RowId pairs a slot with the slot's generation, so a handle that outlives its record is rejected instead of silently naming the slot's next occupant (the ABA problem). Insertion order is preserved, and any number of named secondary indexes (unique / non-unique, hashed / sorted) can be registered over the records and are kept consistent by the chokepoints.

    Table::clear

    fn[K, V] Table::clear(self : Table[K, V]) -> Unit

    Drop every record and every index entry. Index registrations stay, so the next insert is indexed again.

    Storage is kept (the slot arrays are recycled, not freed) and every handle issued before the call becomes stale, so a later insert can never resolve an old handle. A clear of an already-empty table is not a change and leaves version alone (I8).

    Table::contains

    fn[K, V] Table::contains(self : Table[K, V], id : RowId) -> Bool

    Whether id names a live record (a stale handle answers false).

    Table::from_json

    Rebuild a table from {"records": [...]}, records in the order they appear.

    Malformed input raises @json.JsonDecodeError carrying the path to the offending field (missing records, wrong type, bad element …) — nothing is silently dropped.

    The returned table has no registered index; register them again and the backfill scan reindexes every record. The bound is the one insert carries: restoring records goes through the same chokepoint as any other insert.

    Table::get

    fn[K, V] Table::get(self : Table[K, V], id : RowId) -> V?

    The record named by id, or None if id is stale.

    A handle is stale when its generation no longer matches the slot's — the slot was recycled (or the table cleared), so id names a record that is gone. A stale handle never resolves to the slot's current occupant (ABA guard).

    Table::get_by

    fn[K : Hash + Eq + Compare, V] Table::get_by(self : Table[K, V], name : String, key : K) -> (RowId, V)?

    The record the index name maps key to: its stable handle plus the value.

    On a non-unique index this is the record in the smallest slot of the bucket — deterministic, but not stable across operations (a removal elsewhere changes which slot is smallest). Use iter_by for every match.

    A key that is simply absent yields None; an unknown index name aborts — looking up an index that was never registered is a caller bug, and answering it with "no data" would hide that bug (docs/SPEC.md §7.1).

    Table::has_index

    fn[K, V] Table::has_index(self : Table[K, V], name : String) -> Bool

    Whether an index is registered under name.

    Table::index_entries

    fn[K, V] Table::index_entries(self : Table[K, V]) -> Array[(String, IndexKind)]

    Every registered index as (name, kind), ascending by name — the same order, and the same shortlex rule, as index_names (docs/SPEC.md §3.3.1).

    This is the enumeration index_stats cannot provide on its own: stats take a name, so before this method a caller had no public way to learn which names exist and what kind each one is. IndexStats itself keeps its four fields — growing it would be a MAJOR break for every external construction site (Issue #4).

    Table::index_names

    fn[K, V] Table::index_names(self : Table[K, V]) -> Array[String]

    The registered index names, ascending.

    "Ascending" is MoonBit's own String order, which is shortlex: shorter names first, then code-unit order (builtin/string.mbt). So ["aa", "b"] enumerates as ["b", "aa"]. Following the platform (as docs/SPEC.md §3.3.1 does for range) keeps this consistent with any caller-side sort.

    Table::index_stats

    fn[K, V] Table::index_stats(self : Table[K, V], name : String) -> IndexStats

    Bucket-shape statistics of the index registered under name (docs/SPEC.md §3.4).

    Works for every quadrant: "how concentrated is this index" is a question about buckets, not about key order. An unknown name aborts (docs/SPEC.md §7.1).

    Table::insert

    fn[K : Hash + Eq + Compare, V] Table::insert(self : Table[K, V], value : V) -> Result[RowId, Rejected[K, V]]

    Store value and return its stable handle.

    Every unique index is probed before anything is written (docs/SPEC.md §5 rule 2). On a collision the table is exactly what it was, and the record comes back inside Err(Rejected) — MoonBit passes records by value, so reporting the conflict alone would destroy the caller's data.

    Table::insert_many

    fn[K : Hash + Eq + Compare, V] Table::insert_many(self : Table[K, V], values : Array[V]) -> Result[Array[RowId], Rejected[K, V]]

    Store every record of values, or none of them.

    The whole batch is checked before anything is written (docs/SPEC.md §11): each record is probed against the live table and against the earlier members of the same batch, so a duplicate inside the batch fails exactly like one that clashes with an existing record. On Err the table is what it was, and the offending record comes back inside Rejected (the caller keeps its array too — MoonBit arrays are references).

    The reference implementation's FromIterator is first-wins; this library deliberately does not copy that, because a silent winning rule contradicts "a conflict is never silently overwritten" (the deviation is registered in tools/diffgen/README.md).

    holder in the conflict: against a live record it is that record's handle; for a clash inside the batch it is the handle the earlier member would receive once the batch is accepted, since no record is live there yet (see CHANGELOG Notes).

    Table::insert_or_replace

    fn[K : Hash + Eq + Compare, V] Table::insert_or_replace(self : Table[K, V], value : V) -> Result[(RowId, V?), Rejected[K, V]]

    Insert value, or replace the record it collides with — keeping that record's RowId (docs/SPEC.md §3.2.2).

    Keeping the RowId is the point of the method: remove + insert would retire the old handle and hand out a fresh one, silently stranding every handle the caller already holds. The logical record survives the write, so its identity must survive with it.

    • No unique key is taken: exactly insert, Ok((new_row, None)).
    • Exactly one record holds one or more of the new keys: it is replaced in place, Ok((its_row, Some(displaced_value))). Non-unique indexes are rebuilt from the new record — a multi bucket never decides who is replaced.
    • Two different records are hit, one per unique index: no single replacement can honour both, and arbitrarily dropping one would corrupt the caller's data model. The call aborts, naming both indexes and both RowIds. No new Conflict variant carries this: adding one would break exhaustive matches at every existing Err site (docs/SPEC.md §3.2.2).

    Atomicity: replace_target decides the victim with a read-only scan, then update's pre-check runs with the victim excluded, and only then does modify_impl — update's own commit chokepoint — touch the table. The replace path shares one pre-check and one commit path with update, and every refusal path leaves the table byte-for-byte unchanged.

    Table::is_empty

    fn[K, V] Table::is_empty(self : Table[K, V]) -> Bool

    Whether the table holds no live records.

    Table::iter

    fn[K, V] Table::iter(self : Table[K, V]) -> Iter2[RowId, V]

    Iterate the live records in insertion order, each as (handle, value).

    Mutating the table while an iteration is in flight aborts it (fail-fast, keyed on the mutation counter version) instead of yielding a torn view.

    Table::iter_asc_by

    fn[K, V] Table::iter_asc_by(self : Table[K, V], name : String) -> Iter2[RowId, V]

    Every record in the index, ordered by key ascending.

    Deliberately unconstrained: the walk never compares keys, and the runtime abort ("this index has no key order") tells a caller far more than a compile error demanding Compare for what is merely a hash index (docs/SPEC.md §3.3).

    Table::iter_by

    fn[K : Hash + Eq + Compare, V] Table::iter_by(self : Table[K, V], name : String, key : K) -> Iter2[RowId, V]

    Every record the index name maps key to, as (handle, value) pairs in ascending slot order. A unique index yields 0 or 1 pair, so both quadrants share one shape.

    The bucket is snapshotted when the iterator is created (so the yielded set is stable), while mutation of the table mid-iteration aborts, exactly like Table::iter (fail-fast on version).

    Table::iter_desc_by

    fn[K, V] Table::iter_desc_by(self : Table[K, V], name : String) -> Iter2[RowId, V]

    Every record in the index, ordered by key descending (within one key the slots still come out ascending — only the key order flips). Unconstrained for the same reason as iter_asc_by.

    Table::len

    fn[K, V] Table::len(self : Table[K, V]) -> Int

    Number of live records.

    Table::modify

    fn[K : Hash + Eq + Compare, V] Table::modify(self : Table[K, V], id : RowId, f : (V) -> V) -> Result[Unit, UpdateRejected[K, V]]

    Replace the record named by id with f(record).

    f runs exactly once, before any mutation, and only the index buckets whose projected key changes are touched (docs/SPEC.md §5 rule 4 — the ordering the reference implementation gets wrong). A stale id yields Err(StaleHandle(id, None)): with no record to transform there is no new record to hand back.

    Table::new

    fn[K, V] Table::new() -> Table[K, V]

    Create an empty table with no registered index.

    Table::range_by

    fn[K : Compare + Eq, V] Table::range_by(self : Table[K, V], name : String, lo : K, hi : K) -> Iter2[RowId, V]

    Every record whose index key lies in the closed interval [lo, hi], ascending by key (and, inside one bucket, ascending by slot).

    Forwarded to core's SortedMap::range, which is closed on both ends (docs/SPEC.md §3.3.1 follows the platform here). lo > hi is an empty interval, not an error. Aborts for an unknown name or a hash index (§7.1).

    Table::range_half_open_by

    fn[K : Compare + Eq, V] Table::range_half_open_by(self : Table[K, V], name : String, lo : K, hi : K) -> Iter2[RowId, V]

    Every record whose index key lies in the half-open interval [lo, hi).

    The closed range minus its hi entries (which sit at the end of the walk, since keys come out ascending), so lo == hi is empty by construction.

    Table::register_non_unique

    fn[K : Hash + Eq, V] Table::register_non_unique(self : Table[K, V], name : String, project : (V) -> K) -> Result[Unit, RegisterError[K]]

    Register a hashed non-unique index under name (Map[K, SortedSet[Int]]).

    Non-unique quadrants have no uniqueness constraint, so the only failure is a duplicate name. Existing records are backfilled.

    Table::register_non_unique_ordered

    fn[K : Compare + Eq, V] Table::register_non_unique_ordered(self : Table[K, V], name : String, project : (V) -> K) -> Result[Unit, RegisterError[K]]

    Register a sorted non-unique index under name (SortedMap[K, SortedSet[Int]]).

    Same contract as register_non_unique, in the sorted quadrant.

    Table::register_unique

    fn[K : Hash + Eq, V] Table::register_unique(self : Table[K, V], name : String, project : (V) -> K) -> Result[Unit, RegisterError[K]]

    Register a hashed unique index under name (Map[K, Int]).

    Registering against a non-empty table backfills it in insertion order. Fails with DuplicateName when the name is taken, or with UniqueViolation when the existing records already share a key — in both cases nothing is registered.

    Table::register_unique_ordered

    fn[K : Compare + Eq, V] Table::register_unique_ordered(self : Table[K, V], name : String, project : (V) -> K) -> Result[Unit, RegisterError[K]]

    Register a sorted unique index under name (SortedMap[K, Int]).

    Same contract as register_unique; the sorted quadrant additionally supports the range queries of a later milestone.

    Table::remove

    fn[K : Hash + Eq + Compare, V] Table::remove(self : Table[K, V], id : RowId) -> V?

    Remove and return the record named by id, or None if id is stale.

    A rejected handle leaves the table — and the slot's current occupant — completely untouched.

    Table::swap_remove

    fn[K : Hash + Eq + Compare, V] Table::swap_remove(self : Table[K, V], id : RowId) -> V?

    Remove and return the record named by id in O(1), or None if id is stale.

    This is remove with one difference: the last slot in order is moved into the hole instead of compacting the tail. Records are not moved — every other live RowId stays valid — but iteration order no longer matches insertion order after a swap_remove. Use remove when you need insertion order, swap_remove when you only need speed (see docs/SPEC.md §6).

    A rejected handle leaves the table — and the slot's current occupant — completely untouched, exactly like remove.

    Table::to_json

    Serialize the records in insertion order (docs/SPEC.md §8).

    Nothing about the registry is written: two tables with different indexes but the same records serialize to the same JSON.

    Table::unregister_index

    fn[K, V] Table::unregister_index(self : Table[K, V], name : String) -> Bool

    Remove the index registered under name, reporting whether one was removed.

    This is the explicit exception to docs/SPEC.md §7.1 ("an unknown index name aborts"): an unknown name answers false instead of panicking, so a caller can probe the registry without turning a lookup into a panic. Every other query entry point that takes an index name (get_by / iter_by / range_* / index_stats) keeps aborting on an unknown name.

    Only the registry entry is dropped. Records, insertion order, generations, the free-slot stack and every other index are untouched, so len and iter() are unchanged. version is not bumped either: I8 counts record changes, and removing an index changes no record. An iterator obtained from iter_by before the call therefore keeps walking the slot set it snapshotted at creation (see Table::iter_by).

    The name is free immediately and may be registered again, possibly with a different projection. An old name never means "the same index": after a re-registration, lookups through it answer from the new projection and the old keys no longer match.

    Table::update

    fn[K : Hash + Eq + Compare, V] Table::update(self : Table[K, V], id : RowId, value : V) -> Result[Unit, UpdateRejected[K, V]]

    Replace the record named by id with value.

    The excluding-self check runs first (docs/SPEC.md §5 rule 1): writing a record back under a key it already holds is not a conflict. Every unique index is probed before anything is written, so on Err the table is exactly what it was and value travels back inside UpdateRejected. A stale id yields Err(StaleHandle(id, Some(value))) — the caller's record is never swallowed.

    Table::verify_invariants

    fn[K : Eq +
    Debug
    , V] Table::verify_invariants(self : Table[K, V]) -> Result[Unit, String]

    Check every invariant on the inside of the table and report the first violation, with enough context to locate it (docs/SPEC.md §3.4).

    The message names the invariant (I1–I9) and the slot or key involved. A checker that could only answer "false" would be of little use to its caller, which is why this returns Result[Unit, String] rather than Bool. K : Eq + Debug is what comparing keys and rendering them in messages costs — nothing more.

    Table::with_capacity

    fn[K, V] Table::with_capacity(cap : Int) -> Table[K, V]

    Create an empty table whose slot storage is already sized for cap records.

    The reserved slots are all vacant, so they all go on free — pushed high-to-low, so the LIFO stack hands out slot 0 first and the first cap inserts never have to grow the slot arrays. cap <= 0 reserves nothing.

    UpdateRejected

    pub enum UpdateRejected[K, V] {
    StaleHandle(RowId, V?)
    Conflict(Conflict[K], V)
    } derive(Eq,
    Debug
    )

    Why an update was refused (docs/SPEC.md §2.5).

    • StaleHandle(id, pending) — id no longer names a live record. update gives the record it was handed back (Some(value)); modify has none to give (None: with no record to transform, no new record exists).
    • Conflict(conflict, value) — a unique index already holds one of the record's keys; the record always comes back.

    Same principle as Rejected: a refused write never swallows the caller's data.

    UpdateRejected::equal

    fn[K : Eq, V : Eq] UpdateRejected::equal(UpdateRejected[K, V], UpdateRejected[K, V]) -> Bool

    VERSION

    let VERSION : String

    Library version string.