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

v0 source pure

Package index is the secondary index every realm with a store was writing by hand: a lookup from a key a human typed ...

Readme View source

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.

Overview

Package index is the secondary index every realm with a store was writing by hand: a lookup from a key a human typed to the [store.ID]s that carry it.

A realm keeps its records in a gno.land/p/moul/kit/store and answers "entry 7". The moment it also has to answer "every entry tagged gno" it needs a second container, and the correctness of the pair is entirely in the discipline of writing both in the same function. This package owns the second container and as much of that discipline as a package can.

Example
 1var notes = store.Named("note")
 2var byTag index.Index // the zero value is an empty, usable index
 3
 4func Post(cur realm, tag, body string) int64 {
 5	id := notes.Add(&note{Tag: tag, Body: body})
 6	if err := byTag.Add(tag, id); err != nil {
 7		panic(err) // the store write is not committed either: same frame
 8	}
 9	return int64(id)
10}

It is NOT a multi-index record store. gno.land/p/moul/collection is that, and at n = 1,000 an update there costs 964,833 gas against the 508,977 that created the record, because it re-indexes 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.

Live demo: r/moul/x/kitindexdemo.

Constants 1

const Fanout

1const Fanout = 32
source

Fanout is the B+ tree fanout this package uses: 32, because an index's keys are removed as its records move.

A removal shifts every later value in its leaf and rewrites each one, and an index's values are pointers: 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 against 8.0M for the last, about 230k per value shifted. Fanout 32 bounds what one removal rewrites to about 45 values: its own leaf, and a neighbour's when the leaf underflows and borrows. What it costs, measured against gno master 1fc4c140e on 2026-09-29 at n = 1,000 with string values: 671 bytes per entry and 162,161 gas per insert, against 592 and 152,975 at fanout 128, and avl's 2,029 and 417,811.

Variables 1

var ErrEmptyKey, ErrZeroID, ErrDuplicateKey

 1var (
 2	// ErrEmptyKey is returned for "". An empty key sorts before every real one
 3	// and is almost always an unset field rather than a deliberate bucket, so
 4	// it is refused rather than silently indexed.
 5	ErrEmptyKey = errors.New("index: the key is empty")
 6
 7	// ErrZeroID is returned for store.ID(0), which [store.Store] never assigns,
 8	// so indexing it can only produce a lookup that cannot hit.
 9	ErrZeroID = errors.New("index: the id is zero")
10
11	// ErrDuplicateKey is returned by a [Unique] index asked to attach a second
12	// id to a key that already has one.
13	ErrDuplicateKey = errors.New("index: the key is already taken")
14)
source

Errors an Add can return. A /p/ package returns errors and lets the realm decide whether they are fatal; every one of these is a programming error in the realm rather than a value a user chose, so panicking on them is the normal handling.

Functions 2

func New

1func New() *Index
source

New returns an empty index where a key may carry many ids. It is the zero value, spelled out for a caller who prefers a constructor.

func Unique

1func Unique() *Index
source

Unique returns an empty index where a key carries at most one id, and a second Index.Add to the same key fails with ErrDuplicateKey instead of appending.

Unique is a constructor rather than a field or a setter because flipping it on a populated index would have to either reject the duplicates already in it or silently keep them, and both are worse than not offering the operation.

Types 2

type Entry

struct
1type Entry struct {
2	Key string
3	IDs []store.ID
4}
source

Entry is one key and the ids carrying it, as returned by Index.Page.

type Index

struct
1type Index struct {
2	s *state
3}
source

Index maps a key to the ids carrying it, ordered by key.

The zero value is an empty, usable, non-unique index: a realm declares `var byTag index.Index` and writes to it, exactly as it does with a store.Store. Use Unique when at most one id may carry a key.

An Index is a handle, like the store.Store it sits next to: copies taken after the first write, or of anything Unique returned, are the same index, and a write through one is seen through all of them. A copy of a zero value that was never written is a separate empty index.

Methods on Index

func Add

method on Index
1func (ix *Index) Add(key string, id store.ID) error
source

Add attaches id to key.

Adding a pair that is already present is a no-op and returns nil, so a realm re-running a write is not punished for it. The ids under a key are kept ascending, which makes Index.Lookup depend 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, and a Render whose output depends on insertion history is the determinism bug this avoids.

func Count

method on Index
1func (ix *Index) Count(key string) int
source

Count returns how many ids carry key.

func Each

method on Index
1func (ix *Index) Each(fn func(key string, ids []store.ID) bool) bool
source

Each visits every key in ascending order with a copy of its ids, and stops when fn returns true. It reports whether it stopped early.

True means stop, matching bptree.IterCbFn and kit/store's EachUntil exactly, so a callback moved between them keeps its meaning.

fn must not call Index.Add, Index.Remove or Index.RemoveKey on this index. The walk is the B+ tree's own, which holds a position inside a leaf: removing the key being visited shifts the next one into that slot and the walk steps over it, and inserting one ahead of it is visited twice. Collect what to change, then change it after Each returns.

func First

method on Index
1func (ix *Index) First(key string) (store.ID, bool)
source

First returns the lowest id carrying key. On a Unique index it is the only one, which is the usual reason to call it.

func Has

method on Index
1func (ix *Index) Has(key string) bool
source

Has reports whether any id carries key.

func IsUnique

method on Index
1func (ix *Index) IsUnique() bool
source

IsUnique reports whether this index was built by Unique.

func Keys

method on Index
1func (ix *Index) Keys() int
source

Keys returns the number of distinct keys.

func Len

method on Index
1func (ix *Index) Len() int
source

Len returns the number of (key, id) pairs, which is what the index actually costs. It is NOT Index.Keys, and the two differ by exactly the amount of fan-out a realm has.

func Lookup

method on Index
1func (ix *Index) Lookup(key string) []store.ID
source

Lookup returns the ids carrying key, ascending, or nil.

The slice is a COPY. Handing out the stored one would make every caller a writer: an append past its length would be invisible to the index, and an assignment into it would corrupt the index with no write path having been called. A realm that returns this value onward is returning its own data, not a handle to ours.

func Page

method on Index
1func (ix *Index) Page(page, size int) []Entry
source

Page returns page number page of size keys, ascending, each with a copy of its ids.

Pages are 1-based, and every out-of-range page (zero, negative, past the end, any page of an empty index) returns nil rather than panicking, because the page number usually arrives from a Render path and is user input. This is NOT store.Store.Page, which returns an empty non-nil slice past the end; test with len, not with nil, when code handles both.

func Pages

method on Index
1func (ix *Index) Pages(size int) int
source

Pages returns how many pages of the given size the keys fill, at least 1 so a page picker always has something to render.

func Remove

method on Index
1func (ix *Index) Remove(key string, id store.ID) bool
source

Remove detaches id from key and reports whether the pair was there. A key whose last id is removed is removed with it, so Index.Keys never counts an empty bucket.

func RemoveKey

method on Index
1func (ix *Index) RemoveKey(key string) int
source

RemoveKey detaches every id from key and reports how many it detached.

Imports 3

Source Files 4