db.gno
12.74 Kb · 433 lines
1package catalog
2
3import (
4 "strings"
5
6 "gno.land/p/nym-alexiscolin000/gnoradio/store/v0"
7 "gno.land/p/nym-alexiscolin000/gnoradio/text/v0"
8 "gno.land/r/nym-alexiscolin000/gnoradio/data"
9)
10
11// The catalog's collections in the data realm (docs/ARCHITECTURE-v1.md,
12// section 3). P(n) is store.Pad. Records are store.Rec values; id lists are
13// store's packed lists.
14const (
15 cArtists = "catalog/artists" // P((id-1)/chunkSize) -> artist heads (chunk)
16 cAlists = "catalog/alists" // P(artist) -> Rec(track ids, album ids)
17 cOwners = "catalog/owners" // owner address -> artist id
18 cNames = "catalog/names" // name skeleton -> artist id
19 cTracks = "catalog/tracks" // P((id-1)/chunkSize) -> track records (chunk)
20 cGenres = "catalog/genres" // P(genre)/P(id>>9) -> packed track ids
21 cAlbums = "catalog/albums" // P((id-1)/chunkSize) -> album records (chunk)
22 cPlaylists = "catalog/playlists" // P(id) -> playlist record
23 cUsers = "catalog/users" // address -> Rec(follows, tipped, playlists, reports)
24 cLikes = "catalog/likes" // address/P(id>>9) -> packed liked track ids
25 cTipped = "catalog/tipped" // address/P(id>>9) -> packed tipped track ids
26 cReports = "catalog/reports" // P(id) -> report record (open reports only)
27 cRemoved = "catalog/removed" // kind:id -> moderator's reason, "" when its artist hid it
28 cHosts = "catalog/hosts" // allowed https host -> "1"
29 cMeta = "catalog/meta" // "n" -> counts
30 cTops = "catalog/tops" // liked|artists|fans|support -> ranks
31 cActivity = "catalog/activity" // P(0..63) -> activity record (ring, head in meta)
32 cConfig = "catalog/config" // admin, treasury, goal, bot, gen
33 cSupporters = "catalog/supporters" // address -> lifetime support
34 cMonthly = "catalog/monthly" // YYYY-MM -> support that month
35 cPromo = "catalog/promo" // P(artist) -> promo percent
36 cBudgets = "catalog/budgets" // P(artist) -> sponsored-pick settings and stats
37 cWalletDay = "catalog/walletday" // address -> day|artist ids sponsored that day
38 cClaims = "catalog/claims" // P(artist) -> pending claim
39 cProofs = "catalog/proofs" // P(artist) -> proof URL of the last finalized claim
40)
41
42var collections = []string{cArtists, cAlists, cOwners, cNames, cTracks, cGenres, cAlbums, cPlaylists, cUsers,
43 cLikes, cTipped, cReports, cRemoved, cHosts, cMeta, cTops, cActivity, cConfig, cSupporters, cMonthly,
44 cPromo, cBudgets, cWalletDay, cClaims, cProofs}
45
46const (
47 chunkSize = 8 // records per key of the chunked collections; tickets/nft reads artists with it: frozen
48 maxRec = 1500 // longest artist, track or album record when written by its author
49 idRange = 9 // likes, tips and genres are kept per range of 1<<idRange track ids
50 activitySize = 64
51)
52
53// setup makes the catalog's collections and its first settings, once: a
54// later release finds them made (it is not the writer in its init).
55func setup(cur realm, holder address) {
56 for _, c := range collections {
57 data.Make(cross(cur), c)
58 }
59 if data.Has(cMeta, "n") {
60 return
61 }
62 ops := store.Ops{}.Set(cMeta, "n", newCounts()).Set(cConfig, "admin", holder.String()).
63 Set(cHosts, "archive.org", "1").Set(cHosts, "upload.wikimedia.org", "1")
64 data.Batch(cross(cur), ops)
65}
66
67// tx reads each key from the data realm once and writes every key it
68// changed once, in one call (data.Set or data.Batch). Every entrypoint loads
69// what it needs, changes it in its tx, then saves; readers use one too, so a
70// chunk is decoded once per call. A tx is never stored.
71type tx struct {
72 es []entry
73 idx map[string]int // entry key -> position in es
74}
75
76type entry struct {
77 c, k string // collection and key
78 val string
79 ok bool // the key exists
80 dirty bool
81}
82
83func rd() *tx { return &tx{idx: map[string]int{}} }
84
85// at finds a key's entry, reading it from data the first time unless blind
86// (a key about to be overwritten).
87func (t *tx) at(c, k string, blind bool) *entry {
88 key := c + " " + k
89 if i, ok := t.idx[key]; ok {
90 return &t.es[i]
91 }
92 e := entry{c: c, k: k}
93 if !blind {
94 e.val, e.ok = data.Get(c, k)
95 }
96 t.idx[key] = len(t.es)
97 t.es = append(t.es, e)
98 return &t.es[len(t.es)-1]
99}
100
101func (t *tx) get(c, k string) (string, bool) {
102 e := t.at(c, k, false)
103 return e.val, e.ok
104}
105
106func (t *tx) val(c, k string) string { return t.at(c, k, false).val }
107
108func (t *tx) set(c, k, v string) {
109 e := t.at(c, k, false)
110 e.val, e.ok, e.dirty = v, true, true
111}
112
113// put writes a key without reading it first.
114func (t *tx) put(c, k, v string) {
115 e := t.at(c, k, true)
116 e.val, e.ok, e.dirty = v, true, true
117}
118
119func (t *tx) del(c, k string) {
120 if e := t.at(c, k, false); e.ok {
121 e.val, e.ok, e.dirty = "", false, true
122 }
123}
124
125// save writes what t changed: one data.Set or data.Remove, or Batches of
126// 64 ops (a bulk ResolveReports may change more keys than one Batch takes;
127// the transaction keeps them atomic, as in radio).
128func save(cur realm, t *tx) {
129 var ops store.Ops
130 for _, e := range t.es {
131 if !e.dirty {
132 continue
133 }
134 if e.ok {
135 ops = ops.Set(e.c, e.k, e.val)
136 } else {
137 ops = ops.Del(e.c, e.k)
138 }
139 }
140 for len(ops) > 4*64 {
141 data.Batch(cross(cur), ops[:4*64])
142 ops = ops[4*64:]
143 }
144 switch {
145 case len(ops) == 0:
146 case len(ops) > 4:
147 data.Batch(cross(cur), ops)
148 case ops[0] == store.OpSet:
149 data.Set(cross(cur), ops[1], ops[2], ops[3])
150 default:
151 data.Remove(cross(cur), ops[1], ops[2])
152 }
153}
154
155// ---- chunked records: artists, tracks and albums, chunkSize per key ----
156
157// rec returns record id of a chunked collection ("" when absent).
158func (t *tx) rec(c string, id int) string {
159 if id < 1 {
160 return ""
161 }
162 k, i := store.Slot(id, chunkSize)
163 ch := t.val(c, k)
164 if ch == "" {
165 return ""
166 }
167 return store.Field(ch, i)
168}
169
170// point is tx.rec without a tx, for the getters that read one or two
171// records: no tx to build for a call the radio makes in loops.
172func point(c string, id int) string {
173 if id < 1 {
174 return ""
175 }
176 k, i := store.Slot(id, chunkSize)
177 if ch, _ := data.Get(c, k); ch != "" {
178 return store.Field(ch, i)
179 }
180 return ""
181}
182
183// artistAt is field f of an artist's record, "" when unknown (point).
184func artistAt(id, f int) string {
185 if rec := point(cArtists, id); rec != "" {
186 return store.Field(rec, f)
187 }
188 return ""
189}
190
191// setRec replaces record id.
192func (t *tx) setRec(c string, id int, rec string) {
193 k, i := store.Slot(id, chunkSize)
194 t.set(c, k, store.With(t.val(c, k), i, rec))
195}
196
197// addRec appends record id, the collection's next one.
198func (t *tx) addRec(c string, id int, rec string) {
199 k, i := store.Slot(id, chunkSize)
200 if i == 0 {
201 t.put(c, k, store.Rec(rec))
202 return
203 }
204 t.set(c, k, store.Append(t.val(c, k), rec))
205}
206
207// fits refuses a record too long for its chunk: chunkSize records, each
208// with room left for its counters and early badges, stay under data's
209// 16 KiB value limit, so one author can never fill a chunk others share.
210func fits(rec string) string {
211 if len(rec) > maxRec {
212 panic("catalog: these details are too long, shorten the links or texts")
213 }
214 return rec
215}
216
217// ---- counts: catalog/meta "n", one field each ----
218
219// Count fields. A count is read and changed in place (store.Field and
220// store.With): converting numbers costs gas, so nothing decodes the record.
221// Reaching field i walks the i before it: what a publish changes comes first.
222const (
223 mTracks = 0
224 mGenre = 0 // + genre id (1..NumGenres): tracks filed under it
225)
226
227const (
228 mArtists = NumGenres + 1 + iota
229 mAlbums
230 mPlaylists
231 mReports // filed
232 mOpen // reports not yet resolved
233 mSupporters
234 mSupport // ugnot
235 mFields
236)
237
238func newCounts() string {
239 fs := make([]string, mFields)
240 for i := range fs {
241 fs[i] = "0"
242 }
243 return store.Rec(fs...)
244}
245
246func (t *tx) count(i int) int64 {
247 if m := t.val(cMeta, "n"); m != "" {
248 return text.Atoi64(store.Field(m, i))
249 }
250 return 0
251}
252
253// bump adds d to count i and returns the new value; save writes it.
254func (t *tx) bump(i int, d int64) int64 {
255 n := t.count(i) + d
256 t.set(cMeta, "n", store.With(t.val(cMeta, "n"), i, text.Itoa64(n)))
257 return n
258}
259
260// ---- id ranges: a sorted packed list per 512 ids ----
261
262func rangeKey(prefix string, id int) string { return prefix + "/" + store.Pad(id>>idRange) }
263
264// addID and removeID change a ranged id set; false when nothing changed.
265func (t *tx) addID(c, prefix string, id int) bool {
266 k := rangeKey(prefix, id)
267 list := t.val(c, k)
268 if n := store.Count(list); n == 0 || store.At(list, n-1) < id {
269 t.set(c, k, list+store.Enc(id)) // a new highest id, as every new track's
270 return true
271 }
272 list, ok := store.Add(list, id)
273 if ok {
274 t.set(c, k, list)
275 }
276 return ok
277}
278
279func (t *tx) removeID(c, prefix string, id int) bool {
280 k := rangeKey(prefix, id)
281 list, ok := store.Remove(t.val(c, k), id)
282 switch {
283 case !ok:
284 case list == "":
285 t.del(c, k)
286 default:
287 t.set(c, k, list)
288 }
289 return ok
290}
291
292// rangePage reads up to limit ids of a ranged set from offset, highest
293// first, and the set's size, one data.Page per 100 ranges.
294func rangePage(c, prefix string, offset, limit int) (ids []int, total int) {
295 end := prefix + "0"
296 // ponytail: at most 10 pages of 100 ranges (512,000 track ids); keep a
297 // counter per set if the catalog ever gets near that.
298 for p := 0; p < 10; p++ {
299 keys, lists := store.Rows(data.Page(c, prefix+"/", end, 100, true))
300 for _, l := range lists {
301 n := store.Count(l)
302 total += n
303 if offset >= n {
304 offset -= n
305 continue
306 }
307 if len(ids) < limit {
308 ids = append(ids, store.Page(l, offset, limit-len(ids), true)...)
309 }
310 offset = 0
311 }
312 if len(keys) < 100 {
313 break
314 }
315 end = keys[len(keys)-1]
316 }
317 return ids, total
318}
319
320// ---- activity ring and leaderboards ----
321
322// Activity is one entry of the public activity feed.
323type Activity struct {
324 Kind string // publish | like | follow | tip | support | playlist | album | claim
325 By address
326 Track int // track, playlist or album id depending on Kind
327 Artist int
328 Amount int64
329 At int64
330}
331
332// activity writes the next slot of the fixed-size ring (storage never
333// grows), numbers given as decimal strings. The ring's head is a key of its
334// own in catalog/meta: rewriting the counts would cost a like more.
335func (t *tx) activity(kind string, by address, track, artist, amount string) {
336 h := text.Atoi(t.val(cMeta, "head"))
337 t.put(cActivity, store.Pad(h), store.Rec(kind, by.String(), track, artist, amount, text.Itoa64(now())))
338 t.set(cMeta, "head", text.Itoa((h+1)%activitySize))
339}
340
341// activityOnce is activity for likes and follows, skipped when the newest
342// entry is already this wallet's of the same kind: like and unlike cycles,
343// or a run of likes, take one slot of the feed, not the whole ring.
344func (t *tx) activityOnce(kind string, by address, track, artist string) {
345 h := text.Atoi(t.val(cMeta, "head"))
346 last := t.val(cActivity, store.Pad((h+activitySize-1)%activitySize))
347 if store.Field(last, 0) == kind && store.Field(last, 1) == by.String() {
348 return
349 }
350 t.activity(kind, by, track, artist, "0")
351}
352
353type Rank struct {
354 Key string
355 Value int64
356}
357
358// Leaderboard names in cTops.
359const (
360 topLiked = "liked" // track ids by likes
361 topArtists = "artists" // artist ids by tips
362 topFans = "fans" // addresses by tipped
363 topSupport = "support" // addresses by support
364)
365
366func ranks(rec string) []Rank {
367 if rec == "" {
368 return nil
369 }
370 f := store.Fields(rec)
371 out := make([]Rank, 0, len(f)/2)
372 for i := 0; i+1 < len(f); i += 2 {
373 out = append(out, Rank{f[i], text.Atoi64(f[i+1])})
374 }
375 return out
376}
377
378func (t *tx) top(name string) []Rank { return ranks(t.val(cTops, name)) }
379
380// rank moves k to v in a leaderboard, written only when the ranking
381// changed. Most changes leave it alone: when v grew (up) and is no higher
382// than the tenth value, k was not ranked either, so nothing is decoded. A
383// value that went down is looked for in the list (a substring match only
384// means "maybe ranked").
385func (t *tx) rank(name, k string, v int64, up bool) {
386 old := t.val(cTops, name)
387 if old != "" {
388 if last := store.Field(old, 2*topSize-1); last != "" && v <= text.Atoi64(last) && (up || !strings.Contains(old, k)) {
389 return
390 }
391 }
392 list := updateTop(ranks(old), k, v)
393 fs := make([]string, 0, 2*len(list))
394 for _, r := range list {
395 fs = append(fs, r.Key, text.Itoa64(r.Value))
396 }
397 if rec := store.Rec(fs...); rec != old {
398 t.set(cTops, name, rec)
399 }
400}
401
402// updateTop keeps a short leaderboard sorted by value, highest first.
403func updateTop(list []Rank, k string, v int64) []Rank {
404 if len(list) == topSize && v <= list[topSize-1].Value {
405 ranked := false
406 for _, r := range list {
407 ranked = ranked || r.Key == k
408 }
409 if !ranked {
410 return list
411 }
412 }
413 out := make([]Rank, 0, topSize+1)
414 for _, r := range list {
415 if r.Key != k {
416 out = append(out, r)
417 }
418 }
419 i := len(out)
420 for i > 0 && out[i-1].Value < v {
421 i--
422 }
423 if i >= topSize {
424 return out
425 }
426 out = append(out, Rank{})
427 copy(out[i+1:], out[i:])
428 out[i] = Rank{k, v}
429 if len(out) > topSize {
430 out = out[:topSize]
431 }
432 return out
433}