Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

README.md

4.81 Kb · 111 lines

index

The secondary index every realm with a store was writing by hand: a lookup from a key a human typed to the store.IDs that carry it.

 1import (
 2	"gno.land/p/moul/kit/index/v0"
 3	"gno.land/p/moul/kit/store/v0"
 4)
 5
 6var notes = store.Named("note")
 7var byTag index.Index // the zero value is an empty, usable index
 8
 9func Post(cur realm, tag, body string) int64 {
10	id := notes.Add(&note{Tag: tag, Body: body})
11	if err := byTag.Add(tag, id); err != nil {
12		panic(err) // the store write is not committed either: same frame
13	}
14	return int64(id)
15}
16
17func Render(path string) string {
18	for _, e := range byTag.Page(1, 20) { // keys ascending, one page
19		_, _ = e.Key, e.IDs
20	}
21	return ""
22}

Fifth package of the p/moul/kit/* layer, after ui, store, num and tally. Live demo: r/moul/x/kitindexdemo.

What it is not

Not a multi-index record store. p/moul/collection is that, and measured at n = 1,000 an update there costs 964,833 gas against the 508,977 that created the record, because Update removes and re-adds every index entry for every index whether or not the indexed value changed. Two containers written side by side cost a third of that, and the realm can see what it is paying for.

Not a bound. An index grows with the store it indexes. The realm owns the bound, because only the realm knows what it is willing to pay a storage deposit for.

The decisions worth knowing

Backed by a B+ tree at fanout 32, not avl, and not 128. A wider node is cheaper to store and to insert into, measured against gno master 1fc4c140e on 2026-09-29, n = 1,000, string values:

backing bytes/entry gas/insert
bptree fanout 128 592 152,975
bptree fanout 32 671 162,161
avl 2,029 417,811

But an index's keys are removed whenever its records move, and a removal rewrites every later value in its leaf. Measured on a real node against gno master 3cc494ec4 on 2026-10-02: removing the first key of a fanout-128 leaf holding 120 cost 35.3M gas, the last 8.0M, about 230k per value shifted (its values are *entry, a pointer). At 32 one removal rewrites about 45 values at most (its own leaf, and a neighbour's when the leaf underflows and borrows), so it is the cheaper default for something that moves.

The ids under a key are kept ascending, not in insertion order. So Lookup depends on the set of ids and not on the order they arrived in: two realms that indexed the same records in a different order render identically. A Render whose output depends on insertion history is a determinism bug, and this is where it would have come from.

Lookup and Each hand out a copy. Returning the stored slice would make every caller a writer: an assignment into it corrupts the index with no write path having been called. A realm that passes the result onward is passing its own data, not a handle to ours.

The Index itself is a handle. Its state sits behind one pointer, so a copy taken after the first write (or of anything Unique() returned) is the same index, and Len can never disagree with Keys about what it holds.

API

New(), Unique() many ids per key, or at most one. The zero value is New()
Add(key, id) error idempotent for the same pair; ErrEmptyKey, ErrZeroID, ErrDuplicateKey
Remove(key, id) bool, RemoveKey(key) int a key whose last id goes is removed with it
Lookup(key) []store.ID, First(key) (store.ID, bool) ascending; a copy
Has, Count(key), Keys(), Len(), IsUnique() Keys counts keys, Len counts pairs, and the difference is the fan-out
Each(fn) bool ascending by key; true means stop, matching bptree and kit/store. fn must not write to the index
Page(page, size) []Entry, Pages(size) int 1-based, every out-of-range page is nil (kit/store's Page returns an empty slice instead), Pages is never 0

Add returns an error rather than aborting, because a p/ package does not know the caller's policy. In a realm the handling is almost always panic(err): every one of the three is a bug in the realm rather than a value a user chose.


Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.

Dependency graph:

gno.land/p/moul/kit/index/v0 dependency graph

⚠️ Disclaimer: provided as-is, without warranty; not security-audited. Full disclaimer: DISCLAIMER.