Artifact a12505b8306ad019183b2b59d4960611b3ade16ae55a1fb18efa2dc414cef67d:
- File
internal/index/landing.go
— part of check-in
[5d398dd22e]
at
2026-08-26 10:02:50
on branch landing-timeline
— Timeline: landings are landings, not forum posts
Landings are built on forum artifacts, so the forum trigger filed every comment and verdict as kind 'f' with a 'Forum: ' prefix — rows reading 'Forum: zit-landing-state: accepted Accepted. The forge now tells y'. quarry had already decided these are not forum content; the timeline was the surface that missed it.
Verdicts are kind 'l' and appear in a mixed timeline because they change the target branch. Comments are 'lc' and appear only under the new y=l. Both link to the landing rather than the post.
Classified in the fold, not the trigger: the trigger fires before the landing row exists, since the root post is what it is derived from. Backfilled for stores folded before this. (user: dmestas, size: 17248) [annotate] [blame] [check-ins using] [more...]
package index import ( "database/sql" "strings" ) // A landing (zit#127) is a branch proposed to land on a target, carried // entirely by artifacts fossil already understands: the record is a forum // thread whose root body opens with a marker line, every state change is a // reply in that thread, and the outcome is a raw tag on the check-in that // landed. zit mints no card type for it, so there is nothing here to fold // from a new artifact kind — a landing is a READING of forum_post rows. // // That is why this table is derived and recomputed rather than written once // on arrival. Folding is ORDER-INDEPENDENT (0001 §5): a reply can be indexed // before the root it belongs to, a root can arrive after its own acceptance, // and either order must converge on the same row. So any forum post in a // thread recomputes that thread's landing from the forum_post rows present, // instead of trying to patch a row incrementally from the artifact in hand. // // The fold rule matches zit's exactly — latest transition wins, ordered by // (date, hash) — because two readers of the same store disagreeing about a // landing's state is the failure this whole feature exists to avoid. // landingMarker opens the body of a thread root that is a landing record. // Versioned at the source; a body announcing a version this code does not // know is NOT a landing here, which is a refusal to guess rather than an // oversight. Reading it as a landing anyway would mean rendering fields we // only think we recognise. const landingMarker = "zit-landing: 1" // IsLandingBody reports whether a forum post opens a landing. Exported // so the ingest fold can name the event correctly: a landing IS a forum // thread, and calling its opening post forum.posted would be true but // useless — nothing wanting landings could select them. func IsLandingBody(body string) bool { return strings.Contains(body, landingMarker) } // IsLandingStateBody reports whether a post carries a landing verdict. func IsLandingStateBody(body string) bool { return strings.Contains(body, landingStateMarker) } // landingStateMarker opens the body of a reply carrying a state transition. const landingStateMarker = "zit-landing-state:" // landingStates is the closed set. A reply naming anything else is a comment // with an odd first line, not a transition — an unknown state must never // settle a record. var landingStates = map[string]bool{"open": true, "accepted": true, "rejected": true} // parseLandingHeader reads branch and target out of a thread root's body. // ok is false for every ordinary forum post, which is the common case. func parseLandingHeader(body string) (branch, target string, ok bool) { if !strings.HasPrefix(body, landingMarker) { return "", "", false } lines := strings.Split(body, "\n") for _, line := range lines[1:] { if line == "" || line == "\r" { break // the blank line ends the header } switch { case strings.HasPrefix(line, "branch: "): branch = strings.TrimSpace(line[len("branch: "):]) case strings.HasPrefix(line, "target: "): target = strings.TrimSpace(line[len("target: "):]) default: // An unrecognised header line under a version we DO claim to // know is a dialect mismatch. Refuse the record rather than // index a partial reading of it. return "", "", false } } if branch == "" || target == "" { return "", "", false } return branch, target, true } // parseLandingState reads the state a reply carries, or ok=false when the // reply is an ordinary comment. func parseLandingState(body string) (string, bool) { if !strings.HasPrefix(body, landingStateMarker) { return "", false } rest := body[len(landingStateMarker):] if i := strings.IndexByte(rest, '\n'); i >= 0 { rest = rest[:i] } s := strings.TrimSpace(rest) if !landingStates[s] { return "", false } return s, true } // LandingProse is a landing root's body with its header block removed — the // message the author actually wrote. // // The header is ENCODING, and zit's landing module is explicit that the // interface hides it ("if a thread hash leaks into the interface ... this has // become a pass-through wrapper"). A page rendering the raw root body shows // `zit-landing: 1` to a human, which is the encoding leaking through the // interface built to hide it. Stripping it belongs here, beside the parser // that defines where the header ends, rather than in each reader. func LandingProse(body string) string { if !strings.HasPrefix(body, landingMarker) { return body } // The blank line ends the header, exactly as parseLandingHeader reads it. if i := strings.Index(body, "\n\n"); i >= 0 { return strings.TrimLeft(body[i+2:], "\n") } return "" } // notALanding is the clause that keeps landing records off the forum. // // A landing IS a forum thread — that is the compatibility story, and it is // why a stock fossil clone can read one. But it is not forum CONTENT: it is a // decision record with its own surface, its own controls and its own listing, // and a reader scanning discussions has no use for a thread whose first line // is a marker. // // The clause lives here, once, because a listing and the count above it // disagreeing about what a forum holds is the confirmation-leak shape in // miniature: the number would promise a thread the list refuses to show. func notALanding(col string) string { return col + ` NOT IN (SELECT id FROM landing)` } // LandingByPrefix resolves a landing from an id prefix, the way every other // hash in this forge resolves: a prefix matching two records matches neither, // because answering arbitrarily is worse than refusing. func (ix *Index) LandingByPrefix(prefix string) (LandingSummary, bool, error) { rows, err := ix.db.Query(`SELECT id, branch, target, title, author, opened, updated, state, settled_by FROM landing WHERE id LIKE ? || '%' LIMIT 2`, prefix) if err != nil { return LandingSummary{}, false, err } defer rows.Close() var out LandingSummary n := 0 for rows.Next() { n++ if n > 1 { return LandingSummary{}, false, rows.Err() } if err := rows.Scan(&out.ID, &out.Branch, &out.Target, &out.Title, &out.Author, &out.Opened, &out.Updated, &out.State, &out.SettledBy); err != nil { return LandingSummary{}, false, err } } return out, n == 1, rows.Err() } // LandingTransition reports whether a post body is a state transition rather // than a remark, and which state it carries. // // Exported because the API has to tell the two apart to render them // differently: a transition is an EVENT in the record ("dan accepted this"), // the way every forge renders a state change, while a remark is a comment. // Reading the marker in a second place would be a second dialect, so the // classification stays with the fold that defines it. func LandingTransition(body string) (state string, ok bool) { return parseLandingState(body) } // foldLanding recomputes one thread's landing row from the forum_post rows // currently present, and is called for every forum post folded in that // thread. Cheap: one thread is a handful of rows, and the alternative — // patching incrementally — cannot be made order-independent. // // A thread that is not (or is no longer) a landing has its row DELETED, so a // record can never outlive the post it was read from. func foldLanding(tx *sql.Tx, thread string) error { var body, title, user, opened string err := tx.QueryRow(`SELECT body, title, user, date FROM forum_post WHERE hash = ?`, thread).Scan(&body, &title, &user, &opened) if err == sql.ErrNoRows { // The root has not been folded yet. Not an error and not a gap: // whichever post arrives last recomputes the whole thread, so the // row appears as soon as the root does. return nil } if err != nil { return err } branch, target, ok := parseLandingHeader(body) if !ok { if _, err := tx.Exec(`DELETE FROM landing WHERE id = ?`, thread); err != nil { return err } // Whatever this thread is, it is forum content again. Only // reachable by a refold that saw the root differently, but the // re-filing below is not reversible any other way and a one-way // classification is the kind that leaves a store wrong for ever. _, err := tx.Exec(`UPDATE event SET kind = 'f', ref = ?, comment = (SELECT 'Forum: ' || CASE WHEN p.title != '' THEN p.title ELSE substr(p.body, 1, 60) END FROM forum_post p WHERE p.hash = event.hash) WHERE hash IN (SELECT hash FROM forum_post WHERE thread = ?)`, thread, thread) return err } // Ascending, so the LAST transition seen wins — zit's own fold rule, // deliberately mirrored rather than re-derived. rows, err := tx.Query(`SELECT hash, body, date FROM forum_post WHERE thread = ? AND hash <> ? ORDER BY date, hash`, thread, thread) if err != nil { return err } defer rows.Close() // verdicts holds each reply that ANNOUNCES a state, by its own // state rather than the thread's final one: a timeline row says // what happened at that moment, not how the story ended. verdicts := map[string]string{} state, settledBy := "open", "" updated := opened for rows.Next() { var hash, replyBody, date string if err := rows.Scan(&hash, &replyBody, &date); err != nil { return err } if date > updated { updated = date } if s, ok := parseLandingState(replyBody); ok { state, settledBy = s, hash verdicts[hash] = s } } if err := rows.Err(); err != nil { return err } if _, err := tx.Exec(`INSERT OR REPLACE INTO landing (id, branch, target, title, author, opened, updated, state, settled_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, thread, branch, target, title, user, opened, updated, state, settledBy); err != nil { return err } return refileLandingEvents(tx, thread, title, verdicts) } // refileLandingEvents moves a landing's posts off the forum kind. // // A landing IS a forum thread, so the forum trigger files every post in // one as kind 'f' with a "Forum: " prefix — which produced timeline // rows reading "Forum: zit-landing-state: accepted Accepted. The forge // now tells y", a prefix over a marker over a truncated body. quarry // had already decided these are not forum content: notALanding keeps // them out of the forum listing and its count. The timeline was the // surface that missed the memo, and surfaces disagreeing about what a // forum holds is the same shape as a count promising a thread the list // refuses to show. // // This runs HERE, not in the trigger, because the trigger fires while // the forum post is being inserted and the landing row does not exist // yet — the root post is what the landing row is derived FROM. Any post // in a thread recomputes the record, so classification converges no // matter which order a clone delivers them in. // // Verdicts are 'l' and plain comments are 'lc'. A verdict changes the // target branch and belongs in a mixed timeline; a comment is // conversation, and three landings produced six rows of it. func refileLandingEvents(tx *sql.Tx, thread, title string, verdicts map[string]string) error { hashes, err := tx.Query(`SELECT hash FROM forum_post WHERE thread = ?`, thread) if err != nil { return err } defer hashes.Close() var posts []string for hashes.Next() { var h string if err := hashes.Scan(&h); err != nil { return err } posts = append(posts, h) } if err := hashes.Err(); err != nil { return err } for _, h := range posts { kind, label := "lc", "Comment on landing: "+title switch { case h == thread: kind, label = "l", "Landing: "+title case verdicts[h] != "": kind, label = "l", "Landing "+verdicts[h]+": "+title } if _, err := tx.Exec(`UPDATE event SET kind = ?, ref = ?, comment = ? WHERE hash = ?`, kind, thread, label, h); err != nil { return err } } return nil } // backfillLandings populates the table for indexes written before it // existed, for the reason backfillTickets gives: the fold only writes it on // newly arriving posts, so an upgraded index would show an EMPTY landing list // until someone reindexed a store whose forum_post rows are already complete. // // Once, at open, and only when there is nothing to lose: a non-empty table is // left alone. func (ix *Index) backfillLandings() error { var landings, posts int if err := ix.db.QueryRow(`SELECT count(*) FROM landing`).Scan(&landings); err != nil { return err } if err := ix.db.QueryRow(`SELECT count(*) FROM forum_post`).Scan(&posts); err != nil { return err } if landings > 0 || posts == 0 { return nil } rows, err := ix.db.Query(`SELECT hash FROM forum_post WHERE hash = thread AND body LIKE ?`, landingMarker+"%") if err != nil { return err } var roots []string for rows.Next() { var h string if err := rows.Scan(&h); err != nil { rows.Close() return err } roots = append(roots, h) } rows.Close() if err := rows.Err(); err != nil { return err } if len(roots) == 0 { return nil } // The LIKE above is a cheap prefilter; parseLandingHeader inside // foldLanding is the real gate, so a body that merely starts with the // marker but carries an unknown header is still refused. tx, err := ix.db.Begin() if err != nil { return err } defer tx.Rollback() for _, root := range roots { if err := foldLanding(tx, root); err != nil { return err } } return tx.Commit() } // backfillLandingEvents re-files landing posts that an older index filed // as forum events. // // The classification moved from the forum trigger into the fold, so a // store folded before that change carries "Forum: " rows for every // landing comment and verdict it already holds. Without this, the // timeline stays wrong until somebody happens to reindex — and derived // data that is only correct after a manual step is not derived data, // it is a chore. // // Idempotent and cheap to skip: the count is an indexed lookup, and a // store with nothing to fix pays only for that. func (ix *Index) backfillLandingEvents() error { var stale int if err := ix.db.QueryRow(`SELECT count(*) FROM event e JOIN forum_post p ON p.hash = e.hash WHERE e.kind = 'f' AND p.thread IN (SELECT id FROM landing)`).Scan(&stale); err != nil { return err } if stale == 0 { return nil } rows, err := ix.db.Query(`SELECT id FROM landing`) if err != nil { return err } var roots []string for rows.Next() { var h string if err := rows.Scan(&h); err != nil { rows.Close() return err } roots = append(roots, h) } rows.Close() if err := rows.Err(); err != nil { return err } tx, err := ix.db.Begin() if err != nil { return err } defer tx.Rollback() // foldLanding rather than refileLandingEvents directly: it is the // one place that decides which reply is a verdict, and a backfill // that re-derived that rule beside it would be a second answer to // the same question. for _, root := range roots { if err := foldLanding(tx, root); err != nil { return err } } return tx.Commit() } // LandingSummary is the list-surface row: everything the queue and the // detail header need, without a second query or a DAG walk. type LandingSummary struct { ID string `json:"id"` Branch string `json:"branch"` Target string `json:"target"` Title string `json:"title"` Author string `json:"author"` Opened string `json:"opened"` Updated string `json:"updated"` State string `json:"state"` // SettledBy is the reply that decided State; empty while open. SettledBy string `json:"settled_by,omitempty"` } // Landings lists landings, newest activity first. state filters to one of // open/accepted/rejected; empty returns every landing. // // limit of 0 means no limit — the caller is a page that already knows how // many rows it wants, and a silent default would truncate a listing that // looked complete. func (ix *Index) Landings(state string, limit int) ([]LandingSummary, error) { q := `SELECT id, branch, target, title, author, opened, updated, state, settled_by FROM landing` var args []any if state != "" { q += ` WHERE state = ?` args = append(args, state) } q += ` ORDER BY updated DESC, id DESC` if limit > 0 { q += ` LIMIT ?` args = append(args, limit) } rows, err := ix.db.Query(q, args...) if err != nil { return nil, err } defer rows.Close() var out []LandingSummary for rows.Next() { var l LandingSummary if err := rows.Scan(&l.ID, &l.Branch, &l.Target, &l.Title, &l.Author, &l.Opened, &l.Updated, &l.State, &l.SettledBy); err != nil { return nil, err } out = append(out, l) } return out, rows.Err() } // Landing returns one landing by id, or ok=false when no such record is // indexed. Prefix matching belongs to the caller's hash resolution, not // here: this table's key is the full thread-root hash. func (ix *Index) Landing(id string) (LandingSummary, bool, error) { var l LandingSummary err := ix.db.QueryRow(`SELECT id, branch, target, title, author, opened, updated, state, settled_by FROM landing WHERE id = ?`, id).Scan(&l.ID, &l.Branch, &l.Target, &l.Title, &l.Author, &l.Opened, &l.Updated, &l.State, &l.SettledBy) if err == sql.ErrNoRows { return LandingSummary{}, false, nil } if err != nil { return LandingSummary{}, false, err } return l, true, nil }