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

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}