Artifact f7dcfdb8aa56d1c6416522401ed6a7aaba95501b0a807bbebac31eb576a72597:
- File
internal/index/landing.go
— part of check-in
[c2930a1351]
at
2026-08-26 12:53:49
on branch landings-batch
— One open landing per branch: refuse the second, and agree which wins
zit#127 left this as 'probably yes, refused by name on the second' and neither side enforced it, so two threads naming one branch produced two open landings with nothing saying which accept should honour.
Both halves, because refusing is not enough: two peers can each open one in separate clones and both are valid artifacts when they meet. landing.open refuses by name; the fold picks the newest root as current and marks the rest, recomputed each fold so a record revives if the one that displaced it is settled.
Settled landings never take part — a later proposal for the same branch is a new proposal, not a correction of a finished one. Nothing is deleted; a record somebody argued in is not noise. (user: dmestas, size: 28644) [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, superseded_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, &out.SupersededBy); 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 } if err := refileLandingEvents(tx, thread, title, verdicts); err != nil { return err } return supersedeLandings(tx, branch) } // supersedeLandings decides which of several open landings on one // branch is the current one. // // zit#127 left this as "probably yes, refused by name on the second" // and neither side enforces it. Refusing at open is the courtesy and it // belongs in the writer; it cannot be the whole answer, because two // people can open a landing for one branch in separate clones and both // are ordinary forum threads that are valid artifacts when they meet. // Sync makes prevention unenforceable, so the readers have to agree. // // The newest root wins, by (opened, id). Newest rather than oldest // because a second landing is the author saying something about the // branch NOW, and because it is the rule the state fold already uses — // two folds in one file disagreeing about which artifact supersedes // which is a worse thing to own than either rule. // // SETTLED landings never take part. An accepted landing is history and // its check-in is on the target; a later proposal for the same branch // is a new proposal, not a correction of a finished one. // // The losers keep their rows and their discussion — nothing is deleted, // because a record somebody argued in is not noise. They are marked, so // the queue can show one open landing per branch and the page can say // where the conversation moved. func supersedeLandings(tx *sql.Tx, branch string) error { if branch == "" { return nil } rows, err := tx.Query(`SELECT id FROM landing WHERE branch = ? AND state = 'open' ORDER BY opened DESC, id DESC`, branch) if err != nil { return err } var open []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { rows.Close() return err } open = append(open, id) } rows.Close() if err := rows.Err(); err != nil { return err } if len(open) == 0 { return nil } // Recomputed from scratch every fold, never accumulated: a landing // that was superseded and then settled elsewhere must be able to // become current again when the winner is settled or withdrawn. winner := open[0] for _, id := range open { to := winner if id == winner { to = "" } if _, err := tx.Exec(`UPDATE landing SET superseded_by = ? WHERE id = ?`, to, id); err != nil { return err } } // A settled landing is never superseded by an open one. _, err = tx.Exec(`UPDATE landing SET superseded_by = '' WHERE branch = ? AND state <> 'open' AND superseded_by <> ''`, branch) return err } // 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() } // OpenLandingOn reports the current open landing for a branch, if // there is one. The courtesy check a writer makes before opening a // second: see supersedeLandings for why it cannot be the whole answer. func (ix *Index) OpenLandingOn(branch string) (LandingSummary, bool, error) { var l LandingSummary err := ix.db.QueryRow(`SELECT id, branch, target, title, author, opened, updated, state, settled_by, superseded_by FROM landing WHERE branch = ? AND state = 'open' AND superseded_by = '' ORDER BY opened DESC, id DESC LIMIT 1`, branch).Scan(&l.ID, &l.Branch, &l.Target, &l.Title, &l.Author, &l.Opened, &l.Updated, &l.State, &l.SettledBy, &l.SupersededBy) if err == sql.ErrNoRows { return LandingSummary{}, false, nil } if err != nil { return LandingSummary{}, false, err } return l, true, nil } // BranchCommit is one check-in a landing proposes. type BranchCommit struct { Hash string `json:"hash"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` } // BranchCommits returns a branch's own check-ins in ANCESTRY order, // parents before children. // // The landing page used to list them through the timeline with a branch // filter, which orders by (date DESC, hash DESC). Two check-ins made in // the same second tie on date and then order by hash, which has nothing // to do with which is the parent — so a branch's own commits rendered // in an order that is arbitrary with respect to ancestry. Commits land // back-to-back routinely here, so this is not a rare shape, and it is // exactly the quiet kind of wrong a reviewer would trust. // // zit hit the same bug in `landing show` and its fix is the one worth // inheriting: stop hand-rolling the order. The verb owns no ancestry // ordering of its own; the DAG does. // // So this walks parents from the branch tip and orders by depth. A // check-in's depth is its LONGEST path back from the tip, which is what // makes a parent always deeper than its child even where the branch // forked and rejoined. Dates break ties between unrelated commits at // the same depth, where any order is as good as another, and never // between a parent and its child. // // The walk stops where the branch does: a parent on another branch is // the point the proposal diverged, and everything beyond it is shared // history the target already has. func (ix *Index) BranchCommits(branch string, limit int) ([]BranchCommit, error) { if branch == "" { return nil, nil } if limit <= 0 { limit = 50 } tip, err := ix.branchTip(branch) if err != nil || tip == "" { return nil, err } rows, err := ix.db.Query(` WITH RECURSIVE walk(hash, depth) AS ( SELECT ?, 0 UNION SELECT p.parent, w.depth + 1 FROM checkin_parent p JOIN walk w ON p.child = w.hash JOIN checkin c ON c.hash = p.parent WHERE c.branch = ? ) SELECT c.hash, c.date, c.user, c.comment, max(w.depth) AS d FROM walk w JOIN checkin c ON c.hash = w.hash WHERE c.branch = ? GROUP BY c.hash ORDER BY d DESC, c.date, c.hash LIMIT ?`, tip, branch, branch, limit) if err != nil { return nil, err } defer rows.Close() var out []BranchCommit for rows.Next() { var c BranchCommit var depth int if err := rows.Scan(&c.Hash, &c.Date, &c.User, &c.Comment, &depth); err != nil { return nil, err } out = append(out, c) } return out, rows.Err() } // branchTip is the check-in on a branch that nothing else on that // branch calls a parent. // // Not "the newest by date", which is the same mistake one level up: // two tips tying on a second would pick by hash. A tip is a structural // property — a leaf within the branch — and only a branch that genuinely // forked has more than one, where the newest is the honest guess. func (ix *Index) branchTip(branch string) (string, error) { var tip string err := ix.db.QueryRow(`SELECT c.hash FROM checkin c WHERE c.branch = ? AND NOT EXISTS ( SELECT 1 FROM checkin_parent p JOIN checkin k ON k.hash = p.child WHERE p.parent = c.hash AND k.branch = ?) ORDER BY c.date DESC, c.hash DESC LIMIT 1`, branch, branch).Scan(&tip) if err == sql.ErrNoRows { return "", nil } return tip, err } // LandingMerge is the check-in a landing produced. type LandingMerge struct { Checkin string `json:"checkin"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` } // MergesForLandings resolves each landing to the check-in that merged // it, for the landings named. Landings that never landed are absent. // // landing.settled_by holds the reply that DECIDED the state — a forum // post hash, not a commit — so it was read as meaning nothing connects // a landing to the check-in it produced, and the durable fix was // assumed to be upstream. It is not: the answer is already in the DAG. // A merge check-in's P card lists its primary parent first and the // branches it merged after, so ord > 0 IS the branch tip that landed, // and the landing knows which branch it proposed. // // That is a structural fact zit wrote when it merged, not a guess. // Matching on comment text would have been a guess — those matched only // because the accept message and the merge message were written the // same way by habit — and branch-plus-time is a heuristic that is // eventually wrong. // // The search is bounded by when the landing was OPENED, not when it was // accepted. The merge lands before the state artifact recording it is // written — pager-shape merged at 12:46:55 and its verdict was filed at // 12:49:51 — so a verdict bound silently loses exactly the check-ins // this exists to find. A proposal cannot be landed before it is made, // which is the bound that actually holds. // // The remaining ambiguity is a branch landed more than once; the // earliest qualifying merge wins, which is the one that first landing // produced. func (ix *Index) MergesForLandings(ids []string) (map[string]LandingMerge, error) { out := map[string]LandingMerge{} if len(ids) == 0 { return out, nil } stmt, err := ix.db.Prepare(`SELECT c.hash, c.date, c.user, c.comment FROM checkin c JOIN checkin_parent p ON p.child = c.hash AND p.ord > 0 JOIN checkin tip ON tip.hash = p.parent WHERE tip.branch = ? AND c.branch = ? AND c.date >= ? ORDER BY c.date, c.hash LIMIT 1`) if err != nil { return nil, err } defer stmt.Close() for _, id := range ids { var branch, target, opened string err := ix.db.QueryRow(`SELECT l.branch, l.target, l.opened FROM landing l WHERE l.id = ? AND l.state = 'accepted'`, id).Scan(&branch, &target, &opened) if err == sql.ErrNoRows { continue // open, rejected, or unknown: nothing landed } if err != nil { return nil, err } var m LandingMerge err = stmt.QueryRow(branch, target, opened).Scan(&m.Checkin, &m.Date, &m.User, &m.Comment) if err == sql.ErrNoRows { continue } if err != nil { return nil, err } out[id] = m } return out, nil } // attachLandings decorates the MERGE check-ins in a timeline page with // the landing each one settled, and who approved it. // // Evaluated for the returned rows only, and only for the merges among // them — the same rule the page's other per-row extras follow. A // timeline of check-ins on a forge with no landings pays one nothing. func (ix *Index) attachLandings(out []TimelineEntry) error { var merges []int for i := range out { if out[i].Kind == "ci" && out[i].IsMerge { merges = append(merges, i) } } if len(merges) == 0 { return nil } stmt, err := ix.db.Prepare(`SELECT l.id, l.title, COALESCE((SELECT f.user FROM forum_post f WHERE f.hash = l.settled_by), l.author) FROM checkin c JOIN checkin_parent p ON p.child = c.hash AND p.ord > 0 JOIN checkin tip ON tip.hash = p.parent JOIN landing l ON l.branch = tip.branch AND l.target = c.branch AND l.state = 'accepted' AND c.date >= l.opened WHERE c.hash = ? ORDER BY l.updated DESC LIMIT 1`) if err != nil { return err } defer stmt.Close() for _, i := range merges { var id, title, approver string err := stmt.QueryRow(out[i].Hash).Scan(&id, &title, &approver) if err == sql.ErrNoRows { continue // a merge somebody made by hand } if err != nil { return err } out[i].Landing, out[i].LandingTitle, out[i].LandingApprover = id, title, approver } return nil } // 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"` // SupersededBy names the landing this one defers to when two are // open on the same branch. Empty is the ordinary case. SupersededBy string `json:"superseded_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, superseded_by FROM landing` var args []any if state != "" { q += ` WHERE state = ?` args = append(args, state) // The open queue is a work list, and a superseded proposal is // not work: its branch already has a current landing. It stays // reachable, and the unfiltered listing still shows it. if state == "open" { q += ` AND superseded_by = ''` } } 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, &l.SupersededBy); 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, superseded_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, &l.SupersededBy) if err == sql.ErrNoRows { return LandingSummary{}, false, nil } if err != nil { return LandingSummary{}, false, err } return l, true, nil }