Transaction

72F71A363DF107…0212532552EF

Block 271,237 · index 0 · indexed

Summary

Hash
72F71A363DF1076471985E9FDAA0C3A3673B74A33C17FF807B180212532552EF
Block
271,237
Size
30952 bytes
Gas used
38,829,673 / 55,004,400
Fee
550044ugnot
Status
success

Messages

#1AddPackagegno.land/r/moul/blog17 arguments
Attached funds
13000000ugnot

Arguments · 17

  1. #1blog
  2. #2README.md
  3. #3# `gno.land/r/moul/blog` moul's personal blog, on chain. One author, posts stored as data rather than code, and a URL that is meant to survive being pasted into someone else's chat. ``` gno.land/r/moul/blog the index, newest first gno.land/r/moul/blog:<slug> one post gno.land/r/moul/blog:t/<tag> every post carrying a tag ``` ## Why private, and why no `/vN` These are the same decision seen from two sides. A post URL leaves this repository the moment it is published: it goes into chats, into other people's articles, into a link someone bookmarked. None of those can be rewritten. A `/v1` would break all of them at once, which is the one failure a blog cannot absorb, so the module line carries no version. What normally pays for a version is the ability to change the code. Here that comes from `private = true` instead: a private package may be re-added at the same path by the address in `[addpkg].creator` (`gno.land/pkg/sdk/vm/keeper.go`, `checkRedeployPermission`), and nothing else may import it. So the code can be replaced without the path moving. The bill for that arrives as a wipe. A redeploy re-runs `init()` and resets every package-level variable, so **every post is erased**. That is survivable only because the markdown is kept off chain and `tools/gnoblog -all` pushes all of it back in one transaction. The chain holds the published copy; it is not the only copy. `tools/gnocontracts` records the exemption from the repository's "every path ends in `/vN`" rule, next to `r/moul/home`'s, with the reason. ## The API Writes are restricted to `g1manfred47kzduec920z88wfr64ylksmdcedlf5`. | function | what it does | | --- | --- | | `Set(cur, slug, title, date, tags, body)` | create or replace a post, the ordinary publish | | `Append(cur, slug, body)` | append, for a body too large for one transaction | | `Delete(cur, slug)` | remove a post | | `SetIntro(cur, body)` | the markdown above the index; `""` restores the default | | `Get(slug)` | one post's raw markdown | | `Count()` · `Revision()` | how many posts, and how many writes have been accepted | | `Manifest()` | `slug⇥rev⇥date⇥len⇥sha256` per post, the diff surface | A slug is 1 to 64 bytes of `[a-z0-9._-]`. No `':'` and no `'/'`, so a slug can never break out of its own render path. `intro` is refused, because `intro.md` names the index header on the local side and a post there would have nowhere to live. A date is exactly `YYYY-MM-DD`, and that is load-bearing rather than cosmetic: posts are kept in a second avl tree keyed by `"<date>/<slug>"` and walked in reverse, so lexical order has to be chronological order. gno has no `sort.Slice`; this is what replaces it. ## `Manifest()` is the whole sync protocol One `vm/qeval` returns a line per post carrying a sha256 over the title, date, tags and body **together**. So `gnoblog status` can say exactly which local file needs a transaction without downloading a single body, and changing any one of the four fields changes the hash. Both halves build that hashed record the same way, and neither can check the other at run time: the realm's test asserts the record, and the tool's test pins its digest as a literal. If one side ever drifts, the tool reports every post as up to date while the chain holds the old text. ## Escaping The body renders **verbatim**. It is markdown written by the one address that can call `Set`, and escaping it would turn every heading and link in a post into literal text. Titles, tags and index excerpts go through `p/moul/kit/ui` anyway. Not because the author is untrusted, but because a title with a `]` in it would otherwise break the link it sits inside, and that is a rendering bug with no attacker in it at all. ## Publishing a post The markdown lives outside this repository; `tools/gnoblog` never defaults to a path to it. ```sh go -C tools tool gnoblog -content DIR status # what differs from the chain go -C tools tool gnoblog -content DIR tx # the transaction that fixes it ``` `tx` writes an unsigned transaction document and the two `gnokey` commands that sign and broadcast it. Several posts go in one document, so publishing three is one signature and one atomic broadcast. After a redeploy, `tx -all`. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/blog dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/blog/deps.png) > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4blog.gno
  5. #5// Package blog is moul's personal blog on gno.land: one realm, one author, // posts that live in chain storage rather than in the code. // // # The shape // // A post is markdown plus four fields (slug, title, date, tags). Writing one // is a single Set transaction, so publishing never redeploys the realm and // fixing a typo costs one small call instead of a new package path. // // The reader-facing paths are deliberately short, because they get pasted into // other people's chats: // // gno.land/r/moul/blog the index, newest first // gno.land/r/moul/blog:<slug> one post // gno.land/r/moul/blog:t/<tag> every post carrying a tag // // # Ordering without sort // // gno has no sort.Slice, and avl.Tree iterates by key. So posts live in two // trees: `posts` keyed by slug, for the O(log n) lookup a permalink needs, and // `order` keyed by "<date>/<slug>", walked in reverse for newest-first. The // date is YYYY-MM-DD precisely so that lexical order is chronological order, // which is what makes the second tree free. // // # Versioning // // gnomod.toml declares private = true, which lets the creator re-add the // package at this exact path. That redeploy re-runs init() and WIPES every // post here, so the markdown kept off chain is the source of truth and // tools/gnoblog pushes it back. Trading that risk for a stable URL is the // whole design: a /v1 would break every link already shared, a redeploy does // not, and Manifest() makes restoring the content one diff. package blog import ( "crypto/sha256" "encoding/hex" "strconv" "strings" "chain/runtime" "gno.land/p/nt/avl/v0" ) // author is the only address allowed to write. Deliberately a constant rather // than transferable ownership: this is one person's blog, and the same address // is the only one the chain lets redeploy the realm anyway, so a second owner // could publish posts it could never restore after a wipe. const author = address("g1manfred47kzduec920z88wfr64ylksmdcedlf5") // realmPath is this realm's own path, used to build the links Render emits. const realmPath = "gno.land/r/moul/blog" // maxSlugLen bounds a slug so a permalink stays typable and a key stays cheap. const maxSlugLen = 64 // excerptLen is how much of a post body the index shows. const excerptLen = 200 // introKey is the manifest key of the index header. It starts with '!', which // validSlug rejects, so it can never collide with a post. const introKey = "!intro" // introSlug is refused as a post slug. Not because this realm needs it: the // intro lives in its own variable. It is refused so that the local half stays // representable, where a post is <slug>.md and the header is intro.md. A post // published at this slug by a hand-written transaction would have no file it // could live in, and tools/gnoblog would report it as an extra forever. const introSlug = "intro" type post struct { slug string title string date string // YYYY-MM-DD, the sort key tags string // comma-separated, normalized by splitTags body string // raw markdown, authored by `author`, rendered unescaped rev int // rev at the time of the last write to this post updated int64 // block height of the last write } var ( posts = avl.NewTree() // slug -> *post order = avl.NewTree() // "<date>/<slug>" -> *post intro string // markdown shown above the index rev int // total writes accepted, bumped by every mutation ) func assertAuthor(cur realm) { if cur.Previous().Address() != author { panic("restricted to the author") } } // validSlug reports whether slug is a legal permalink: 1..maxSlugLen bytes of // [a-z0-9._-]. The character set excludes ':' and '/' so a slug can never // break out of its own render path, and excludes uppercase so a post has // exactly one URL. func validSlug(slug string) bool { if len(slug) == 0 || len(slug) > maxSlugLen { return false } for i := 0; i < len(slug); i++ { c := slug[i] switch { case c >= 'a' && c <= 'z', c >= '0' && c <= '9': case c == '-', c == '_', c == '.': default: return false } } return true } // validDate reports whether date is exactly YYYY-MM-DD. The format is not // cosmetic: `order` relies on lexical order being chronological order, so a // date of any other shape would silently sort into the wrong place. func validDate(date string) bool { if len(date) != 10 { return false } for i := 0; i < 10; i++ { c := date[i] if i == 4 || i == 7 { if c != '-' { return false } continue } if c < '0' || c > '9' { return false } } return true } // orderKey is the key a post takes in `order`. The slug is appended so two // posts published on the same day do not collide. func orderKey(date, slug string) string { return date + "/" + slug } // splitTags normalizes a comma-separated tag list: trimmed, lowercased, // deduplicated, empties dropped. It returns the canonical string that gets // stored, so the hash a client computes locally matches the one on chain. func splitTags(tags string) []string { var out []string for _, t := range strings.Split(tags, ",") { t = strings.ToLower(strings.TrimSpace(t)) if t == "" { continue } dup := false for _, seen := range out { if seen == t { dup = true break } } if !dup { out = append(out, t) } } return out } func joinTags(tags []string) string { return strings.Join(tags, ",") } // record is the canonical serialization a post hashes to. Both this realm and // tools/gnoblog build it the same way, which is what lets one Manifest() read // decide whether a local file needs a transaction at all. func record(title, date, tags, body string) string { return title + "\n" + date + "\n" + tags + "\n" + body } func hashOf(s string) string { sum := sha256.Sum256([]byte(s)) return hex.EncodeToString(sum[:]) } // Set creates or replaces the post at slug. This is the ordinary publish: one // post, one transaction, idempotent. // // Changing the date moves the post in the index, so the old order entry is // removed before the new one is written. func Set(cur realm, slug, title, date, tags, body string) { assertAuthor(cur) if !validSlug(slug) { panic("invalid slug: want 1-" + strconv.Itoa(maxSlugLen) + " bytes of [a-z0-9._-], got " + strconv.Quote(slug)) } if slug == introSlug { panic("reserved slug: " + introSlug + " names the index header, set it with SetIntro") } if !validDate(date) { panic("invalid date: want YYYY-MM-DD, got " + strconv.Quote(date)) } title = strings.TrimSpace(title) if title == "" { panic("a post needs a title") } if v := posts.Get(slug); v != nil { order.Remove(orderKey(v.(*post).date, slug)) } rev++ p := &post{ slug: slug, title: title, date: date, tags: joinTags(splitTags(tags)), body: body, rev: rev, updated: runtime.ChainHeight(), } posts.Set(slug, p) order.Set(orderKey(date, slug), p) } // Append adds to the end of a post's body. It exists for a body too large to // fit in one transaction: Set the first chunk, Append the rest. An ordinary // edit uses Set, which is idempotent and therefore safe to retry. func Append(cur realm, slug, body string) { assertAuthor(cur) v := posts.Get(slug) if v == nil { panic("no such post: " + strconv.Quote(slug)) } p := v.(*post) rev++ p.body += body p.rev = rev p.updated = runtime.ChainHeight() } // Delete removes a post and its index entry. func Delete(cur realm, slug string) { assertAuthor(cur) v, removed := posts.Remove(slug) if !removed { panic("no such post: " + strconv.Quote(slug)) } order.Remove(orderKey(v.(*post).date, slug)) rev++ } // SetIntro replaces the markdown shown above the index. Passing "" restores // the built-in header. func SetIntro(cur realm, body string) { assertAuthor(cur) rev++ intro = body } // Get returns a post's raw markdown body, or "" when there is no such post. func Get(slug string) string { v := posts.Get(slug) if v == nil { return "" } return v.(*post).body } // Count returns the number of published posts. func Count() int { return posts.Size() } // Revision returns the total number of writes this realm has accepted. It // changes on every mutation, so a client can cheaply tell "nothing moved". func Revision() int { return rev } // Manifest returns one tab-separated line per post, plus one for the intro: // // <slug>\t<rev>\t<date>\t<len of body>\t<sha256 of the canonical record> // // It is the diff surface tools/gnoblog reads with a single vm/qeval: the hash // covers the title, date, tags and body together, so one read says exactly // which local file needs a transaction without downloading a single body. // // The intro is reported under the key "!intro" with an empty date. '!' is not // a legal slug character, so that row can never be confused with a post. func Manifest() string { var b strings.Builder b.WriteString(introKey) b.WriteString("\t0\t\t") b.WriteString(strconv.Itoa(len(intro))) b.WriteString("\t") b.WriteString(hashOf(intro)) b.WriteString("\n") posts.Iterate("", "", func(key string, value any) bool { p := value.(*post) b.WriteString(p.slug) b.WriteString("\t") b.WriteString(strconv.Itoa(p.rev)) b.WriteString("\t") b.WriteString(p.date) b.WriteString("\t") b.WriteString(strconv.Itoa(len(p.body))) b.WriteString("\t") b.WriteString(hashOf(record(p.title, p.date, p.tags, p.body))) b.WriteString("\n") return false }) return b.String() }
  6. #6blog_test.gno
  7. #7package blog import ( "strings" "testing" "gno.land/p/nt/avl/v0" "gno.land/p/nt/uassert/v0" ) // reset empties every package-level variable. ExampleRender runs after every // Test in this package and would otherwise render whatever they left behind, // so both the tests and the example start from here. func reset() { posts = avl.NewTree() order = avl.NewTree() intro = "" rev = 0 } // seed writes a post without going through Set, so a caller needs no // `cur realm` and the example stays a plain function. func seed(slug, title, date, tags, body string) { rev++ p := &post{ slug: slug, title: title, date: date, tags: joinTags(splitTags(tags)), body: body, rev: rev, } posts.Set(slug, p) order.Set(orderKey(date, slug), p) } func TestValidSlug(t *testing.T) { for _, tc := range []struct { slug string want bool }{ {"mygnoscan", true}, {"a", true}, {"hello-world.2", true}, {"under_score", true}, {"", false}, {"Caps", false}, {"has space", false}, {"colon:here", false}, {"slash/here", false}, {"!intro", false}, {strings.Repeat("a", maxSlugLen), true}, {strings.Repeat("a", maxSlugLen+1), false}, } { uassert.Equal(t, tc.want, validSlug(tc.slug), "validSlug("+tc.slug+")") } } func TestValidDate(t *testing.T) { for _, tc := range []struct { date string want bool }{ {"2026-09-23", true}, {"0000-00-00", true}, // shape only: ordering is all this field is for {"2026-9-23", false}, {"2026/09/23", false}, {"2026-09-233", false}, {"", false}, } { uassert.Equal(t, tc.want, validDate(tc.date), "validDate("+tc.date+")") } } func TestSplitTagsNormalizes(t *testing.T) { for _, tc := range []struct{ in, want string }{ {"", ""}, {"gno", "gno"}, {" Gno , TOOLING ", "gno,tooling"}, {"gno,gno,gno", "gno"}, {"gno,,tooling", "gno,tooling"}, } { uassert.Equal(t, tc.want, joinTags(splitTags(tc.in)), "splitTags("+tc.in+")") } } // A post published earlier must render below one published later, which is // the only thing the second tree exists to guarantee. func TestIndexIsNewestFirst(t *testing.T) { reset() seed("older", "Older", "2026-01-01", "", "First.") seed("newer", "Newer", "2026-09-23", "", "Second.") out := Render("") iNew := strings.Index(out, "Newer") iOld := strings.Index(out, "Older") uassert.True(t, iNew >= 0 && iOld >= 0, "both posts render") uassert.True(t, iNew < iOld, "the newer post renders first") } // Re-dating a post has to move it, not leave a ghost entry behind at the old // key. Set is the only path that can do this, so it is checked through Set. func TestRedatingMovesTheIndexEntry(t *testing.T) { reset() seed("a", "A", "2026-01-01", "", "Body A.") uassert.Equal(t, 1, order.Size(), "one order entry") // Emulate Set's re-key without needing a realm: remove then re-add. p := posts.Get("a").(*post) order.Remove(orderKey(p.date, "a")) p.date = "2026-12-31" order.Set(orderKey(p.date, "a"), p) uassert.Equal(t, 1, order.Size(), "still one order entry, not two") uassert.True(t, strings.Contains(Render(""), "2026-12-31"), "the new date renders") } func TestRenderPostAndNotFound(t *testing.T) { reset() seed("mygnoscan", "Sharing mygnoscan", "2026-09-23", "mygnoscan,tooling", "## Why\n\nBecause it is useful.") out := Render("mygnoscan") uassert.True(t, strings.Contains(out, "# Sharing mygnoscan"), "title renders as the H1") uassert.True(t, strings.Contains(out, "Because it is useful."), "body renders verbatim") uassert.True(t, strings.Contains(out, "## Why"), "markdown in the body is not escaped") missing := Render("nope") uassert.True(t, strings.Contains(missing, "Not found"), "an unknown slug is a 404, not the index") } func TestRenderTagFiltersAndReportsEmpty(t *testing.T) { reset() seed("a", "A", "2026-01-01", "tooling", "Body A.") seed("b", "B", "2026-01-02", "chain", "Body B.") out := Render("t/tooling") uassert.True(t, strings.Contains(out, "A"), "the tagged post is listed") uassert.False(t, strings.Contains(out, "](/r/moul/blog:b)"), "the untagged post is not") empty := Render("t/nothing") uassert.True(t, strings.Contains(empty, "No post carries this tag."), "an unused tag says so") } // The manifest is the whole diff protocol. A change to any of the four fields // the record covers has to change the hash, or gnoblog reports a stale post as // up to date and the fix never gets pushed. func TestManifestHashCoversEveryField(t *testing.T) { base := record("T", "2026-01-01", "a", "body") for _, tc := range []struct { name string got string }{ {"title", record("T2", "2026-01-01", "a", "body")}, {"date", record("T", "2026-01-02", "a", "body")}, {"tags", record("T", "2026-01-01", "b", "body")}, {"body", record("T", "2026-01-01", "a", "body2")}, } { uassert.NotEqual(t, hashOf(base), hashOf(tc.got), "changing the "+tc.name+" changes the hash") } } func TestManifestShape(t *testing.T) { reset() seed("a", "A", "2026-01-01", "tooling", "Body A.") lines := strings.Split(strings.TrimRight(Manifest(), "\n"), "\n") uassert.Equal(t, 2, len(lines), "one intro row plus one post row") uassert.True(t, strings.HasPrefix(lines[0], introKey+"\t"), "the intro row comes first") f := strings.Split(lines[1], "\t") uassert.Equal(t, 5, len(f), "five tab-separated fields") uassert.Equal(t, "a", f[0], "slug") uassert.Equal(t, "2026-01-01", f[2], "date") uassert.Equal(t, "7", f[3], "body length") uassert.Equal(t, hashOf(record("A", "2026-01-01", "tooling", "Body A.")), f[4], "record hash") } func TestFirstLineSkipsHeadings(t *testing.T) { uassert.Equal(t, "The prose.", firstLine("# Title\n\n## Sub\n\nThe prose.\n")) uassert.Equal(t, "", firstLine("# Only a heading\n")) } func TestIntroDefaultsAndOverrides(t *testing.T) { reset() uassert.True(t, strings.Contains(Render(""), "moul's blog"), "the built-in header shows before SetIntro") intro = "# Something else" uassert.True(t, strings.Contains(Render(""), "Something else"), "a set intro replaces it") uassert.False(t, strings.Contains(Render(""), "moul's blog"), "and the default is gone") }
  8. #8gnomod.toml
  9. #9module = "gno.land/r/moul/blog" gno = "0.9" # Redeployable by its creator. See README.md § "Why private, and why no /vN". # # A private package may be re-added at the same path by the address recorded in # [addpkg].creator (gno.land/pkg/sdk/vm/keeper.go, checkRedeployPermission), and # nothing else may import it. A redeploy RE-RUNS init() and resets realm state, # so the markdown kept off chain is the source of truth and tools/gnoblog pushes # every post back. That wipe is the reason the post URL can stay version-free: # a code change is a redeploy of this path, never a /v1 that moves every link # already shared. private = true
  10. #10render.gno
  11. #11package blog import ( "strconv" "strings" "gno.land/p/moul/kit/ui/v0" ) // linkPrefix is what a permalink starts with. gnoweb addresses a realm's // render path with ':' after the package path, so this is realmPath with the // leading "gno.land" dropped and the separator appended. const linkPrefix = "/r/moul/blog:" // defaultIntro is the header shown before SetIntro has ever been called, i.e. // right after the first deploy or after a redeploy wiped the state. const defaultIntro = "# moul's blog\n\nNotes on gno.land, from the person building on it.\n" // Render serves three views: // // "" the index, newest first // "<slug>" one post // "t/<tag>" every post carrying a tag func Render(path string) string { path = strings.TrimSpace(path) switch { case path == "": return renderIndex() case strings.HasPrefix(path, "t/"): return renderTag(strings.TrimPrefix(path, "t/")) default: return renderPost(path) } } func header() string { if intro == "" { return defaultIntro } return strings.TrimRight(intro, "\n") + "\n" } func renderIndex() string { var b strings.Builder b.WriteString(header()) if posts.Size() == 0 { b.WriteString("\n") b.WriteString(ui.Empty("Nothing published yet.")) return b.String() } // order is keyed by "<date>/<slug>", so walking it backwards is // newest-first without a sort the language does not have. order.ReverseIterate("", "", func(_ string, value any) bool { b.WriteString("\n") b.WriteString(entry(value.(*post))) return false }) return b.String() } func renderTag(tag string) string { tag = strings.ToLower(strings.TrimSpace(tag)) var b strings.Builder b.WriteString("# Tagged ") b.WriteString(ui.Inline(tag)) b.WriteString("\n") found := 0 order.ReverseIterate("", "", func(_ string, value any) bool { p := value.(*post) if !hasTag(p, tag) { return false } found++ b.WriteString("\n") b.WriteString(entry(p)) return false }) if found == 0 { b.WriteString("\n") b.WriteString(ui.Empty("No post carries this tag.")) } b.WriteString("\n[← all posts](" + linkPrefix + ")\n") return b.String() } func renderPost(slug string) string { v := posts.Get(slug) if v == nil { return "# Not found\n\nNo post at " + strconv.Quote(ui.Inline(slug)) + ".\n\n[← all posts](" + linkPrefix + ")\n" } p := v.(*post) var b strings.Builder b.WriteString("# ") b.WriteString(ui.Inline(p.title)) b.WriteString("\n\n") b.WriteString(byline(p)) b.WriteString("\n\n") // The body is markdown written by `author`, who is also the only address // that can call Set. It is the post, so it renders verbatim: escaping it // would turn every heading and link in it into literal text. b.WriteString(strings.TrimRight(p.body, "\n")) b.WriteString("\n\n---\n\n[← all posts](" + linkPrefix + ")\n") return b.String() } // entry is one row of a listing: linked title, byline, excerpt. func entry(p *post) string { var b strings.Builder b.WriteString("## [") b.WriteString(ui.Inline(p.title)) b.WriteString("](") b.WriteString(linkPrefix) b.WriteString(p.slug) b.WriteString(")\n\n") b.WriteString(byline(p)) b.WriteString("\n\n") b.WriteString(ui.Excerpt(firstLine(p.body), excerptLen)) b.WriteString("\n") return b.String() } // byline is the date plus the post's tags as links, on one line. func byline(p *post) string { var b strings.Builder b.WriteString(p.date) for _, t := range splitTags(p.tags) { b.WriteString(" · [#") b.WriteString(ui.Inline(t)) b.WriteString("](") b.WriteString(linkPrefix) b.WriteString("t/") b.WriteString(t) b.WriteString(")") } return b.String() } func hasTag(p *post, tag string) bool { for _, t := range splitTags(p.tags) { if t == tag { return true } } return false } // firstLine returns the first non-empty, non-heading line of a body, which is // what an excerpt should show: a post that opens with "## Something" would // otherwise preview its own heading. func firstLine(body string) string { for _, line := range strings.Split(body, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } return line } return "" }
  12. #12render_example_test.gno
  13. #13package blog // ExampleRender pins the index. It seeds state directly (rather than through // Set) so the example takes no `cur realm`, and it resets first because an // example runs after every Test in the package and would otherwise render what // they left behind. func ExampleRender() { reset() seed("mygnoscan", "Sharing mygnoscan", "2026-09-23", "mygnoscan,tooling", "An unfinished BI tool for gno.land.") seed("hello", "Hello", "2026-09-01", "meta", "Starting a blog on chain.") print(Render("")) // Output: // # moul's blog // // Notes on gno.land, from the person building on it. // // ## [Sharing mygnoscan](/r/moul/blog:mygnoscan) // // 2026-09-23 · [#mygnoscan](/r/moul/blog:t/mygnoscan) · [#tooling](/r/moul/blog:t/tooling) // // An unfinished BI tool for gno\.land\. // // ## [Hello](/r/moul/blog:hello) // // 2026-09-01 · [#meta](/r/moul/blog:t/meta) // // Starting a blog on chain\. }
  14. #14write_test.gno
  15. #15package blog import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var stranger = testutils.TestAddress("stranger") // Everything here goes through the real crossing functions rather than seed(), // because the author check, the re-key on a date change and the panic messages // only exist on that path and are exactly what a typo would break on a // permanent path. func TestSetPublishesAndRenders(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(author)) Set(cross(cur), "hello", "Hello", "2026-09-23", "Gno, tooling", "The body.") uassert.Equal(t, 1, Count(), "one post") uassert.Equal(t, "The body.", Get("hello"), "the body round-trips") uassert.True(t, strings.Contains(Render("hello"), "# Hello"), "it renders") // Tags are normalized on write, not on read, so the hash the tool computed // locally is the hash the manifest reports. uassert.True(t, strings.Contains(Manifest(), "\t"+hashOf(record("Hello", "2026-09-23", "gno,tooling", "The body."))), "the manifest hashes the normalized record") } func TestOnlyTheAuthorMayWrite(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(stranger)) // AbortsWithMessage, not PanicsWithMessage: a panic that crosses a realm // boundary becomes an abort, which recover() cannot catch, so asserting it // as a panic does not fail the assertion, it kills the whole test binary. uassert.AbortsWithMessage(t, cur, "restricted to the author", func() { Set(cross(cur), "nope", "Nope", "2026-09-23", "", "Body.") }) uassert.AbortsWithMessage(t, cur, "restricted to the author", func() { SetIntro(cross(cur), "mine now") }) uassert.Equal(t, 0, Count(), "nothing was written") } // The one case a second tree can get wrong: re-dating a post must MOVE its // index entry, not leave a ghost at the old key rendering the post twice. func TestSetRedatingMovesTheIndexEntry(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(author)) Set(cross(cur), "a", "A", "2026-01-01", "", "Body A.") Set(cross(cur), "a", "A", "2026-12-31", "", "Body A.") uassert.Equal(t, 1, Count(), "still one post") out := Render("") uassert.Equal(t, 1, strings.Count(out, "](/r/moul/blog:a)"), "listed exactly once") uassert.True(t, strings.Contains(out, "2026-12-31"), "under the new date") uassert.False(t, strings.Contains(out, "2026-01-01"), "and not the old one") } func TestSetRejectsWhatTheToolRejects(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(author)) uassert.AbortsContains(t, cur, "invalid slug", func() { Set(cross(cur), "Caps", "T", "2026-09-23", "", "Body.") }) uassert.AbortsContains(t, cur, "reserved slug", func() { Set(cross(cur), introSlug, "T", "2026-09-23", "", "Body.") }) uassert.AbortsContains(t, cur, "invalid date", func() { Set(cross(cur), "ok", "T", "23-09-2026", "", "Body.") }) uassert.AbortsContains(t, cur, "a post needs a title", func() { Set(cross(cur), "ok", " ", "2026-09-23", "", "Body.") }) uassert.Equal(t, 0, Count(), "none of them was written") } func TestAppendAndDelete(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(author)) Set(cross(cur), "long", "Long", "2026-09-23", "", "First half.") Append(cross(cur), "long", " Second half.") uassert.Equal(t, "First half. Second half.", Get("long"), "append concatenates, it does not replace") uassert.AbortsWithMessage(t, cur, `no such post: "ghost"`, func() { Append(cross(cur), "ghost", "x") }) Delete(cross(cur), "long") uassert.Equal(t, 0, Count(), "deleted") uassert.True(t, strings.Contains(Render(""), "Nothing published yet."), "and gone from the index") uassert.AbortsWithMessage(t, cur, `no such post: "long"`, func() { Delete(cross(cur), "long") }) } func TestRevisionMovesOnEveryWrite(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(author)) uassert.Equal(t, 0, Revision(), "a fresh realm is at zero") Set(cross(cur), "a", "A", "2026-09-23", "", "Body.") Append(cross(cur), "a", "!") SetIntro(cross(cur), "# Header") Delete(cross(cur), "a") uassert.Equal(t, 4, Revision(), "one per mutation, so a client can tell nothing moved") }
  16. #16/gno.MemPackageType
  17. #17 MPUserAll

Result log

msg:0,success:true,log:,events:[]

← Back to block 271,237