Artifact 1aae1c604c20da131011b002ca0d9280aa1644e9d35024a129d1aaeaa574ab4e:
- File
internal/index/landing.go
— part of check-in
[06942dcc5d]
at
2026-08-24 16:57:41
on branch trunk
— Close the push gap: derived events all come from the fold
The index holds the state an op would have read, so a pushed artifact gets the same event a forge op would have raised. Ticket transitions and landing verdicts compare folded state before and after; forum, wiki and technote need only the artifact or one lookup.
Op sites stop raising derived events, so 2.1's split is now enforced rather than documented. (user: dmestas, size: 12683) [annotate] [blame] [check-ins using]
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 { _, err := tx.Exec(`DELETE FROM landing WHERE id = ?`, 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() 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 } } if err := rows.Err(); err != nil { return err } _, 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) return err } // 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() } // 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 }