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

kitindexdemo.gno

5.79 Kb · 179 lines
  1// untrusted-render: the tag is charset-checked to [a-z0-9-] at write time and
  2// is the only stored string interpolated raw; the body is a caller's text and
  3// goes through ui.Cell at every call site that renders it.
  4
  5// Package kitindexdemo is a notes board whose only job is to show
  6// gno.land/p/moul/kit/index doing the thing a realm with a store always ends
  7// up needing: answering "every note tagged gno" as cheaply as it answers
  8// "note 7".
  9//
 10// There is no logic of its own here. The records are a kit/store, the two
 11// lookups are kit/index, the page is kit/ui, and what is worth reading is the
 12// shape: every write touches the store and both indexes in ONE function, which
 13// is the entire correctness argument for keeping them in separate containers.
 14//
 15// Demo of the p/moul/kit/index library.
 16package kitindexdemo
 17
 18import (
 19	"strconv"
 20	"strings"
 21
 22	"chain/runtime"
 23
 24	"gno.land/p/moul/kit/index/v0"
 25	"gno.land/p/moul/kit/store/v0"
 26)
 27
 28// MaxBody is the longest note this realm accepts, in bytes.
 29//
 30// A bound rather than none: every byte stored here locks a storage deposit,
 31// paid by whoever signs the write and refunded to whoever signs the delete
 32// (EFFECTIVE_GNO.md section 9.2), and an unbounded write is an unbounded
 33// state the realm carries forever.
 34const MaxBody = 280
 35
 36// MaxTagLen bounds the index key for the same reason, and small because a tag
 37// is a label rather than a sentence.
 38const MaxTagLen = 24
 39
 40// PageSize is how many tags the root page shows.
 41const PageSize = 20
 42
 43// MaxNotes and MaxPerAuthor bound the WHOLE board, which MaxBody does not.
 44//
 45// A per-call length limit bounds one write and nothing else: a caller posting
 46// one-byte notes in a loop grows the store and both indexes without limit. "An unbounded container a third party can grow is an
 47// unbounded storage deposit someone is paying for" is a rule this realm exists
 48// to demonstrate, so it had better obey it.
 49//
 50// Two caps rather than one, and what each buys is narrower than it looks.
 51// MaxNotes bounds the state, which is the property this realm promises.
 52// MaxPerAuthor stops one ADDRESS, not one actor: addresses are free, so fifty
 53// fresh ones at twenty notes each still fill the board and lock everyone else
 54// out until somebody retracts. Keeping a board open to strangers needs
 55// something a griefer cannot mint for free, a deposit or an expiry, which this
 56// demo does not carry. Retracting frees a slot, so neither cap is a one-way door.
 57//
 58// MaxPerAuthor is answered by byAuthor.Count, which is the index doing the job
 59// it was added for.
 60const (
 61	MaxNotes     = 1000
 62	MaxPerAuthor = 20
 63)
 64
 65type note struct {
 66	Author address
 67	Tag    string
 68	Body   string
 69	Height int64
 70}
 71
 72var (
 73	notes    = store.Named("note")
 74	byTag    index.Index
 75	byAuthor index.Index
 76)
 77
 78// Post files a note under a tag and returns its id.
 79//
 80// The store write and both index writes happen here, in this order, in one
 81// frame. An abort anywhere in it leaves none of them applied, which is what
 82// makes three containers safe to keep apart.
 83func Post(cur realm, tag, body string) int64 {
 84	who := caller(cur)
 85	tag = normaliseTag(tag)
 86	assertBody(body)
 87	assertRoom(who)
 88
 89	id := notes.Add(&note{
 90		Author: who,
 91		Tag:    tag,
 92		Body:   body,
 93		Height: runtime.ChainHeight(),
 94	})
 95	mustIndex(byTag.Add(tag, id))
 96	mustIndex(byAuthor.Add(who.String(), id))
 97	return int64(id)
 98}
 99
100// Retract removes a note. Only its author may, and the store and both indexes
101// are unwound together.
102func Retract(cur realm, id int64) {
103	who := caller(cur)
104	sid, ok := store.ParseID(strconv.FormatInt(id, 10))
105	if !ok {
106		panic("kitindexdemo: not an id: " + strconv.FormatInt(id, 10))
107	}
108	n, ok := notes.Get(sid)
109	if !ok {
110		panic("kitindexdemo: note #" + sid.String() + " not found")
111	}
112	rec := n.(*note)
113	if rec.Author != who {
114		panic("kitindexdemo: note #" + sid.String() + " is not yours")
115	}
116	notes.Remove(sid)
117	byTag.Remove(rec.Tag, sid)
118	byAuthor.Remove(rec.Author.String(), sid)
119}
120
121// caller is the one place this realm decides who is acting: the realm token is
122// checked before it is walked, because an unchecked token is not a caller.
123func caller(cur realm) address {
124	if !cur.IsCurrent() {
125		panic("kitindexdemo: spoofed realm")
126	}
127	return cur.Previous().Address()
128}
129
130func mustIndex(err error) {
131	if err != nil {
132		panic(err)
133	}
134}
135
136// normaliseTag lowercases and validates the tag.
137//
138// Validated at WRITE time rather than escaped at render time, deliberately: a
139// tag is also an index key and a path segment, so a tag that could carry a
140// pipe or a slash would break the table and the URL as well as the page. The
141// charset is the narrowest thing that still reads as a label.
142func normaliseTag(tag string) string {
143	tag = strings.ToLower(strings.TrimSpace(tag))
144	if tag == "" || len(tag) > MaxTagLen {
145		panic("kitindexdemo: a tag is 1 to " + strconv.Itoa(MaxTagLen) + " characters")
146	}
147	for _, r := range tag {
148		switch {
149		case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
150		default:
151			panic("kitindexdemo: a tag is [a-z0-9-], got " + strconv.Quote(tag))
152		}
153	}
154	return tag
155}
156
157// assertRoom refuses a write the board has no room for, globally or for this
158// address. Checked before the store write, so a refusal costs the caller the
159// gas of three reads and nothing is half-applied.
160func assertRoom(who address) {
161	if notes.Len() >= MaxNotes {
162		panic("kitindexdemo: the board is full at " + strconv.Itoa(MaxNotes) +
163			" notes; retract one to free a slot")
164	}
165	if n := byAuthor.Count(who.String()); n >= MaxPerAuthor {
166		panic("kitindexdemo: " + who.String() + " already has " + strconv.Itoa(n) +
167			" notes, the limit is " + strconv.Itoa(MaxPerAuthor))
168	}
169}
170
171func assertBody(body string) {
172	if strings.TrimSpace(body) == "" {
173		panic("kitindexdemo: the body is empty")
174	}
175	if len(body) > MaxBody {
176		panic("kitindexdemo: the body is " + strconv.Itoa(len(body)) +
177			" bytes, the limit is " + strconv.Itoa(MaxBody))
178	}
179}