store.gno
13.62 Kb · 441 lines
1// Package store keeps Gnogolf's data (every record, best, board, standing,
2// round in play and hole) apart from its rules, so a new version of the rules
3// takes the data on instead of starting empty. It knows nothing of golf: a
4// few ordered collections of string keys to string values, written only by
5// the one realm it names as its writer (the rules: golf/v2 at the launch),
6// read by anyone. A new writer takes over three days after its code is on
7// chain and says so (the owner proposes it, it calls Ready), on its own; the
8// owner can stop every write at once (Pause), start the same rules again at
9// once (Resume), and renounce the role for good.
10package store
11
12import (
13 "chain"
14 "chain/runtime/unsafe"
15 "strconv"
16 "strings"
17 "time"
18
19 bptree "gno.land/p/nt/bptree/v0"
20)
21
22// DELAY is how long a proposed writer waits, from its Ready, before it takes
23// over: three days for anyone to read its code on gnoweb, which is on chain
24// from then on and cannot change. A constant: it can never be shortened.
25const DELAY = 72 * 3600 // seconds of block time
26
27// The bounds of every call: none can be made to run, or to store, unbounded.
28const (
29 maxKey = 256
30 maxValue = 64 << 10
31 maxPage = 300
32 maxOps = 64
33 maxName = 32
34)
35
36var (
37 self = unsafe.CurrentRealm().PkgPath()
38 // writer is the rules' pkgpath, the only caller a write takes ("" paused);
39 // proposed, the next one, taking over at at (block time, unix seconds; 0
40 // until it calls Ready)
41 writer = strings.TrimSuffix(self, "store") + "golf/v2"
42 proposed string
43 at int64
44 // paused is the writer a Pause stopped, which Resume starts again ("" for none)
45 paused string
46 // owner may propose, cancel, pause and pass the role; "" once renounced
47 owner address
48 pending address
49 // the collections, by name: made at init, or by the writer (Make)
50 colls = map[string]*bptree.BPTree{}
51)
52
53// the collections v2 keeps its data in, made at deploy: a first save never
54// pays a whole new tree
55var standard = []string{"meta", "holes", "community", "data", "slots", "aliases", "authors", "hidden", "archived", "rounds", "bests", "boards", "totals", "ranks", "wear", "fresh", "copies"}
56
57func init() {
58 owner = unsafe.OriginCaller()
59 for _, n := range standard {
60 colls[n] = seeded()
61 }
62}
63
64// seeded is a collection whose first leaf the deployer pays, not its first
65// player: its key "", which no write can make (checkKey) and no read shows,
66// sorts before every other.
67func seeded() *bptree.BPTree {
68 t := bptree.NewBPTree32()
69 t.Set("", "")
70 return t
71}
72
73// --- who writes ----------------------------------------------------------
74
75// Writer is the realm whose writes store takes now: the proposed one once its
76// delay is over ("" while paused).
77func Writer() string {
78 if matured() {
79 return proposed
80 }
81 return writer
82}
83
84func matured() bool { return proposed != "" && at != 0 && time.Now().Unix() >= at }
85
86// Proposed is the writer proposed and when it takes over (unix seconds; 0
87// while its code has not called Ready), or "" and 0.
88func Proposed() (string, int64) {
89 if matured() {
90 return "", 0
91 }
92 return proposed, at
93}
94
95// settle makes a proposed writer whose delay is over the writer, as it is
96// already for every read (Writer).
97func settle() {
98 if matured() {
99 writer, proposed, at, paused = proposed, "", 0, ""
100 chain.Emit("WriterChanged", "writer", writer)
101 }
102}
103
104// Owner is the role that may propose, cancel and pause, "" once renounced.
105func Owner() address { return owner }
106
107// gate lets the writer through, and only it: the immediate caller's pkgpath,
108// never the signer (an account, a MsgRun script or another realm is refused).
109// A proposed writer whose delay is over becomes the writer at its first write.
110func gate(cur realm) {
111 w := Writer()
112 if w == "" {
113 panic("store: paused: no writes until the owner names new rules")
114 }
115 if cur.Previous().PkgPath() != w {
116 panic("store: only " + w + " writes here")
117 }
118 settle()
119}
120
121func onlyOwner(cur realm, what string) {
122 if owner == "" || cur.Previous().Address() != owner {
123 panic("store: only the owner can " + what)
124 }
125}
126
127// Propose names the next writer, a realm's pkgpath under gno.land/r/. Only the
128// owner can; a new proposal replaces the last. Its delay starts when that
129// realm, on chain, calls Ready: its code can then be read, as it will run.
130func Propose(cur realm, pkgpath string) {
131 onlyOwner(cur, "propose new rules")
132 if !validPkgPath(pkgpath) || pkgpath == self {
133 panic("store: the rules are a realm: gno.land/r/ and up to 100 of a-z, 0-9 and _-/.")
134 }
135 settle() // (one whose delay is over is the writer already)
136 proposed, at = pkgpath, 0
137 chain.Emit("WriterProposed", "writer", pkgpath)
138}
139
140// Ready is the proposed writer's own call, once it is on chain: it takes over
141// DELAY later, with no transaction to send. Only that realm can, once.
142func Ready(cur realm) {
143 if proposed == "" || at != 0 || cur.Previous().PkgPath() != proposed {
144 panic("store: only the proposed rules, once, say they are ready")
145 }
146 at = time.Now().Unix() + DELAY
147 chain.Emit("WriterReady", "writer", proposed, "at", strconv.FormatInt(at, 10))
148}
149
150// validPkgPath is golf v1's: gno.land/r/, up to 100 of a-z, 0-9 and _-/.,
151// no "", "." or ".." segment.
152func validPkgPath(p string) bool {
153 if len(p) > 100 || !strings.HasPrefix(p, "gno.land/r/") {
154 return false
155 }
156 for _, seg := range strings.Split(p[len("gno.land/r/"):], "/") {
157 if seg == "" || seg == "." || seg == ".." {
158 return false
159 }
160 }
161 for i := 0; i < len(p); i++ {
162 c := p[i]
163 if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || strings.IndexByte("_-/.", c) >= 0) {
164 return false
165 }
166 }
167 return true
168}
169
170// Cancel drops the proposed writer before it takes over. Only the owner can.
171func Cancel(cur realm) {
172 onlyOwner(cur, "cancel new rules")
173 if p, _ := Proposed(); p == "" {
174 panic("store: no new rules proposed")
175 }
176 drop()
177}
178
179// Pause stops every write at once (the reads go on), and drops any proposal.
180// Only the owner can: for a bug in the rules, or one feared. Resume starts the
181// same rules again at once; new rules take their delay.
182func Pause(cur realm) {
183 onlyOwner(cur, "pause")
184 settle() // (a takeover already in force is said, then stopped too)
185 drop()
186 if writer != "" {
187 paused = writer
188 }
189 writer = ""
190 chain.Emit("Paused")
191}
192
193// Resume starts again, at once, the rules a Pause stopped: their code was in
194// force already, so nothing new runs. Only the owner can.
195func Resume(cur realm) {
196 onlyOwner(cur, "resume")
197 settle()
198 if writer != "" || paused == "" {
199 panic("store: not paused")
200 }
201 writer, paused = paused, ""
202 chain.Emit("Resumed", "writer", writer)
203}
204
205// drop forgets a proposal not in force, and says so.
206func drop() {
207 if proposed != "" {
208 chain.Emit("WriterCancelled", "writer", proposed)
209 }
210 proposed, at = "", 0
211}
212
213// Transfer offers the owner's role, which the address offered takes with Accept.
214func Transfer(cur realm, to address) {
215 onlyOwner(cur, "pass the role")
216 if !to.IsValid() {
217 panic("store: not an address")
218 }
219 pending = to
220 chain.Emit("OwnerOffered", "to", to.String())
221}
222
223// Accept takes the role offered.
224func Accept(cur realm) {
225 if pending == "" || cur.Previous().Address() != pending {
226 panic("store: no transfer to you")
227 }
228 owner, pending = pending, ""
229 chain.Emit("OwnerChanged", "owner", owner.String())
230}
231
232// Renounce gives the role up for good: the writer can then never change, nor
233// be paused; a proposal not yet in force is dropped. Only the owner can, and
234// not while paused: no rules could ever write again.
235func Renounce(cur realm) {
236 onlyOwner(cur, "renounce")
237 settle()
238 if writer == "" {
239 panic("store: paused: name new rules before giving the role up, or nothing could ever be saved again")
240 }
241 drop()
242 owner, pending = "", ""
243 chain.Emit("OwnerRenounced")
244}
245
246// --- writes (the writer only) -----------------------------------------------
247
248func coll(name string) *bptree.BPTree {
249 t := colls[name]
250 if t == nil {
251 panic("store: no collection " + strconv.Quote(name))
252 }
253 return t
254}
255
256func checkKey(k string) {
257 if k == "" || len(k) > maxKey {
258 panic("store: a key is 1 to 256 bytes")
259 }
260}
261
262// (a string is a value: what store keeps is its own, never the caller's slice)
263func set(name, k, v string) {
264 checkKey(k)
265 if len(v) > maxValue {
266 panic("store: a value is at most 64 KiB")
267 }
268 coll(name).Set(k, v)
269}
270
271// Set keeps value under key in a collection. The writer only.
272func Set(cur realm, name, key, value string) {
273 gate(cur)
274 set(name, key, value)
275}
276
277// Remove drops a key from a collection, reporting whether it was there. The writer only.
278func Remove(cur realm, name, key string) bool {
279 gate(cur)
280 checkKey(key)
281 _, ok := coll(name).Remove(key)
282 return ok
283}
284
285// Batch applies a save's writes in one call: ops is "set", collection, key,
286// value or "del", collection, key, "" (four strings an op), at most 64 ops.
287// A transaction is all or nothing: a bad op undoes the whole batch.
288func Batch(cur realm, ops []string) {
289 gate(cur)
290 if len(ops)%4 != 0 || len(ops) > 4*maxOps {
291 panic("store: a batch is up to 64 ops of four strings")
292 }
293 for i := 0; i < len(ops); i += 4 {
294 switch ops[i] {
295 case "set":
296 set(ops[i+1], ops[i+2], ops[i+3])
297 case "del":
298 checkKey(ops[i+2])
299 coll(ops[i+1]).Remove(ops[i+2])
300 default:
301 panic("store: an op is set or del")
302 }
303 }
304}
305
306// Make adds a collection, for rules that need one more. The writer only.
307func Make(cur realm, name string) {
308 gate(cur)
309 if name == "" || len(name) > maxName || colls[name] != nil {
310 panic("store: a new collection's name is 1 to 32 bytes, not taken")
311 }
312 colls[name] = seeded()
313}
314
315// --- reads (anyone) ----------------------------------------------------------
316
317// Get is a key's value in a collection, and whether it is there.
318func Get(name, key string) (string, bool) {
319 if key == "" {
320 return "", false // (the seed)
321 }
322 v := coll(name).Get(key)
323 if v == nil {
324 return "", false
325 }
326 return v.(string), true
327}
328
329// Has reports whether a collection holds a key.
330func Has(name, key string) bool { return key != "" && coll(name).Has(key) }
331
332// Size is how many keys a collection holds.
333func Size(name string) int { return coll(name).Size() - 1 }
334
335// Index is how many keys of a collection sort before key: its place, from 0
336// (O(log n) a step of a binary search, as golf v1's boards).
337func Index(name, key string) int {
338 t := coll(name)
339 lo, hi := 0, t.Size()
340 for lo < hi {
341 mid := (lo + hi) / 2
342 if k, _ := t.GetByIndex(mid); k < key {
343 lo = mid + 1
344 } else {
345 hi = mid
346 }
347 }
348 if lo > 0 {
349 lo-- // (the seed, first: never counted)
350 }
351 return lo
352}
353
354// At is the key and value at a place in a collection's order.
355func At(name string, i int) (string, string) {
356 t := coll(name)
357 if i < 0 || i >= t.Size()-1 {
358 return "", ""
359 }
360 k, v := t.GetByIndex(i + 1)
361 return k, v.(string)
362}
363
364// Page is up to limit keys of a collection from start (included) to end
365// (excluded; "" to its end), in order, as rows one after another: each the
366// key's length, ":", the key, the value's length, ":", the value (no
367// separator to escape: the lengths say where each ends).
368// limit is 1 to 300.
369func Page(name, start, end string, limit int) string { return page(name, start, end, limit, false) }
370
371// PageBack is Page from end (kept, as bptree's back walk) down to start.
372func PageBack(name, start, end string, limit int) string { return page(name, start, end, limit, true) }
373
374// PageAt is up to limit keys from the offset-th of a collection (from 0), as Page's rows.
375func PageAt(name string, offset, limit int) string {
376 if limit < 1 || limit > maxPage {
377 panic("store: a page is 1 to 300 keys")
378 }
379 var sb strings.Builder
380 t := coll(name)
381 if offset < 0 {
382 offset = 0
383 }
384 if offset >= t.Size()-1 {
385 return "" // (and offset+1 cannot wrap)
386 }
387 t.IterateByOffset(offset+1, limit, func(k string, v any) bool { // (past the seed)
388 row(&sb, k, v.(string))
389 return false
390 })
391 return sb.String()
392}
393
394func page(name, start, end string, limit int, back bool) string {
395 if limit < 1 || limit > maxPage {
396 panic("store: a page is 1 to 300 keys")
397 }
398 var sb strings.Builder
399 n := 0
400 cb := func(k string, v any) bool {
401 if k == "" {
402 return false // (the seed)
403 }
404 row(&sb, k, v.(string))
405 n++
406 return n >= limit
407 }
408 if back {
409 coll(name).ReverseIterate(start, end, cb)
410 } else {
411 coll(name).Iterate(start, end, cb)
412 }
413 return sb.String()
414}
415
416// row writes one key and its value into a page.
417func row(sb *strings.Builder, k, v string) {
418 sb.WriteString(strconv.Itoa(len(k)) + ":" + k + strconv.Itoa(len(v)) + ":" + v)
419}
420
421// Render says who writes, what is proposed and who may change it.
422func Render(path string) string {
423 var sb strings.Builder
424 sb.WriteString("# Gnogolf's data\n\nEvery record, best, board and hole of the game, kept apart from its rules so that new rules take them on.\n\n")
425 if w := Writer(); w == "" {
426 sb.WriteString("**Paused:** no saves until the owner resumes the rules or names new ones. Everything can still be read.\n\n")
427 } else {
428 sb.WriteString("**The rules:** [" + w + "](" + strings.TrimPrefix(w, "gno.land") + ")\n\n")
429 }
430 if p, t := Proposed(); p != "" && t == 0 {
431 sb.WriteString("**New rules proposed:** " + p + ", not on chain yet: they take over three days after their code calls Ready, unless the owner cancels them.\n\n")
432 } else if p != "" {
433 sb.WriteString("**New rules proposed:** [" + p + "](" + strings.TrimPrefix(p, "gno.land") + "), taking over on " + time.Unix(t, 0).UTC().Format("2006-01-02 15:04 UTC") + " unless the owner cancels them. Read their code before then.\n\n")
434 }
435 if owner == "" {
436 sb.WriteString("**Nobody** can change the rules any more: the owner's role was given up.\n")
437 } else {
438 sb.WriteString("**The owner** (" + owner.String() + ") can propose new rules, which take over three days after their code is on chain, cancel them before, or stop every save at once and start the same rules again. Nothing else: no record can be written but by the rules.\n")
439 }
440 return sb.String()
441}