The auto-id ordered collection every r/moul realm was building by hand: an
avl.Tree, an int counter, and a private function that zero-pads the counter
into a key so the tree iterates in the order a human expects.
Second package of the p/moul/kit/* layer
(moul/gno-contracts#151).
kit composes the existing packages, it does not replace them.
Why it exists
Sixteen realm files in this repo carry that third piece. They do not agree:
Name
Width
Implementation
Realms
padID
6
for len(s) < 6 { s = "0" + s }
asciiart
pad
12
for len(s) < 12 { s = "0" + s }
tictactoe
key
12
the same loop, a different name
blog, crowdfund, englishauction, erc721, splitter
idKey
12
the same loop again, a third name
connect4
idKey
12
strings.Repeat, guarded by len(s) >= width
governor, todos
idKey
12
strings.Repeat, unguarded
timecapsule
seqKey
16
strings.Repeat, guarded
guestbook
Five names, three widths, three implementations, one job, across 16 files in 12
realms.
Every one of them is a silent ceiling. The key is a decimal string, and
decimal strings do not sort numerically. Below the width the zero-padding hides
that; at the width the padding stops and the tree starts ordering
"1000000000000" before "999999999999", so every list the realm renders is
wrong from that entry on. Nothing fails, nothing logs, the order is just quietly
false.
The unguarded variant fails harder. strings.Repeat panics on a negative count,
so past its width timecapsule stops accepting writes rather than mis-sorting
them.
asciiart's width of 6 puts its ceiling at one million entries.
Both tests live in
store_test.gno (TestOrderSurvivesThePaddingCeiling,
TestUnguardedPadPanicsPastItsWidth), asserting the defect rather than
describing it.
The fix is to delete the decision
Keys are the big-endian bytes of a
p/nt/seqid ID: a fixed 8 bytes whose lexicographic order
is numeric order, for every value a uint64 can hold. There is no width to
pick and no width to outgrow. seqid already solved this and was imported by
exactly one file in the repo.
1import"gno.land/p/moul/kit/store/v0" 2 3vargames=store.Named("game")// or `var games store.Store` 4 5funcNewGame(currealm)int64{ 6returnint64(games.Add(&Game{Board:empty,X:caller()})) 7} 8 9funcMove(currealm,gameIDint64,cellint){10g:=games.MustGet(store.ID(gameID)).(*Game)// panics "game #7 not found"11...12}1314funcRender(pathstring)string{15for_,e:=rangegames.PageReverse(1,20){// newest first, one page16g:=e.Value.(*Game)17...e.ID...18}19}
API
New()
a new empty store; the zero Store works too
Named("game")
the same, with a noun for the MustGet panic
Add(v) ID
store under the next ID
Set(id, v) bool
write at an explicit ID, reports a replacement
Get(id) (any, bool)
value and presence, so a stored nil is not "absent"
MustGet(id) any
or panic store: no entry #7, or game #7 not found when named
Has(id), Remove(id), Len(), LastID()
Each(fn), EachReverse(fn)
every entry, ascending / descending
EachUntil(fn) bool, EachReverseUntil(fn) bool
stop when fn returns true
Page(page, size) []Entry
1-based, ascending
PageReverse(page, size) []Entry
1-based, newest first
Pages(size) int
for the pager footer
ParseID(s) (ID, bool), ID.String(), ID.Key()
the path round trip
IDs stay plain integers
An ID is a uint64 that renders as a decimal number, so a realm ported to
this package keeps showing #7 exactly as it did. Only the avl key changes, and
the key was never user-visible.
IDs start at 1, so the zero ID is usable as "absent" and ParseID("0")
rejects. Removing an entry does not free its ID: IDs are a history, not a
dense index, and reusing one would silently repoint an old link at a new object.
Two iteration shapes, on purpose
Each takes no stop signal. It is the common case, and a callback whose bool
means "stop" reads identically to one whose bool means "continue", so the
wrong guess is invisible. When iteration has to end early the name says so:
EachUntil, where true means stop, matching avl.IterCbFn exactly. A
callback moved between this package and a raw tree keeps its meaning.
Paging is 1-based and forgiving
Page numbers are shown to a reader ("page 1 of 4"), and an off-by-one between
the URL and the label is the bug this avoids. A page past the end, or a page or
size below 1, returns an empty slice rather than panicking, because the page
number usually arrives from a Render path and that is user input. Only the
requested window is walked, not the whole tree.
Design rule
The safe, conventional thing must be the shortest thing to type.Add is
shorter than nextID++ plus a padding function, and it cannot be got wrong.
That is the only mechanism that stops this helper from being written a
seventeenth time.
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:
⚠️ Disclaimer: provided as-is, without warranty; not security-audited. Full disclaimer: DISCLAIMER.
Overview
Package store is the auto-id ordered collection every r/moul realm was building by hand: an avl.Tree, an int counter, and a private function that zero-pads the counter into a key so the tree iterates in the order a human expects.
Sixteen realm files in this repo carry that third piece. They do not agree:
Example
1padID width 6 for len(s) < 6 { s = "0" + s } asciiart
2pad width 12 for len(s) < 12 { s = "0" + s } tictactoe
3key width 12 the same loop, a different name blog, crowdfund,
4 englishauction,
5 erc721, splitter
6idKey width 12 the same loop, a third name connect4
7idKey width 12 strings.Repeat, guarded by len(s) >= 12 governor, todos
8idKey width 12 strings.Repeat, UNGUARDED timecapsule
9seqKey width 16 strings.Repeat, guarded guestbook
Every one of them is a silent ceiling. Past the width, the padding stops and the tree starts ordering "1000000000000" before "999999999999", so the list a realm renders is simply wrong from that entry on. The unguarded variant is worse: strings.Repeat panics on a negative count, so the realm stops accepting writes rather than merely mis-sorting them. asciiart's width of 6 puts that ceiling at one million entries.
The ceiling exists because the key is a decimal string, and decimal strings do not sort numerically. This package removes the ceiling by removing the decision: keys are the big-endian bytes of a gno.land/p/nt/seqid/v0 ID, a fixed 8 bytes whose lexicographic order IS numeric order, for every value a uint64 can hold. There is no width to pick and no width to outgrow.
Usage
Example
1var games store.Store // the zero value is an empty store
2 3func NewGame(cur realm) int64 {
4 id := games.Add(&Game{...})
5 return int64(id)
6}
7 8func Move(cur realm, gameID int64, cell int) {
9 g := games.MustGet(store.ID(gameID)).(*Game)
10 ...
11}
1213func Render(path string) string {
14 for _, e := range games.PageReverse(1, 20) { // newest first
15 g := e.Value.(*Game)
16 ... e.ID ...
17 }
18}
Identifiers
An ID is a uint64 that renders as a plain decimal number, so a realm ported to this package keeps showing "#7" exactly as it did before. Only the avl key changes, and the key was never user-visible.
IDs start at 1. The zero ID is never assigned, so it is usable as "absent", and Store.Get on it always misses.
Removing an entry does not free its ID. IDs are a history, not a dense index: reusing one would silently repoint an old link at a new object.
ParseID reads an ID from its decimal form, the shape it has in a Render path or a function argument.
It returns false for anything that is not a plain unsigned decimal, which includes "", "-1", "1.0", a value past uint64, and the zero ID (never assigned, so accepting it would only produce a lookup that cannot hit). Leading zeros are accepted: "007" is the ID 7.
Named is New with a noun for what the store holds, used in the panic Store.MustGet raises: store.Named("game") makes it "game #7 not found" instead of "store: no entry #7".
It exists so porting a realm to this package does not have to trade its own error wording for one shared implementation. The realms being replaced here panic "game not found", "proposal not found", "capsule not found"; the label keeps that and adds the id they all omitted.
Key is the avl key for the ID: the 8 big-endian bytes of the underlying seqid, whose byte order is its numeric order.
Exposed because a realm that keeps a secondary index keyed by the same ID needs the identical key, and because it is the one place the encoding is decided.
Store is an ordered collection of values under auto-assigned IDs.
The zero Store is an empty store and is ready to use, so a realm can declare one as a package-level var without an initializer. New exists for the pointer form.
It takes no stop signal on purpose: this is the common case, and a callback whose bool means "stop" reads identically to one whose bool means "continue". When iteration has to end early, say so in the name and use Store.EachUntil.
Get returns the value at id, and whether it was there.
Unlike avl.Tree.Get, a nil value stored at a live ID is distinguishable from an absent one, because the second result comes from Has rather than from the value being nil.
This is the "get or panic" the realms wrote eighteen times, with one implementation instead of eighteen. The panic names the id, which none of them did: "store: no entry #7", or "game #7 not found" for a store built with Named.
Page returns page number page of size entries, in ascending ID order.
Pages are 1-based, because they are shown to a reader ("page 1 of 4") and an off-by-one between the URL and the label is the bug this saves. A page past the end, or a page or size below 1, returns an empty slice rather than panicking: the page number usually comes from a Render path, which is user input.
It walks only the requested window, not the whole tree.
Set writes v at an existing or explicit ID, and reports whether it replaced a value.
Use it to update an entry in place. It does not move the ID counter, so setting an ID above Store.LastID leaves a gap that Store.Add will later walk into and overwrite. Prefer Store.Add to create.