Skip to content
quarry Artifact [1aae1c604c]
Login

Artifact [1aae1c604c]

Artifact 1aae1c604c20da131011b002ca0d9280aa1644e9d35024a129d1aaeaa574ab4e:


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
}