// Package index is the per-node derived index (0001 §5): SQLite tables
// folded from store artifacts, disposable and rebuildable by design —
// dropping the database file and re-ingesting must converge to identical
// state, and the disposability harness proves it. This package is a data
// plane; the arch gate restricts its importers.
//
// # The contract this index owes its readers
//
// Both of quarry's neighbours state this contract explicitly and quarry
// did not, which is how it came to serve an incomplete history as
// though it were whole — a missing check-in drew a second branch on the
// timeline while the store had one, and every surface reported "ok".
//
// zit's derivation cache (repocache.zig) is "a pure MEMO of the derived
// repo-view... Deleting the file must never change any answer: it is
// disposable by construction." It is keyed on the log's identity — end
// offset, frame count, last-frame header CRC — plus a self-checksum,
// and "a stale, truncated, or corrupt cache is therefore never
// trusted": any mismatch is a MISS and the caller rebuilds.
//
// Fossil checks a schema version when it opens a repository and, on a
// mismatch, refuses to run at all: "run fossil rebuild to fix this
// problem". It would rather stop than answer from derived tables whose
// shape it does not recognise.
//
// The shared rule is that derived state must be able to say it is
// WRONG. Serving a plausible answer from a stale or partial derivation
// is the failure both designs are built to prevent. quarry follows it
// with three checks, differing from fossil only in the remedy: this
// index is disposable, so a failed check rebuilds rather than refuses.
//
// 1. Shape. schemaVersion is stamped in the file and compared on open.
// A file written by code with different derived tables is stale by
// definition — no amount of folding forward repairs a table that
// did not exist when its rows were written.
// 2. Completeness. DanglingParents finds edges naming a check-in this
// index does not hold; the ingest pipeline asks the store whether
// those artifacts still exist, and rebuilds when they do.
// 3. Convergence. The disposability harness proves a rebuild reaches
// identical state, which is what makes 1 and 2 safe remedies.
package index
import (
"database/sql"
"errors"
"fmt"
"log"
"slices"
"strings"
"sync"
"github.com/ncruces/go-sqlite3/driver"
"github.com/ncruces/go-sqlite3/ext/fts5"
"craftdesign.group/quarry/internal/artifact"
)
const schema = `
CREATE TABLE IF NOT EXISTS checkin (
hash TEXT PRIMARY KEY,
date TEXT NOT NULL,
user TEXT NOT NULL,
comment TEXT NOT NULL,
branch TEXT NOT NULL DEFAULT 'trunk'
);
-- One row per (path, check-in that changed it), plus a row per ancestor
-- directory, so "what last touched this entry" is an indexed exact-path
-- lookup for files and directories alike (fossil's mlink, flattened).
CREATE TABLE IF NOT EXISTS filechange (
path TEXT NOT NULL,
checkin TEXT NOT NULL,
date TEXT NOT NULL,
PRIMARY KEY (path, checkin)
);
CREATE INDEX IF NOT EXISTS filechange_by_path ON filechange (path, date DESC);
CREATE INDEX IF NOT EXISTS checkin_by_branch_date ON checkin (branch, date DESC);
CREATE TABLE IF NOT EXISTS checkin_tag (
src TEXT NOT NULL, -- artifact that carries the T card
date TEXT NOT NULL, -- that artifact's D card: tag priority order
checkin TEXT NOT NULL, -- tag target ("*" resolved to src for manifests)
name TEXT NOT NULL, -- without the sign prefix
sign TEXT NOT NULL, -- + - *
value TEXT NOT NULL,
PRIMARY KEY (src, checkin, name)
);
CREATE INDEX IF NOT EXISTS checkin_tag_by_target ON checkin_tag (checkin, name, date DESC);
CREATE TABLE IF NOT EXISTS checkin_parent (
child TEXT NOT NULL,
parent TEXT NOT NULL,
ord INTEGER NOT NULL,
PRIMARY KEY (child, ord)
);
-- Branch recompute walks parent->child; leaf detection asks "who lists
-- me as parent". Both need the reverse edge indexed.
CREATE INDEX IF NOT EXISTS checkin_parent_by_parent ON checkin_parent (parent);
-- Branch listing and per-branch tip lookups group by branch and take the
-- newest date; without this they degrade to a full scan per branch.
CREATE INDEX IF NOT EXISTS checkin_by_branch ON checkin (branch, date);
CREATE INDEX IF NOT EXISTS checkin_tag_by_checkin ON checkin_tag (checkin, name, sign);
CREATE TABLE IF NOT EXISTS technote (
hash TEXT PRIMARY KEY, -- the technote artifact
event TEXT NOT NULL, -- E-card id (stable across edits; latest wins)
date TEXT NOT NULL, -- E-card timestamp the note is filed under
edited TEXT NOT NULL, -- D-card edit time (dedup: newest edit wins)
user TEXT NOT NULL,
mimetype TEXT NOT NULL,
comment TEXT NOT NULL DEFAULT '', -- C card: the timeline title
body TEXT NOT NULL -- W card
);
CREATE INDEX IF NOT EXISTS technote_by_event ON technote (event, edited);
CREATE TABLE IF NOT EXISTS forum_post (
hash TEXT PRIMARY KEY, -- the forum-post artifact
thread TEXT NOT NULL, -- root post hash (self for a thread opener)
parent TEXT NOT NULL, -- I-card reply target ('' for the opener)
title TEXT NOT NULL, -- H card (opener only)
date TEXT NOT NULL,
user TEXT NOT NULL,
mimetype TEXT NOT NULL DEFAULT '', -- N card ('' = Fossil wiki, per fossil)
body TEXT NOT NULL -- W card
);
CREATE INDEX IF NOT EXISTS forum_post_by_thread ON forum_post (thread, date);
CREATE TABLE IF NOT EXISTS attachment (
hash TEXT PRIMARY KEY, -- the attachment artifact (A card)
name TEXT NOT NULL, -- filename as attached
target TEXT NOT NULL, -- ticket id, wiki page name, or technote event id
src TEXT NOT NULL, -- content artifact; '' is fossil's DELETION form
date TEXT NOT NULL,
user TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS attachment_by_target ON attachment (target, name, date);
CREATE TABLE IF NOT EXISTS wiki_edit (
hash TEXT PRIMARY KEY,
title TEXT NOT NULL,
date TEXT NOT NULL,
user TEXT NOT NULL,
mimetype TEXT NOT NULL,
body BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS ticket_change (
hash TEXT PRIMARY KEY,
ticket TEXT NOT NULL,
date TEXT NOT NULL,
user TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS ticket_change_by_ticket ON ticket_change (ticket, date);
CREATE TABLE IF NOT EXISTS ticket_field (
hash TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (hash, name)
);
-- ticket is the folded CURRENT state of each ticket, one row per ticket.
--
-- The list page derived this per request: GROUP BY over every change row
-- (tk: 35,903), plus two correlated subqueries per ticket to find the
-- newest title and status — 11,618 of them, each with its own temp
-- b-tree — and a HAVING that filtered after grouping, so paging saved
-- nothing. 200ms on tk with no zit call in sight.
--
-- Same lesson as the timeline: fold it once at write time, read it by
-- index. The fold already computes exactly this state for the search
-- doc, so the summary is written from the same pass rather than derived
-- a second way that could disagree with it.
CREATE TABLE IF NOT EXISTS ticket (
id TEXT PRIMARY KEY,
created TEXT NOT NULL,
updated TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL,
-- The rest of fossil's default triage vocabulary. These were already
-- folded into ticket_field by the same pass; keeping them out of the
-- summary meant a board or a filtered list had to join per ticket,
-- so neither existed. Denormalize at write time, read by index.
--
-- Absent is '' rather than NULL: a tracker that does not define a
-- field and a ticket that leaves it blank are the same question to
-- every reader, and '' compares without a three-valued surprise.
type TEXT NOT NULL DEFAULT '',
severity TEXT NOT NULL DEFAULT '',
priority TEXT NOT NULL DEFAULT '',
subsystem TEXT NOT NULL DEFAULT '',
resolution TEXT NOT NULL DEFAULT '',
-- Fossil can mark a whole ticket private. It is a folded COLUMN
-- rather than one row among ticket_field so it can be excluded in
-- the same index walk that lists tickets — a privacy filter that
-- costs a join is a privacy filter somebody eventually skips.
private INTEGER NOT NULL DEFAULT 0
);
-- The list's ordering: newest-updated first, id as the tiebreak so a
-- rebuild pages identically.
CREATE INDEX IF NOT EXISTS ticket_by_updated ON ticket (updated DESC, id);
CREATE TABLE IF NOT EXISTS search_doc (
hash TEXT PRIMARY KEY, -- artifact hash ("ticket:<id>" for folded ticket docs)
kind TEXT NOT NULL, -- checkin | wiki | ticket
ref TEXT NOT NULL, -- link target: checkin hash, wiki title, ticket id
label TEXT NOT NULL, -- human-readable result label
text TEXT NOT NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(
text, content='search_doc', content_rowid='rowid'
);
-- event is the timeline, denormalized at WRITE time.
--
-- It was assembled at read time from a five-way UNION ALL over checkin,
-- wiki_edit, ticket_change, technote and forum_post. That plan ends in
-- USE TEMP B-TREE FOR ORDER BY: every request materialized and sorted
-- every event in the repository to return fifty. On tk (61k events) the
-- timeline cost 536ms and the repository list — which asks each store
-- for its newest event — cost 399ms for eleven repos.
--
-- Fossil solved this long ago and this is its shape: one table with a
-- type column and an index on time (event_i1 ON event(mtime)), so the
-- timeline is an index walk that stops at the limit. Fossil renders the
-- same page in 3-4ms whether the repository holds 503 check-ins or
-- 36,702, which is the behaviour worth copying.
--
-- The comment for a TICKET is deliberately not stored here: its label
-- comes from the folded ticket doc, which is refreshed after the change
-- rows land, so a value written now would be stale. It is joined for the
-- returned page only, at most one page of rows, by primary key.
CREATE TABLE IF NOT EXISTS event (
hash TEXT PRIMARY KEY, -- the artifact; globally unique across kinds
kind TEXT NOT NULL, -- ci | w | t | e | f
ref TEXT NOT NULL, -- link target: check-in hash, wiki title, ticket id…
date TEXT NOT NULL,
user TEXT NOT NULL,
comment TEXT NOT NULL,
branch TEXT NOT NULL DEFAULT ''
);
-- The timeline's orderings. DESC in the index matters: the page reads
-- newest-first, and a mismatched direction costs the sort we are here to
-- remove.
CREATE INDEX IF NOT EXISTS event_by_date ON event (date DESC, hash DESC);
CREATE INDEX IF NOT EXISTS event_by_user ON event (user);
-- The tag listing filters on a name prefix ("sym-") and subqueries the
-- branch names out of the same table; without this both halves are full
-- scans with a temp B-tree over the result. An index changes no row a
-- fold writes, so it needs no schema bump and no rebuild — it is created
-- on open like every other index here.
CREATE INDEX IF NOT EXISTS checkin_tag_by_name ON checkin_tag (name, value);
CREATE INDEX IF NOT EXISTS event_by_kind ON event (kind, date DESC, hash DESC);
CREATE INDEX IF NOT EXISTS event_by_branch ON event (branch, date DESC, hash DESC);
-- Maintained by trigger rather than by the fold, for the same reason
-- search_fts is: five call sites that each remember to write a second
-- table is five chances for the timeline to disagree with the tables it
-- summarizes, and that disagreement would be invisible until someone
-- noticed a missing row.
-- contributor is the fleet's people surface, folded at write time for
-- the same reason the event table is: GROUP BY user over tcl's event
-- table is a 395ms scan with a temp B-tree, and an index only gets it
-- to 98ms because the work is the grouping, not the lookup. Across the
-- fleet that was half a second on a landing page.
--
-- Refreshed per batch for the users a batch touched, NOT by counting
-- triggers: SQLite does not fire DELETE triggers for rows a REPLACE
-- displaces unless recursive_triggers is on, so an increment/decrement
-- pair silently double-counts every event that is ever rewritten. A
-- recount over one user is exact whatever the writer did, and
-- event_by_user makes it an index scan.
CREATE TABLE IF NOT EXISTS contributor (
user TEXT PRIMARY KEY,
events INTEGER NOT NULL,
first TEXT NOT NULL,
last TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS contributor_by_events ON contributor (events DESC, user);
CREATE TRIGGER IF NOT EXISTS event_ci_ai AFTER INSERT ON checkin BEGIN
INSERT INTO event (hash, kind, ref, date, user, comment, branch)
VALUES (new.hash, 'ci', new.hash, new.date, new.user, new.comment, new.branch)
ON CONFLICT (hash) DO UPDATE SET kind=excluded.kind, ref=excluded.ref,
date=excluded.date, user=excluded.user, comment=excluded.comment,
branch=excluded.branch;
END;
CREATE TRIGGER IF NOT EXISTS event_ci_au AFTER UPDATE ON checkin BEGIN
INSERT INTO event (hash, kind, ref, date, user, comment, branch)
VALUES (new.hash, 'ci', new.hash, new.date, new.user, new.comment, new.branch)
ON CONFLICT (hash) DO UPDATE SET kind=excluded.kind, ref=excluded.ref,
date=excluded.date, user=excluded.user, comment=excluded.comment,
branch=excluded.branch;
END;
CREATE TRIGGER IF NOT EXISTS event_ci_ad AFTER DELETE ON checkin BEGIN
DELETE FROM event WHERE hash = old.hash;
END;
CREATE TRIGGER IF NOT EXISTS event_w_ai AFTER INSERT ON wiki_edit BEGIN
INSERT INTO event (hash, kind, ref, date, user, comment, branch)
VALUES (new.hash, 'w', new.title, new.date, new.user, 'Wiki: ' || new.title, '')
ON CONFLICT (hash) DO UPDATE SET kind=excluded.kind, ref=excluded.ref,
date=excluded.date, user=excluded.user, comment=excluded.comment,
branch=excluded.branch;
END;
CREATE TRIGGER IF NOT EXISTS event_w_ad AFTER DELETE ON wiki_edit BEGIN
DELETE FROM event WHERE hash = old.hash;
END;
CREATE TRIGGER IF NOT EXISTS event_t_ai AFTER INSERT ON ticket_change BEGIN
INSERT INTO event (hash, kind, ref, date, user, comment, branch)
VALUES (new.hash, 't', new.ticket, new.date, new.user, '', '')
ON CONFLICT (hash) DO UPDATE SET kind=excluded.kind, ref=excluded.ref,
date=excluded.date, user=excluded.user, comment=excluded.comment,
branch=excluded.branch;
END;
CREATE TRIGGER IF NOT EXISTS event_t_ad AFTER DELETE ON ticket_change BEGIN
DELETE FROM event WHERE hash = old.hash;
END;
CREATE TRIGGER IF NOT EXISTS event_e_ai AFTER INSERT ON technote BEGIN
INSERT INTO event (hash, kind, ref, date, user, comment, branch)
VALUES (new.hash, 'e', new.event, new.date, new.user,
'Technote: ' || CASE WHEN new.comment != '' THEN new.comment ELSE new.event END, '')
ON CONFLICT (hash) DO UPDATE SET kind=excluded.kind, ref=excluded.ref,
date=excluded.date, user=excluded.user, comment=excluded.comment,
branch=excluded.branch;
END;
CREATE TRIGGER IF NOT EXISTS event_e_ad AFTER DELETE ON technote BEGIN
DELETE FROM event WHERE hash = old.hash;
END;
CREATE TRIGGER IF NOT EXISTS event_f_ai AFTER INSERT ON forum_post BEGIN
INSERT INTO event (hash, kind, ref, date, user, comment, branch)
VALUES (new.hash, 'f', new.thread, new.date, new.user,
'Forum: ' || CASE WHEN new.title != '' THEN new.title ELSE substr(new.body, 1, 60) END, '')
ON CONFLICT (hash) DO UPDATE SET kind=excluded.kind, ref=excluded.ref,
date=excluded.date, user=excluded.user, comment=excluded.comment,
branch=excluded.branch;
END;
CREATE TRIGGER IF NOT EXISTS event_f_ad AFTER DELETE ON forum_post BEGIN
DELETE FROM event WHERE hash = old.hash;
END;
CREATE TRIGGER IF NOT EXISTS search_doc_ai AFTER INSERT ON search_doc BEGIN
INSERT INTO search_fts (rowid, text) VALUES (new.rowid, new.text);
END;
CREATE TRIGGER IF NOT EXISTS search_doc_ad AFTER DELETE ON search_doc BEGIN
INSERT INTO search_fts (search_fts, rowid, text) VALUES ('delete', old.rowid, old.text);
END;
CREATE TRIGGER IF NOT EXISTS search_doc_au AFTER UPDATE ON search_doc BEGIN
INSERT INTO search_fts (search_fts, rowid, text) VALUES ('delete', old.rowid, old.text);
INSERT INTO search_fts (rowid, text) VALUES (new.rowid, new.text);
END;
`
// Index is one repository's derived index.
type Index struct {
db *sql.DB
// stale is set at Open when the file's recorded shape does not
// match this code's. Read by the ingest pipeline, which rebuilds.
stale bool
// countMu guards a memo of Counts. The section counts go in the
// repository nav, so they are read on EVERY page render, and on the
// largest store in the corpus (tk: 147k artifacts) computing them
// costs ~110ms — dominated by the ticket and check-in scans. Paying
// that per request would put a tenth of a second on every page in
// the forge to render a handful of numbers.
//
// They only move when a fold does, so the memo is invalidated there
// rather than expired on a timer: a nav count that lags a write is
// the kind of small lie that makes a reader distrust the rest.
countMu sync.Mutex
counts *Counts
}
// Counts is a repository's per-section content census — what the nav
// needs to say how much of each kind a store holds, and to tell an empty
// section from a full one.
type Counts struct {
Checkins int `json:"checkins"`
WikiPages int `json:"wikiPages"`
Tickets int `json:"tickets"`
// ForumThreads counts THREADS, not posts: the forum page lists
// threads, so a count of posts would not match what a reader finds
// when they follow the link.
ForumThreads int `json:"forumThreads"`
Technotes int `json:"technotes"`
// Contributors is the number of distinct users who have authored
// anything. "Is anybody working on this" is a question a listing can
// answer and a commit message cannot.
Contributors int `json:"contributors"`
// Tags counts symbolic names that are not branches — releases.
Tags int `json:"tags"`
}
// invalidateCounts drops the memo. Called wherever folded state changes.
func (ix *Index) invalidateCounts() {
ix.countMu.Lock()
ix.counts = nil
ix.countMu.Unlock()
}
// Counts returns the per-section census, computing it at most once per
// fold.
func (ix *Index) Counts() (Counts, error) {
ix.countMu.Lock()
defer ix.countMu.Unlock()
if ix.counts != nil {
return *ix.counts, nil
}
var c Counts
for _, q := range []struct {
sql string
at *int
}{
{`SELECT count(*) FROM checkin`, &c.Checkins},
// Live pages only, the list's own rule (newest edit non-empty):
// a count that includes what the list hides is simply wrong.
{`SELECT count(*) FROM (SELECT max(date || ' ' || hash), body FROM wiki_edit
GROUP BY title HAVING length(body) > 0)`, &c.WikiPages},
{`SELECT count(*) FROM ticket WHERE private = 0`, &c.Tickets},
{`SELECT count(*) FROM forum_post WHERE hash = thread`, &c.ForumThreads},
{`SELECT count(*) FROM technote`, &c.Technotes},
{`SELECT count(DISTINCT user) FROM event`, &c.Contributors},
{`SELECT count(*) FROM (SELECT DISTINCT substr(name, 5) FROM checkin_tag
WHERE name LIKE 'sym-%' AND substr(name, 5) NOT IN
(SELECT value FROM checkin_tag WHERE name = 'branch'))`, &c.Tags},
} {
if err := ix.db.QueryRow(q.sql).Scan(q.at); err != nil {
return Counts{}, err
}
}
ix.counts = &c
return c, nil
}
// Open opens (creating if needed) the index database at path.
// schemaVersion is the shape of the derived tables. The DEFAULT way to
// change the shape is an entry in migrations — additive, online, no
// wipe. Bump this INSTEAD only when the change cannot be expressed
// additively: a bump marks every existing index stale, and the staged
// rebuild that follows replays each store from zero while its surfaces
// serve partial data.
const schemaVersion = 3
// derivedTables is every table the index owns, for Reset's drop.
// search_fts's shadow tables go with it. Nothing here is source of
// truth — the store is — which is what makes dropping them all safe.
var derivedTables = []string{
"checkin", "checkin_tag", "checkin_parent", "technote", "forum_post",
"attachment", "wiki_edit", "ticket_change", "ticket_field", "ticket",
"search_doc", "search_fts", "event", "contributor", "filechange",
}
// migrations are the additive column changes an existing file needs to
// reach this code's shape. THE DEFAULT PATH for schema change: an ALTER
// here ships online, with the forge serving throughout, and rows
// written before the column stay empty until a reindex. Bump
// schemaVersion INSTEAD only when the change cannot be expressed
// additively — that path wipes and refolds every store.
var migrations = []string{
`ALTER TABLE technote ADD COLUMN comment TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE forum_post ADD COLUMN mimetype TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE ticket ADD COLUMN type TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE ticket ADD COLUMN severity TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE ticket ADD COLUMN priority TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE ticket ADD COLUMN subsystem TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE ticket ADD COLUMN resolution TEXT NOT NULL DEFAULT ''`,
}
// applySchema brings a database to this code's shape: triggers dropped
// and recreated (CREATE TRIGGER IF NOT EXISTS never replaces a changed
// body), tables created, additive migrations applied. Shared by Open
// and Reset so "the shape" has exactly one definition.
func applySchema(db *sql.DB) error {
// Triggers persist, and a definition that changes must be dropped
// first or every existing index keeps the old body forever. These
// were once written with "INSERT OR REPLACE INTO event", which
// SQLite DISCARDS inside a trigger fired by an upsert — refolding a
// known artifact aborted whole batches with a UNIQUE failure.
for _, t := range []string{"event_ci_ai", "event_ci_au", "event_w_ai",
"event_t_ai", "event_e_ai", "event_f_ai"} {
if _, err := db.Exec("DROP TRIGGER IF EXISTS " + t); err != nil {
return fmt.Errorf("index: drop %s: %w", t, err)
}
}
if _, err := db.Exec(schema); err != nil {
return fmt.Errorf("index: schema: %w", err)
}
for _, alter := range migrations {
if _, err := db.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return fmt.Errorf("index: migrate: %w", err)
}
}
return nil
}
func Open(path string) (*Index, error) {
db, err := driver.Open("file:"+path, fts5.Register)
if err != nil {
return nil, err
}
// WAL keeps page reads flowing while a fold batch commits; the busy
// timeout covers the brief checkpoint locks.
if _, err := db.Exec(`PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA busy_timeout=5000;`); err != nil {
db.Close()
return nil, fmt.Errorf("index: pragmas: %w", err)
}
// Shape check, before anything reads a row. A file written by code
// with different derived tables cannot be repaired by folding
// forward — the rows already in it were derived by rules that no
// longer apply — so it is reported as stale and the caller
// rebuilds. Recorded, not guessed: PRAGMA user_version is stamped
// only after a Reset, which is the one moment the file is known to
// match this code's shape.
var onDisk int
if err := db.QueryRow(`PRAGMA user_version`).Scan(&onDisk); err != nil {
db.Close()
return nil, fmt.Errorf("index: schema version: %w", err)
}
stale := onDisk != schemaVersion
if err := applySchema(db); err != nil {
db.Close()
return nil, err
}
ix := &Index{db: db}
// The search index is a cache over search_doc, held to zit's cache
// contract: verified on open, and any mismatch is a MISS that
// rebuilds — never an error a reader sees. Drifted fts shadow state
// (an unsynced delete anywhere in the file's history reuses rowids)
// otherwise turns every search on the repo into a 500 while the
// content table beside it is healthy. ~2.5s to verify and 0.6s to
// rebuild on the fleet's largest index — boot-time insurance, paid once
// per open. The rank argument makes the check verify against the
// external content table too, not just the shadow tables' internal
// consistency — the drift IS the disagreement between the two.
if _, err := db.Exec(`INSERT INTO search_fts(search_fts, rank) VALUES('integrity-check', 1)`); err != nil {
log.Printf("index: %s: search index disagrees with its content (%v); rebuilding it", path, err)
if _, err := db.Exec(`INSERT INTO search_fts(search_fts) VALUES('rebuild')`); err != nil {
db.Close()
return nil, fmt.Errorf("index: search rebuild: %w", err)
}
}
if err := ix.migratePrivateTickets(); err != nil {
db.Close()
return nil, fmt.Errorf("index: private ticket migration: %w", err)
}
if err := ix.backfillEvents(); err != nil {
db.Close()
return nil, fmt.Errorf("index: backfill events: %w", err)
}
if err := ix.backfillContributors(); err != nil {
ix.db.Close()
return nil, fmt.Errorf("index: backfill contributors: %w", err)
}
if err := ix.backfillTickets(); err != nil {
db.Close()
return nil, fmt.Errorf("index: backfill tickets: %w", err)
}
ix.stale = stale
if stale {
// Not an error: an empty file reads as version 0 too, and a
// fresh index is not a problem. Stamp it once the schema is
// applied, and let the caller decide whether the CONTENT needs
// rebuilding — Stale() answers that.
if _, err := ix.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion)); err != nil {
ix.db.Close()
return nil, fmt.Errorf("index: stamp schema version: %w", err)
}
}
return ix, nil
}
// Stale reports that this file was written by code with a different
// derived shape, so its rows were folded under rules that no longer
// apply. It is true for a brand-new file too, which costs one rebuild
// of an empty index and keeps the rule free of exceptions.
//
// The caller rebuilds rather than refusing, because this state is
// disposable — where fossil stops and demands "fossil rebuild", quarry
// can simply do it.
func (ix *Index) Stale() bool { return ix.stale }
// ClearStale is called once a rebuild has re-derived every row under
// the current shape.
func (ix *Index) ClearStale() { ix.stale = false }
// refreshContributor recomputes one person's row from the event table.
// A recount rather than an adjustment: it is correct no matter how the
// event rows were written, and with event_by_user it touches only that
// person's rows. A person whose events all disappeared loses their row
// rather than lingering at zero.
func refreshContributor(tx *sql.Tx, user string) error {
if _, err := tx.Exec(`INSERT OR REPLACE INTO contributor (user, events, first, last)
SELECT user, count(*), min(date), max(date) FROM event
WHERE user = ? GROUP BY user`, user); err != nil {
return err
}
_, err := tx.Exec(`DELETE FROM contributor WHERE user = ?
AND NOT EXISTS (SELECT 1 FROM event WHERE user = ?)`, user, user)
return err
}
// migratePrivateTickets adds the private column to an index written
// before it existed and fills it from the folded fields.
//
// This one is not optional the way a display backfill is: an index that
// upgrades without it has the column defaulting to 0, which reports
// every private ticket as public. The whole point of the column is to
// withhold, so it must be filled in the same step that adds it.
func (ix *Index) migratePrivateTickets() error {
_, err := ix.db.Exec(`ALTER TABLE ticket ADD COLUMN private INTEGER NOT NULL DEFAULT 0`)
if err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
}
if err == nil {
// The column is new, so nothing in it is trustworthy yet.
if _, err := ix.db.Exec(`
UPDATE ticket SET private = 1 WHERE id IN (
SELECT c.ticket FROM ticket_change c
JOIN ticket_field f ON f.hash = c.hash AND f.name = 'is_private'
WHERE lower(f.value) NOT IN ('', '0', 'false', 'no', 'off'))`); err != nil {
return err
}
// And their search documents have to go with them.
if _, err := ix.db.Exec(`DELETE FROM search_doc WHERE kind = 'ticket'
AND substr(hash, 8) IN (SELECT id FROM ticket WHERE private = 1)`); err != nil {
return err
}
}
// The event sweep runs on EVERY open, not only when the column is
// born: events learned to follow privacy after the column did, so an
// index can hold the flag and the leak at once. One indexed delete,
// idempotent, and the fold maintains it from here.
if _, err := ix.db.Exec(`DELETE FROM event WHERE kind = 't'
AND ref IN (SELECT id FROM ticket WHERE private = 1)`); err != nil {
return err
}
return nil
}
// backfillContributors folds the people table from an event table
// written before it existed — same reasoning as backfillEvents, and
// same idempotence: it runs only when the table is empty and events
// are not.
func (ix *Index) backfillContributors() error {
var have, events int
if err := ix.db.QueryRow(`SELECT count(*) FROM contributor`).Scan(&have); err != nil {
return err
}
if err := ix.db.QueryRow(`SELECT count(*) FROM event`).Scan(&events); err != nil {
return err
}
if have > 0 || events == 0 {
return nil
}
_, err := ix.db.Exec(`INSERT OR REPLACE INTO contributor (user, events, first, last)
SELECT user, count(*), min(date), max(date) FROM event GROUP BY user`)
return err
}
// backfillEvents populates the timeline table from indexes written
// before it existed. The triggers only fire on new writes, so without
// this an upgraded index serves an EMPTY timeline until someone happens
// to reindex — derived data, technically repairable, but a blank
// timeline reads as data loss and nobody would connect it to an upgrade.
//
// Cheap and idempotent: it runs only when the table is empty and the
// sources are not, and INSERT OR REPLACE keyed by hash makes a repeat
// harmless.
func (ix *Index) backfillEvents() error {
var events, checkins int
if err := ix.db.QueryRow(`SELECT count(*) FROM event`).Scan(&events); err != nil {
return err
}
if err := ix.db.QueryRow(`SELECT count(*) FROM checkin`).Scan(&checkins); err != nil {
return err
}
if events > 0 || checkins == 0 {
return nil
}
tx, err := ix.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, q := range []string{
`INSERT OR REPLACE INTO event (hash,kind,ref,date,user,comment,branch)
SELECT hash,'ci',hash,date,user,comment,branch FROM checkin`,
`INSERT OR REPLACE INTO event (hash,kind,ref,date,user,comment,branch)
SELECT hash,'w',title,date,user,'Wiki: '||title,'' FROM wiki_edit`,
`INSERT OR REPLACE INTO event (hash,kind,ref,date,user,comment,branch)
SELECT hash,'t',ticket,date,user,'','' FROM ticket_change`,
`INSERT OR REPLACE INTO event (hash,kind,ref,date,user,comment,branch)
SELECT hash,'e',event,date,user,
'Technote: '||CASE WHEN comment!='' THEN comment ELSE event END,'' FROM technote`,
`INSERT OR REPLACE INTO event (hash,kind,ref,date,user,comment,branch)
SELECT hash,'f',thread,date,user,
'Forum: '||CASE WHEN title!='' THEN title ELSE substr(body,1,60) END,'' FROM forum_post`,
} {
if _, err := tx.Exec(q); err != nil {
return err
}
}
return tx.Commit()
}
// backfillTickets populates the folded ticket table for indexes written
// before it existed, for the same reason backfillEvents does: the fold
// only writes it on new changes, so an upgraded index would show an
// EMPTY ticket list until someone reindexed.
//
// This runs the expensive grouped query ONCE, at open, instead of on
// every request — which is the whole point of the table.
func (ix *Index) backfillTickets() error {
var tickets, changes int
if err := ix.db.QueryRow(`SELECT count(*) FROM ticket`).Scan(&tickets); err != nil {
return err
}
if err := ix.db.QueryRow(`SELECT count(*) FROM ticket_change`).Scan(&changes); err != nil {
return err
}
if tickets > 0 || changes == 0 {
return nil
}
_, err := ix.db.Exec(`
INSERT OR REPLACE INTO ticket (id, created, updated, title, status, private)
SELECT c.ticket, min(c.date), max(c.date),
COALESCE((SELECT f.value FROM ticket_change c2
JOIN ticket_field f ON f.hash = c2.hash AND f.name = 'title'
WHERE c2.ticket = c.ticket
ORDER BY c2.date DESC, c2.hash DESC LIMIT 1), ''),
COALESCE((SELECT f.value FROM ticket_change c2
JOIN ticket_field f ON f.hash = c2.hash AND f.name = 'status'
WHERE c2.ticket = c.ticket
ORDER BY c2.date DESC, c2.hash DESC LIMIT 1), ''),
CASE lower(COALESCE((SELECT f.value FROM ticket_change c2
JOIN ticket_field f ON f.hash = c2.hash AND f.name = 'is_private'
WHERE c2.ticket = c.ticket
ORDER BY c2.date DESC, c2.hash DESC LIMIT 1), ''))
WHEN '' THEN 0 WHEN '0' THEN 0 WHEN 'false' THEN 0
WHEN 'no' THEN 0 WHEN 'off' THEN 0 ELSE 1 END
FROM ticket_change c GROUP BY c.ticket`)
return err
}
func (ix *Index) Close() error { return ix.db.Close() }
// Parsed pairs an artifact hash with its parsed form; Art is nil for
// opaque content blobs (folded as nothing — content is served on demand
// through zit, never copied into the index).
type Parsed struct {
Hash string
Art *artifact.Artifact
// Changes is the file paths this check-in changed, resolved by the
// ingest (which owns artifact plumbing) — nil for non-check-ins and
// for check-ins whose baseline could not be recovered, in which
// case the listing simply shows no last-change for those paths.
Changes []string
}
// Fold applies one event batch atomically. Folds are idempotent upserts
// keyed by hash, so at-least-once event delivery converges. Ingest
// position is not stored here: the store cursor lives in NATS KV and the
// stream position in the durable consumer (0001 §4) — both advisory,
// both rebuilt by replaying stores.
// Fold reports whether the batch touched branch-bearing artifacts; the
// caller runs RecomputeBranches once after draining (recomputing per
// batch is quadratic over a bulk ingest).
func (ix *Index) Fold(batch []Parsed) (bool, error) {
tx, err := ix.db.Begin()
if err != nil {
return false, err
}
defer tx.Rollback()
branchy := false
tickets := map[string]bool{}
people := map[string]bool{}
for _, p := range batch {
if p.Art == nil {
continue
}
if err := fold(tx, p.Hash, p.Art); err != nil {
return false, fmt.Errorf("index: fold %s: %w", p.Hash, err)
}
if err := foldChanges(tx, p.Hash, p.Art, p.Changes); err != nil {
return false, fmt.Errorf("index: changes %s: %w", p.Hash, err)
}
if p.Art.User != "" {
people[p.Art.User] = true
}
switch p.Art.Type {
case artifact.Manifest, artifact.Control:
branchy = true
case artifact.Ticket:
tickets[p.Art.TicketID] = true
}
}
// Ticket search docs hold the FOLDED state (one doc per ticket, keyed
// by id), so snippets never show stale field values from early
// changes (ticket 050). Once per touched ticket per batch — per
// change is quadratic over a real ticket corpus.
for id := range tickets {
if err := refreshTicketDoc(tx, id); err != nil {
return false, fmt.Errorf("index: ticket doc %s: %w", id, err)
}
}
// Same shape as the ticket docs above: once per touched person per
// batch, recounted from the event table rather than adjusted.
for user := range people {
if err := refreshContributor(tx, user); err != nil {
return false, fmt.Errorf("index: contributor %s: %w", user, err)
}
}
if err := tx.Commit(); err != nil {
return false, err
}
// The memo is dropped AFTER the commit, so a concurrent reader can
// never repopulate it from the pre-commit state and cache that.
ix.invalidateCounts()
return branchy, nil
}
// CheckinRecord is one check-in's derived facts from `zit crosslink`
// (zit#109) — everything the Manifest branch of fold() writes, without
// the manifest. Date is the canonical millisecond ISO form.
type CheckinRecord struct {
Hash string
Date string
User string
Comment string
Parents []string
Tags []artifact.Tag
Changes []string
}
// FoldCheckins folds crosslink records in one transaction. It writes
// exactly the rows fold()'s Manifest branch writes — checkin, parents,
// tags, filechange — and the event/FTS triggers on those tables keep
// the derived surfaces identical to the artifact path by construction.
func (ix *Index) FoldCheckins(batch []CheckinRecord) error {
tx, err := ix.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Prepared once per transaction: tx.Exec re-prepares per call, and
// a large store folds millions of these rows in one rebuild.
ciStmt, err := tx.Prepare(`INSERT INTO checkin (hash, date, user, comment)
VALUES (?, ?, ?, ?)
ON CONFLICT (hash) DO UPDATE SET date=excluded.date,
user=excluded.user, comment=excluded.comment`)
if err != nil {
return err
}
defer ciStmt.Close()
parStmt, err := tx.Prepare(`INSERT OR REPLACE INTO checkin_parent (child, parent, ord)
VALUES (?, ?, ?)`)
if err != nil {
return err
}
defer parStmt.Close()
fcStmt, err := tx.Prepare(`INSERT INTO filechange (path, checkin, date)
VALUES (?, ?, ?) ON CONFLICT (path, checkin) DO NOTHING`)
if err != nil {
return err
}
defer fcStmt.Close()
sdStmt, err := tx.Prepare(`INSERT OR REPLACE INTO search_doc (hash, kind, ref, label, text)
VALUES (?, 'checkin', ?, ?, ?)`)
if err != nil {
return err
}
defer sdStmt.Close()
for _, r := range batch {
if _, err := ciStmt.Exec(r.Hash, r.Date, r.User, r.Comment); err != nil {
return fmt.Errorf("index: crosslink %s: %w", r.Hash, err)
}
for i, parent := range r.Parents {
if _, err := parStmt.Exec(r.Hash, parent, i); err != nil {
return err
}
}
if err := foldTags(tx, r.Hash, r.Hash, r.Date, r.Tags); err != nil {
return err
}
for _, path := range ancestorPaths(r.Changes) {
if _, err := fcStmt.Exec(path, r.Hash, r.Date); err != nil {
return err
}
}
// The same search document fold() writes for a Manifest — a
// crosslink-rebuilt store must answer check-in search
// identically to an artifact-folded one.
first, _, _ := strings.Cut(r.Comment, "\n")
if _, err := sdStmt.Exec(r.Hash, r.Hash, first, r.Comment); err != nil {
return err
}
}
return tx.Commit()
}
// ancestorPaths expands changed paths with each ancestor directory,
// deduplicated — the same set foldChangePaths writes.
func ancestorPaths(changes []string) []string {
seen := map[string]bool{}
var out []string
for _, path := range changes {
for p := path; p != ""; {
if seen[p] {
break
}
seen[p] = true
out = append(out, p)
i := strings.LastIndexByte(p, '/')
if i < 0 {
break
}
p = p[:i]
}
}
return out
}
// FoldBulk is Fold for a REBUILD: the per-batch ticket-document and
// contributor refreshes are skipped — over a bulk replay they are
// quadratic (every batch re-derives every ticket it touched, and a
// large tracker is touched by most batches) — and FinishBulk performs
// each refresh exactly once at the end.
func (ix *Index) FoldBulk(batch []Parsed) error {
tx, err := ix.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, p := range batch {
if p.Art == nil {
continue
}
if err := fold(tx, p.Hash, p.Art); err != nil {
return fmt.Errorf("index: fold %s: %w", p.Hash, err)
}
if err := foldChanges(tx, p.Hash, p.Art, p.Changes); err != nil {
return fmt.Errorf("index: changes %s: %w", p.Hash, err)
}
}
return tx.Commit()
}
// FoldChangesBulk writes filechange rows for many check-ins in one
// transaction — the rebuild's second pass, after every manifest is in
// hand and changes can be derived without any store round-trip.
func (ix *Index) FoldChangesBulk(items []BulkChanges) error {
tx, err := ix.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, it := range items {
if err := foldChanges(tx, it.Hash, it.Art, it.Changes); err != nil {
return fmt.Errorf("index: changes %s: %w", it.Hash, err)
}
}
return tx.Commit()
}
// BulkChanges pairs a check-in with its resolved changed paths.
type BulkChanges struct {
Hash string
Art *artifact.Artifact
Changes []string
}
// FinishBulk performs the refreshes FoldBulk deferred: every ticket
// document and every contributor aggregate, once each.
func (ix *Index) FinishBulk() error {
tx, err := ix.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
collect := func(q string) ([]string, error) {
rows, err := tx.Query(q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
ids, err := collect(`SELECT DISTINCT ticket FROM ticket_change`)
if err != nil {
return err
}
for _, id := range ids {
if err := refreshTicketDoc(tx, id); err != nil {
return fmt.Errorf("index: ticket doc %s: %w", id, err)
}
}
users, err := collect(`SELECT DISTINCT user FROM event WHERE user <> ''`)
if err != nil {
return err
}
for _, u := range users {
if err := refreshContributor(tx, u); err != nil {
return fmt.Errorf("index: contributor %s: %w", u, err)
}
}
if err := tx.Commit(); err != nil {
return err
}
ix.invalidateCounts()
return nil
}
// FoldNow folds one batch and recomputes branches immediately — the
// single-batch convenience for callers outside the drain loop.
func (ix *Index) FoldNow(batch []Parsed) error {
branchy, err := ix.Fold(batch)
if err != nil {
return err
}
if branchy {
return ix.RecomputeBranches()
}
return nil
}
// RecomputeBranches rewrites the branch column from recorded tag facts —
// once per fold round, after every batch has landed.
func (ix *Index) RecomputeBranches() error {
tx, err := ix.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := recomputeBranches(tx); err != nil {
return fmt.Errorf("index: branches: %w", err)
}
return tx.Commit()
}
func fold(tx *sql.Tx, hash string, a *artifact.Artifact) error {
switch a.Type {
case artifact.Manifest:
// INSERT OR REPLACE would clobber the computed branch column on
// redelivery; upsert only the artifact-borne fields.
if _, err := tx.Exec(`INSERT INTO checkin (hash, date, user, comment)
VALUES (?, ?, ?, ?)
ON CONFLICT (hash) DO UPDATE SET date=excluded.date,
user=excluded.user, comment=excluded.comment`,
hash, a.Date, a.User, a.Comment); err != nil {
return err
}
for i, parent := range a.Parents {
if _, err := tx.Exec(`INSERT OR REPLACE INTO checkin_parent (child, parent, ord)
VALUES (?, ?, ?)`, hash, parent, i); err != nil {
return err
}
}
if err := foldTags(tx, hash, hash, a.Date, a.Tags); err != nil {
return err
}
case artifact.Control:
if err := foldTags(tx, hash, "", a.Date, a.Tags); err != nil {
return err
}
case artifact.Wiki:
if _, err := tx.Exec(`INSERT OR REPLACE INTO wiki_edit (hash, title, date, user, mimetype, body)
VALUES (?, ?, ?, ?, ?, ?)`,
hash, a.WikiTitle, a.Date, a.User, a.Mimetype, a.Body); err != nil {
return err
}
case artifact.Attachment:
// Fossil counts an attachment as an event on whatever it targets,
// which is why a ticket-change census that ignores them reads
// short. An A card with no source is a DELETION of that name on
// that target, not a malformed record, so the row is kept and the
// query resolves it.
if at := a.Attachment; at != nil {
if _, err := tx.Exec(`INSERT OR REPLACE INTO attachment (hash, name, target, src, date, user)
VALUES (?, ?, ?, ?, ?, ?)`,
hash, at.Name, at.Target, at.Source, a.Date, a.User); err != nil {
return err
}
}
case artifact.Ticket:
if _, err := tx.Exec(`INSERT OR REPLACE INTO ticket_change (hash, ticket, date, user)
VALUES (?, ?, ?, ?)`, hash, a.TicketID, a.Date, a.User); err != nil {
return err
}
for _, f := range a.Fields {
if _, err := tx.Exec(`INSERT OR REPLACE INTO ticket_field (hash, name, value)
VALUES (?, ?, ?)`, hash, f.Name, f.Value); err != nil {
return err
}
}
case artifact.Technote:
if _, err := tx.Exec(`INSERT OR REPLACE INTO technote (hash, event, date, edited, user, mimetype, comment, body)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
hash, a.EventID, a.EventTime, a.Date, a.User, a.Mimetype, a.Comment, string(a.Body)); err != nil {
return err
}
case artifact.Forum:
// A thread opener has no G card; it roots its own thread.
thread := a.ThreadRoot
if thread == "" {
thread = hash
}
if _, err := tx.Exec(`INSERT OR REPLACE INTO forum_post (hash, thread, parent, title, date, user, mimetype, body)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
hash, thread, a.InReplyTo, a.ThreadTitle, a.Date, a.User, a.Mimetype, string(a.Body)); err != nil {
return err
}
}
// Ticket search docs are refreshed once per ticket per batch by the
// caller (fold order within the batch doesn't matter — the refresh
// reads the folded rows).
if a.Type != artifact.Ticket {
if kind, ref, label, text := searchDoc(hash, a); kind != "" {
if _, err := tx.Exec(`INSERT OR REPLACE INTO search_doc (hash, kind, ref, label, text)
VALUES (?, ?, ?, ?, ?)`, hash, kind, ref, label, text); err != nil {
return err
}
}
}
// Control, cluster, technote, forum, attachment: folded when their
// pages land (phase 2); skipping them is safe because a reindex
// replays every artifact.
return nil
}
// foldTags records an artifact's T cards as tag facts. self names the
// check-in a "*" target resolves to (the carrying manifest); control
// artifacts have no self and their "*" targets are illegal anyway
// (§5.4), so those rows are skipped.
func foldTags(tx *sql.Tx, src, self, date string, tags []artifact.Tag) error {
for _, t := range tags {
target := t.Target
if target == "*" {
if self == "" {
continue
}
target = self
}
if _, err := tx.Exec(`INSERT OR REPLACE INTO checkin_tag (src, date, checkin, name, sign, value)
VALUES (?, ?, ?, ?, ?, ?)`,
src, date, target, strings.TrimLeft(t.Name, "+-*"), t.Name[:1], t.Value); err != nil {
return err
}
}
return nil
}
// recomputeBranches rewrites every check-in's branch from the recorded
// tag facts: a propagating branch tag names its check-in's branch, and
// the name flows down primary-parent edges until a descendant carries
// its own. A WHOLESALE recompute after each fold batch is what makes
// the column order-independent — the same tag facts and DAG yield the
// same branches no matter what order artifacts arrived in (the
// disposability property). ~4k check-ins recompute in milliseconds.
func recomputeBranches(tx *sql.Tx) error {
// The walk lands in an indexed temp table first: the final UPDATE's
// per-row probe against a bare materialized CTE is O(n²) at real-store
// sizes (sqlite.org's repo: 36k check-ins).
if _, err := tx.Exec(`
CREATE TEMP TABLE IF NOT EXISTS br_walk (hash TEXT PRIMARY KEY, branch TEXT) WITHOUT ROWID;
DELETE FROM br_walk;
INSERT OR IGNORE INTO br_walk (hash, branch)
WITH RECURSIVE br(hash, branch) AS (
-- one seed per check-in: the branch tag from the LATEST
-- carrying artifact wins (bare column with max())
SELECT t.checkin, t.value FROM (
SELECT checkin, value, max(date || src)
FROM checkin_tag WHERE name = 'branch' AND sign = '*'
GROUP BY checkin
) t JOIN checkin c ON c.hash = t.checkin
UNION
SELECT p.child, br.branch FROM br
JOIN checkin_parent p ON p.parent = br.hash AND p.ord = 0
WHERE NOT EXISTS (SELECT 1 FROM checkin_tag t2
WHERE t2.checkin = p.child AND t2.name = 'branch' AND t2.sign = '*')
)
SELECT hash, branch FROM br;
UPDATE checkin SET branch = COALESCE(
(SELECT branch FROM br_walk WHERE br_walk.hash = checkin.hash), 'trunk');`); err != nil {
return err
}
return nil
}
// refreshTicketDoc rebuilds one ticket's search doc from its folded
// current state, inside the fold transaction.
func refreshTicketDoc(tx *sql.Tx, ticket string) error {
rows, err := tx.Query(`SELECT f.name, f.value FROM ticket_change c
JOIN ticket_field f ON f.hash = c.hash
WHERE c.ticket = ? ORDER BY c.date, c.hash, f.name`, ticket)
if err != nil {
return err
}
defer rows.Close()
current := map[string]string{}
var order []string
for rows.Next() {
var name, value string
if err := rows.Scan(&name, &value); err != nil {
return err
}
if _, seen := current[name]; !seen {
order = append(order, name)
}
current[name] = value // latest assignment wins
}
if err := rows.Err(); err != nil {
return err
}
var b strings.Builder
for _, name := range order {
b.WriteString(current[name])
b.WriteByte('\n')
}
label := current["title"]
if label == "" && len(ticket) >= 10 {
label = ticket[:10]
}
// A private ticket is dropped from the search corpus, not merely
// hidden from the list: a snippet leaks the content whether or not
// the ticket page is reachable. Its timeline events go the same way
// — an event row is id, author and date on public display. Both
// transitions are handled here because privacy is a fold-time
// property that can change over a ticket's life: hiding deletes,
// and un-privating RESTORES from ticket_change, or erring toward
// private would be a one-way door nobody dares open.
if boolField(current["is_private"]) == 1 {
if _, err := tx.Exec(`DELETE FROM search_doc WHERE hash = ?`, "ticket:"+ticket); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM event WHERE kind = 't' AND ref = ?`, ticket); err != nil {
return err
}
} else {
if _, err := tx.Exec(`INSERT OR REPLACE INTO search_doc (hash, kind, ref, label, text)
VALUES (?, 'ticket', ?, ?, ?)`, "ticket:"+ticket, ticket, label, b.String()); err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO event (hash, kind, ref, date, user, comment, branch)
SELECT hash, 't', ticket, date, user, '', '' FROM ticket_change WHERE ticket = ?
ON CONFLICT (hash) DO NOTHING`, ticket); err != nil {
return err
}
}
// The list surface, folded from the same pass. Dates come from the
// (ticket, date) index rather than the rows above, which are ordered
// for field folding and would need a second traversal to bound.
var created, updated string
if err := tx.QueryRow(`SELECT COALESCE(min(date),''), COALESCE(max(date),'')
FROM ticket_change WHERE ticket = ?`, ticket).Scan(&created, &updated); err != nil {
return err
}
_, err = tx.Exec(`INSERT INTO ticket
(id, created, updated, title, status, private,
type, severity, priority, subsystem, resolution)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET created=excluded.created,
updated=excluded.updated, title=excluded.title, status=excluded.status,
private=excluded.private, type=excluded.type,
severity=excluded.severity, priority=excluded.priority,
subsystem=excluded.subsystem, resolution=excluded.resolution`,
ticket, created, updated, current["title"], current["status"],
boolField(current["is_private"]),
current["type"], current["severity"], current["priority"],
current["subsystem"], current["resolution"])
return err
}
// boolField reads fossil's private flag. It is a text field like every
// other, so anything that is not an explicit falsehood counts as TRUE:
// erring toward private is the only safe direction for a value whose
// whole purpose is to withhold.
func boolField(v string) int {
switch strings.ToLower(strings.TrimSpace(v)) {
case "", "0", "false", "no", "off":
return 0
}
return 1
}
// searchDoc maps one immutable artifact to its search-corpus row; empty
// kind means "not searched here" (tickets go through refreshTicketDoc).
// Every wiki edit is indexed, so hits can land on historical revisions;
// queries dedupe by ref.
func searchDoc(hash string, a *artifact.Artifact) (kind, ref, label, text string) {
switch a.Type {
case artifact.Manifest:
first, _, _ := strings.Cut(a.Comment, "\n")
return "checkin", hash, first, a.Comment
case artifact.Wiki:
return "wiki", a.WikiTitle, a.WikiTitle, a.WikiTitle + "\n" + string(a.Body)
case artifact.Technote:
label := a.Comment // C card is the optional one-line title
if label == "" {
label, _, _ = strings.Cut(string(a.Body), "\n")
}
return "technote", a.EventID, label, string(a.Body)
case artifact.Forum:
label := a.ThreadTitle
if label == "" {
label, _, _ = strings.Cut(string(a.Body), "\n")
}
root := a.ThreadRoot
if root == "" {
root = hash
}
return "forum", root, label, string(a.Body)
}
return "", "", "", ""
}
// SearchResult is one hit, deduplicated by (kind, ref). Snippet marks
// matches with ⟦…⟧ — characters that cannot collide with markdown or
// bracket-bearing content (ticket 060); renderers turn them into <mark>.
type SearchResult struct {
Kind string `json:"kind"`
Ref string `json:"ref"`
Label string `json:"label"`
Snippet string `json:"snippet"`
// Rank is bm25, lower being better. It is not serialized: a score
// is only meaningful against the corpus that produced it, so
// publishing it would invite callers to compare scores from
// different repositories as if they were one scale. A fleet-wide
// merge uses it in-process and says plainly that it interleaves.
Rank float64 `json:"-"`
}
// SearchKinds are the document kinds Search can restrict to, which is
// every kind searchDoc produces. A caller naming anything else gets an
// error rather than an empty result, because silently matching nothing
// is indistinguishable from a query that genuinely found nothing.
var SearchKinds = []string{"checkin", "wiki", "ticket", "forum", "technote"}
// Search runs an FTS5 query over the corpus. The user string is
// re-quoted token-by-token so FTS operator syntax cannot inject or
// error. Ranking is bm25 with ref as the deterministic tiebreak.
//
// An empty kind searches everything; naming one restricts to it, which
// is what lets a search box mean "in this section" on a section page.
func (ix *Index) Search(query, kind string, limit int) ([]SearchResult, error) {
tokens := strings.Fields(query)
if len(tokens) == 0 {
return nil, nil
}
if kind != "" && !slices.Contains(SearchKinds, kind) {
return nil, fmt.Errorf("index: unknown search kind %q", kind)
}
for i, tok := range tokens {
tokens[i] = `"` + strings.ReplaceAll(tok, `"`, `""`) + `"`
}
// The MATERIALIZED hint stops SQLite flattening the CTE into the
// aggregate query, where bm25() is not usable.
rows, err := ix.db.Query(`
WITH hits AS MATERIALIZED (
SELECT d.kind AS kind, d.ref AS ref, d.label AS label,
snippet(search_fts, 0, '⟦', '⟧', '…', 12) AS snip,
bm25(search_fts) AS rank
FROM search_fts JOIN search_doc d ON d.rowid = search_fts.rowid
WHERE search_fts MATCH ?
)
SELECT kind, ref, label, snip, min(rank) AS best FROM hits
WHERE (? = '' OR kind = ?)
GROUP BY kind, ref ORDER BY best, kind, ref LIMIT ?`,
strings.Join(tokens, " "), kind, kind, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SearchResult
for rows.Next() {
var r SearchResult
if err := rows.Scan(&r.Kind, &r.Ref, &r.Label, &r.Snippet, &r.Rank); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// SearchCount is how many distinct documents a query matches. The
// fleet-wide facet needs a true count, not the number of rows that fit
// under a limit: a facet reading "10" for every repository because ten
// was the page size tells a reader nothing about where to look.
func (ix *Index) SearchCount(query, kind string) (int, error) {
tokens := strings.Fields(query)
if len(tokens) == 0 {
return 0, nil
}
if kind != "" && !slices.Contains(SearchKinds, kind) {
return 0, fmt.Errorf("index: unknown search kind %q", kind)
}
for i, tok := range tokens {
tokens[i] = `"` + strings.ReplaceAll(tok, `"`, `""`) + `"`
}
var n int
err := ix.db.QueryRow(`
SELECT count(*) FROM (
SELECT 1 FROM search_fts JOIN search_doc d ON d.rowid = search_fts.rowid
WHERE search_fts MATCH ? AND (? = '' OR d.kind = ?)
GROUP BY d.kind, d.ref
)`, strings.Join(tokens, " "), kind, kind).Scan(&n)
return n, err
}
// Reset drops all derived state — the reindex path (0001 §5). The next
// Fold rebuilds from a zero cursor.
//
// It drops the TABLES, not their rows: Reset is the remedy the
// schema-version stamp points at, and a version bump means the shape
// changed. A Reset that only deleted rows promised a repair it could
// not perform — the 2026-08-19 bump wiped every store and then failed
// every refold against a table it had emptied but never widened.
// Dropping and reapplying the schema makes "whatever the file held" a
// question with one answer: this code's shape.
func (ix *Index) Reset() error {
for _, table := range derivedTables {
if _, err := ix.db.Exec("DROP TABLE IF EXISTS " + table); err != nil {
return fmt.Errorf("index: reset %s: %w", table, err)
}
}
if err := applySchema(ix.db); err != nil {
return err
}
if _, err := ix.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion)); err != nil {
return fmt.Errorf("index: reset stamp: %w", err)
}
ix.invalidateCounts()
return nil
}
// Tag is a symbolic name that is NOT a branch: fossil gives both a
// "sym-" tag, so the two are told apart by which names also appear as
// branch values. Tip is the check-in the name currently resolves to.
type Tag struct {
Name string `json:"name"`
Tip string `json:"tip"`
Date string `json:"date"`
}
// Tags lists release tags, most recent first. Branch names are excluded
// because the branch selector already offers them and listing a name in
// both places makes the two controls look like they disagree.
// TagCount is the number of distinct release tags — the Tags query
// without the rows, for the About panel's "+N tags" line.
func (ix *Index) TagCount() (int, error) {
var n int
err := ix.db.QueryRow(`
SELECT count(DISTINCT substr(name, 5)) FROM checkin_tag
WHERE name LIKE 'sym-%'
AND substr(name, 5) NOT IN (
SELECT value FROM checkin_tag WHERE name = 'branch')`).Scan(&n)
return n, err
}
func (ix *Index) Tags(limit int) ([]Tag, error) {
if limit <= 0 {
limit = 100
}
rows, err := ix.db.Query(`
SELECT substr(t.name, 5) AS tag, t.checkin, max(t.date) AS d
FROM checkin_tag t
WHERE t.name LIKE 'sym-%'
AND substr(t.name, 5) NOT IN (
SELECT value FROM checkin_tag WHERE name = 'branch')
GROUP BY tag
ORDER BY d DESC, tag
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Tag
for rows.Next() {
var t Tag
if err := rows.Scan(&t.Name, &t.Tip, &t.Date); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// RawDB exposes the handle for tests that need to simulate damage the
// fold path cannot produce on purpose — such as an artifact lost from
// the event window.
func (ix *Index) RawDB() *sql.DB { return ix.db }
// DanglingParents lists check-in hashes referenced as a parent that
// this index does not hold, capped at limit.
//
// A dangling edge is NOT by itself proof of damage. Fossil-born
// histories legitimately reference ancestors the store never held —
// shunned artifacts, or a history imported from a point rather than
// from its root; fossil, tcl and sqlite all carry some. Only the
// caller, which can ask the store, can tell those apart from artifacts
// that were LOST: see the ingest pipeline, where an edge whose parent
// the store still holds is the repairable kind.
//
// Returned as hashes rather than a count so that distinction can be
// made at all. It is an index walk, cheap enough to run every round.
func (ix *Index) DanglingParents(limit int) ([]string, error) {
if limit <= 0 {
limit = 100
}
rows, err := ix.db.Query(`SELECT DISTINCT p.parent FROM checkin_parent p
WHERE NOT EXISTS (SELECT 1 FROM checkin WHERE hash = p.parent)
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var h string
if err := rows.Scan(&h); err != nil {
return nil, err
}
out = append(out, h)
}
return out, rows.Err()
}
// Contributor is one person's activity in a repository, folded at write
// time. Events counts every kind the timeline shows, not just check-ins:
// somebody who only files tickets is a contributor.
type Contributor struct {
User string `json:"user"`
Events int `json:"events"`
First string `json:"first"`
Last string `json:"last"`
}
// Contributors returns the repository's people, busiest first. Reading a
// folded table, so this is an index walk rather than the half-second
// GROUP BY it replaced.
func (ix *Index) Contributors(limit int) ([]Contributor, error) {
if limit <= 0 {
limit = 100
}
rows, err := ix.db.Query(`SELECT user, events, first, last FROM contributor
ORDER BY events DESC, user LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Contributor
for rows.Next() {
var c Contributor
if err := rows.Scan(&c.User, &c.Events, &c.First, &c.Last); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// TimelineEntry is one event on the unified timeline: a check-in
// (kind "ci"), a wiki edit ("w"), a ticket change ("t"), a technote
// ("e"), or a forum post ("f"). Ref is the page target: the check-in
// hash, the wiki title, the ticket id, the technote event id, or the
// forum thread root.
type TimelineEntry struct {
Kind string `json:"kind"`
Hash string `json:"hash"`
Ref string `json:"ref"`
Date string `json:"date"`
User string `json:"user"`
Comment string `json:"comment"`
Branch string `json:"branch"`
IsLeaf bool `json:"isLeaf"`
IsMerge bool `json:"isMerge"`
Parents []string `json:"parents"`
// CI is the recorded verdict for this check-in — "ci-pass",
// "ci-fail", or empty when none was written. It comes from a tag
// artifact in the store (0001 §6), not from the engine, so it
// survives cloning and does not require the engine to be reachable.
CI string `json:"ci,omitempty"`
}
// CheckinByPrefix resolves a hash prefix to exactly one check-in and its
// parents. ok is false when the prefix matches zero or several — an
// ambiguous prefix never resolves (the fossil rule).
// foldChanges records which paths a check-in touched — the file itself
// and every ancestor directory, so a directory's "last change" is the
// same exact-path lookup as a file's.
func foldChanges(tx *sql.Tx, hash string, art *artifact.Artifact, changes []string) error {
if art == nil {
return nil
}
return foldChangePaths(tx, hash, art.Date, changes)
}
func foldChangePaths(tx *sql.Tx, hash, date string, changes []string) error {
if len(changes) == 0 {
return nil
}
seen := map[string]bool{}
for _, path := range changes {
for p := path; p != ""; {
if seen[p] {
break // shared ancestors are already recorded
}
seen[p] = true
if _, err := tx.Exec(`INSERT INTO filechange (path, checkin, date)
VALUES (?, ?, ?) ON CONFLICT (path, checkin) DO NOTHING`,
p, hash, date); err != nil {
return err
}
i := strings.LastIndexByte(p, '/')
if i < 0 {
break
}
p = p[:i]
}
}
return nil
}
// LastChange is the newest check-in that touched path (a file or a
// directory), optionally restricted to one branch. The answer comes
// from the filechange fold; a repository indexed before that fold
// existed simply reports nothing until its next rebuild.
func (ix *Index) LastChange(path, branch string) (TimelineEntry, bool, error) {
q := `SELECT c.hash, c.date, c.user, c.comment, c.branch
FROM filechange f JOIN checkin c ON c.hash = f.checkin
WHERE f.path = ?`
args := []any{path}
if branch != "" {
q += ` AND c.branch = ?`
args = append(args, branch)
}
q += ` ORDER BY f.date DESC LIMIT 1`
var e TimelineEntry
err := ix.db.QueryRow(q, args...).
Scan(&e.Hash, &e.Date, &e.User, &e.Comment, &e.Branch)
if errors.Is(err, sql.ErrNoRows) {
return e, false, nil
}
return e, err == nil, err
}
// DefaultBranch is the branch a nameless request means: among trunk,
// main and master, whichever exists with the NEWEST tip — a repository
// that renamed its trunk (tk, tcl) keeps an old literal "trunk" in the
// branch table forever, and recency is what distinguishes the alias
// from the artifact. No candidate at all falls back to the newest
// branch, then to "trunk" for an empty repository.
func (ix *Index) DefaultBranch() string {
var b string
err := ix.db.QueryRow(`SELECT branch FROM checkin
WHERE branch IN ('trunk', 'main', 'master')
ORDER BY date DESC LIMIT 1`).Scan(&b)
if errors.Is(err, sql.ErrNoRows) {
err = ix.db.QueryRow(`SELECT branch FROM checkin
ORDER BY date DESC LIMIT 1`).Scan(&b)
}
if err != nil || b == "" {
return "trunk"
}
return b
}
func (ix *Index) CheckinByPrefix(prefix string) (TimelineEntry, []string, bool, error) {
var e TimelineEntry
rows, err := ix.db.Query(`SELECT hash, date, user, comment, branch FROM checkin
WHERE hash LIKE ? || '%' LIMIT 2`, prefix)
if err != nil {
return e, nil, false, err
}
defer rows.Close()
n := 0
for rows.Next() {
n++
if n > 1 {
return TimelineEntry{}, nil, false, nil // ambiguous
}
if err := rows.Scan(&e.Hash, &e.Date, &e.User, &e.Comment, &e.Branch); err != nil {
return e, nil, false, err
}
}
if err := rows.Err(); err != nil || n == 0 {
return e, nil, false, err
}
prows, err := ix.db.Query(`SELECT parent FROM checkin_parent WHERE child = ? ORDER BY ord`, e.Hash)
if err != nil {
return e, nil, false, err
}
defer prows.Close()
var parents []string
for prows.Next() {
var p string
if err := prows.Scan(&p); err != nil {
return e, nil, false, err
}
parents = append(parents, p)
}
return e, parents, true, prows.Err()
}
// WikiTitle is one page in the wiki listing: its name and latest edit.
type WikiTitle struct {
Title string `json:"title"`
Date string `json:"date"`
User string `json:"user"`
}
// WikiTitles lists every wiki page by its most recent edit (fossil
// semantics: latest wins), sorted by title for determinism.
// EventByPrefix resolves an artifact hash prefix to the event that
// carries it — the /info fallback for artifacts that are not check-ins
// or tickets (wiki edits, technotes, forum posts). Ambiguous prefixes
// resolve to nothing, like every other prefix lookup here.
func (ix *Index) EventByPrefix(prefix string) (kind, ref string, ok bool, err error) {
rows, err := ix.db.Query(`SELECT kind, ref FROM event
WHERE hash LIKE ? || '%' LIMIT 2`, prefix)
if err != nil {
return "", "", false, err
}
defer rows.Close()
n := 0
for rows.Next() {
n++
if n > 1 {
return "", "", false, nil
}
if err := rows.Scan(&kind, &ref); err != nil {
return "", "", false, err
}
}
return kind, ref, n == 1, rows.Err()
}
func (ix *Index) WikiTitles() ([]WikiTitle, error) {
// Fossil deletes a page by writing an edit with an EMPTY body, so
// "live" means the newest edit says something. The bare body column
// rides SQLite's single-max() rule the same way user already does:
// both come from the row max() selected. History is untouched — a
// deleted page's revisions stay readable; this is a listing rule.
rows, err := ix.db.Query(`SELECT title, max(date || ' ' || hash), user FROM wiki_edit
GROUP BY title HAVING length(body) > 0 ORDER BY title`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []WikiTitle
for rows.Next() {
var w WikiTitle
var dateHash string
if err := rows.Scan(&w.Title, &dateHash, &w.User); err != nil {
return nil, err
}
w.Date, _, _ = strings.Cut(dateHash, " ")
out = append(out, w)
}
return out, rows.Err()
}
// WikiEdit is one page's content at one revision.
type WikiEdit struct {
Hash string
Title string
Date string
User string
Mimetype string
Body []byte
}
// WikiPage returns the latest edit of one page; ok is false when the
// page does not exist. Date ties break by hash, deterministically.
func (ix *Index) WikiPage(title string) (WikiEdit, bool, error) {
return ix.WikiRevision(title, "")
}
// WikiRevision returns one edit of a page: the revision whose artifact
// hash starts with rev, or the latest when rev is empty. ok is false
// when nothing matches (an ambiguous prefix picks nothing).
func (ix *Index) WikiRevision(title, rev string) (WikiEdit, bool, error) {
var w WikiEdit
var err error
if rev == "" {
err = ix.db.QueryRow(`SELECT hash, title, date, user, mimetype, body FROM wiki_edit
WHERE title = ? ORDER BY date DESC, hash DESC LIMIT 1`, title).
Scan(&w.Hash, &w.Title, &w.Date, &w.User, &w.Mimetype, &w.Body)
} else {
rows, qerr := ix.db.Query(`SELECT hash, title, date, user, mimetype, body FROM wiki_edit
WHERE title = ? AND hash LIKE ? || '%' LIMIT 2`, title, rev)
if qerr != nil {
return w, false, qerr
}
defer rows.Close()
n := 0
for rows.Next() {
n++
if n > 1 {
return WikiEdit{}, false, nil // ambiguous
}
if err := rows.Scan(&w.Hash, &w.Title, &w.Date, &w.User, &w.Mimetype, &w.Body); err != nil {
return w, false, err
}
}
if n == 0 {
return w, false, rows.Err()
}
return w, true, rows.Err()
}
if err == sql.ErrNoRows {
return w, false, nil
}
return w, err == nil, err
}
// WikiRev is one entry in a page's edit history.
type WikiRev struct {
Hash string `json:"hash"`
Date string `json:"date"`
User string `json:"user"`
}
// WikiHistory lists every recorded edit of one page, newest first.
func (ix *Index) WikiHistory(title string) ([]WikiRev, error) {
rows, err := ix.db.Query(`SELECT hash, date, user FROM wiki_edit
WHERE title = ? ORDER BY date DESC, hash DESC`, title)
if err != nil {
return nil, err
}
defer rows.Close()
var out []WikiRev
for rows.Next() {
var r WikiRev
if err := rows.Scan(&r.Hash, &r.Date, &r.User); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// Technote is one technote at its latest edit.
type Technote struct {
Hash string `json:"hash"`
Event string `json:"event"`
Date string `json:"date"`
User string `json:"user"`
Mimetype string `json:"mimetype"`
Comment string `json:"comment"` // C card: the timeline/listing title
Body string `json:"body"`
}
// Technotes lists each technote at its latest edit, newest event first.
func (ix *Index) Technotes() ([]Technote, error) {
rows, err := ix.db.Query(`SELECT hash, event, date, user, mimetype, comment, body FROM technote t
WHERE edited = (SELECT max(t2.edited) FROM technote t2 WHERE t2.event = t.event)
GROUP BY event
ORDER BY date DESC, event`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Technote
for rows.Next() {
var n Technote
if err := rows.Scan(&n.Hash, &n.Event, &n.Date, &n.User, &n.Mimetype, &n.Comment, &n.Body); err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}
// TechnoteByEvent returns the latest edit of one technote by event-id
// prefix; ok is false on zero or ambiguous matches.
func (ix *Index) TechnoteByEvent(prefix string) (Technote, bool, error) {
rows, err := ix.db.Query(`SELECT hash, event, date, user, mimetype, comment, body FROM technote t
WHERE event LIKE ? || '%'
AND edited = (SELECT max(t2.edited) FROM technote t2 WHERE t2.event = t.event)
GROUP BY event LIMIT 2`, prefix)
if err != nil {
return Technote{}, false, err
}
defer rows.Close()
var found []Technote
for rows.Next() {
var n Technote
if err := rows.Scan(&n.Hash, &n.Event, &n.Date, &n.User, &n.Mimetype, &n.Comment, &n.Body); err != nil {
return Technote{}, false, err
}
found = append(found, n)
}
if len(found) != 1 {
return Technote{}, false, rows.Err()
}
return found[0], true, rows.Err()
}
// ForumThread is a thread summary for the forum listing.
type ForumThread struct {
Root string `json:"root"`
Title string `json:"title"`
Posts int `json:"posts"`
Updated string `json:"updated"`
User string `json:"user"` // opener
}
// ForumThreads lists threads, most-recently-active first. The title is
// the opener's H card; a titleless thread falls back to its first line.
func (ix *Index) ForumThreads() ([]ForumThread, error) {
rows, err := ix.db.Query(`SELECT p.thread,
COALESCE((SELECT CASE WHEN o.title != '' THEN o.title
ELSE substr(o.body, 1, 80) END FROM forum_post o WHERE o.hash = p.thread), ''),
count(*), max(p.date),
COALESCE((SELECT o.user FROM forum_post o WHERE o.hash = p.thread), '')
FROM forum_post p
GROUP BY p.thread
ORDER BY max(p.date) DESC, p.thread`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ForumThread
for rows.Next() {
var t ForumThread
if err := rows.Scan(&t.Root, &t.Title, &t.Posts, &t.Updated, &t.User); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// ForumPost is one post within a thread.
type ForumPost struct {
Hash string `json:"hash"`
Parent string `json:"parent"`
Date string `json:"date"`
User string `json:"user"`
Mimetype string `json:"mimetype"`
Body string `json:"body"`
}
// ForumThreadPosts returns every post of a thread resolved by root-hash
// prefix, oldest first (reading order); ok is false on no match.
func (ix *Index) ForumThreadPosts(prefix string) (title string, posts []ForumPost, ok bool, err error) {
var root string
err = ix.db.QueryRow(`SELECT DISTINCT thread FROM forum_post
WHERE thread LIKE ? || '%' LIMIT 1`, prefix).Scan(&root)
if err == sql.ErrNoRows {
return "", nil, false, nil
}
if err != nil {
return "", nil, false, err
}
ix.db.QueryRow(`SELECT CASE WHEN title != '' THEN title ELSE substr(body, 1, 80) END
FROM forum_post WHERE hash = ?`, root).Scan(&title)
rows, err := ix.db.Query(`SELECT hash, parent, date, user, mimetype, body FROM forum_post
WHERE thread = ? ORDER BY date, hash`, root)
if err != nil {
return "", nil, false, err
}
defer rows.Close()
for rows.Next() {
var p ForumPost
if err := rows.Scan(&p.Hash, &p.Parent, &p.Date, &p.User, &p.Mimetype, &p.Body); err != nil {
return "", nil, false, err
}
posts = append(posts, p)
}
return title, posts, len(posts) > 0, rows.Err()
}
// TicketChange is one recorded change with the fields it set.
type TicketChange struct {
Hash string `json:"hash"`
Date string `json:"date"`
User string `json:"user"`
Fields []TicketFieldSet `json:"fields"`
}
// TicketFieldSet is one field assignment within a change.
type TicketFieldSet struct {
Name string `json:"name"`
Value string `json:"value"`
}
// TicketState is a ticket's folded state: every field's latest value
// (fossil semantics), plus the change history in order.
type TicketState struct {
ID string `json:"id"`
Created string `json:"created"`
Updated string `json:"updated"`
// Fields is the folded scalar state — status, priority, resolution
// and the rest. Per-change plumbing is NOT here: see Comments.
Fields []TicketFieldSet `json:"fields"`
// Description is the ticket's body (fossil's `comment` field) and
// DescriptionMimetype is how to render it (`cmimetype`).
Description string `json:"description"`
DescriptionMimetype string `json:"descriptionMimetype"`
// Comments is the discussion, oldest first. Fossil keeps each change's
// `icomment` on the CHANGE and never folds it into ticket state; a
// fold would leave one comment standing where a conversation was.
Comments []TicketComment `json:"comments"`
Changes []TicketChange `json:"changes"`
}
// TicketComment is one entry in a ticket's discussion.
type TicketComment struct {
Hash string `json:"hash"`
Date string `json:"date"`
// Login is the authenticated account; Username is what the commenter
// typed. Fossil shows both when they differ ("claiming to be"),
// because on an open tracker they need not agree.
Login string `json:"login"`
Username string `json:"username"`
Mimetype string `json:"mimetype"`
Body string `json:"body"`
}
// perChange names the ticket fields that describe ONE change rather than
// the ticket's state. Folding them produces a field table full of
// plumbing and, worse, collapses a whole discussion into whichever
// comment happened to be last.
// cmimetype is deliberately absent: it describes the FOLDED comment, so
// it is ticket state and folds with it.
var perChange = map[string]bool{
"icomment": true, "mimetype": true, "login": true, "username": true,
}
// TicketSummary is the list-surface fold: current title and status
// without materializing change histories.
type TicketSummary struct {
ID string `json:"id"`
Created string `json:"created"`
Updated string `json:"updated"`
Title string `json:"title"`
Status string `json:"status"`
Type string `json:"type,omitempty"`
Severity string `json:"severity,omitempty"`
Priority string `json:"priority,omitempty"`
Subsystem string `json:"subsystem,omitempty"`
Resolution string `json:"resolution,omitempty"`
}
// TicketSummaries reads the folded ticket table — newest-updated first,
// id as tiebreak so rebuilds page identically. before is a composite
// "updated~ticket" cursor (empty = first page); more reports whether an
// older page follows.
// ClosedStatuses are the status words a tracker uses to mean "done".
// Fossil ticket schemas define their own vocabulary, so this cannot be
// exhaustive — it recognizes the conventional words and treats anything
// else as open, which errs toward SHOWING a ticket rather than hiding
// one. A hidden ticket is invisible; a wrongly-shown one is merely
// noise.
var ClosedStatuses = []string{"closed", "fixed", "verified", "resolved",
"deferred", "rejected", "duplicate", "deleted", "wontfix"}
// closedFilter is the SQL for "not one of the done words", case-folded
// because a repository holding both "Closed" and "closed" is real —
// quarry's own does.
func closedFilter() string {
var q []string
for _, s := range ClosedStatuses {
q = append(q, "'"+s+"'")
}
return "lower(status) NOT IN (" + strings.Join(q, ",") + ")"
}
func (ix *Index) TicketSummaries(limit int, before string, includeClosed bool) ([]TicketSummary, bool, error) {
if limit <= 0 {
limit = 50 // the form codec defaults this; direct calls get the same
}
bu, bt, _ := strings.Cut(before, "~")
// Open-only by default: a tracker's list is a working surface, and
// sqlite's is 50% closed, tk's 44%. Showing everything makes the
// live work the minority of the page.
// A ticket its own tracker marked private is never listed. quarry
// has no capability to grant an exception to yet, so this is
// unconditional — the same direction taken for private_contact.
where := "private = 0 AND (?1 = '' OR updated < ?1 OR (updated = ?1 AND id > ?2))"
if !includeClosed {
where += " AND " + closedFilter()
}
// An index walk over the folded summaries, stopping at the limit.
// The cursor is a WHERE, not a HAVING, so paging actually skips work
// instead of grouping the whole table and discarding most of it.
rows, err := ix.db.Query(`
SELECT id, created, updated, title, status,
type, severity, priority, subsystem, resolution FROM ticket
WHERE `+where+`
ORDER BY updated DESC, id
LIMIT ?3`, bu, bt, limit+1)
if err != nil {
return nil, false, err
}
defer rows.Close()
var out []TicketSummary
for rows.Next() {
var t TicketSummary
if err := rows.Scan(&t.ID, &t.Created, &t.Updated, &t.Title, &t.Status,
&t.Type, &t.Severity, &t.Priority, &t.Subsystem, &t.Resolution); err != nil {
return nil, false, err
}
out = append(out, t)
}
more := len(out) > limit
if more {
out = out[:limit]
}
return out, more, rows.Err()
}
// ticketStates folds change histories into current state for tickets
// matching the WHERE clause. Change order is (date, hash) so the fold is
// deterministic across rebuilds.
func (ix *Index) ticketStates(where string, args ...any) ([]TicketState, error) {
rows, err := ix.db.Query(`
SELECT c.ticket, c.hash, c.date, c.user, f.name, f.value
FROM ticket_change c JOIN ticket_field f ON f.hash = c.hash
`+where+`
ORDER BY c.ticket, c.date, c.hash, f.name`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TicketState
var cur *TicketState
for rows.Next() {
var ticket, hash, date, user, name, value string
if err := rows.Scan(&ticket, &hash, &date, &user, &name, &value); err != nil {
return nil, err
}
if cur == nil || cur.ID != ticket {
out = append(out, TicketState{ID: ticket, Created: date})
cur = &out[len(out)-1]
}
cur.Updated = date
if n := len(cur.Changes); n == 0 || cur.Changes[n-1].Hash != hash {
cur.Changes = append(cur.Changes, TicketChange{Hash: hash, Date: date, User: user})
}
ch := &cur.Changes[len(cur.Changes)-1]
ch.Fields = append(ch.Fields, TicketFieldSet{Name: name, Value: value})
if perChange[name] {
continue // gathered per change below, never folded
}
// A leading + is fossil's APPEND form: "J +comment more" adds to
// the existing comment rather than replacing it, which is how a
// description grows over a ticket's life. Treating it as a
// distinct field named "+comment" both loses the append and puts
// a nonsense row in the field table.
target, appending := strings.CutPrefix(name, "+")
set := false
for i := range cur.Fields {
if cur.Fields[i].Name != target {
continue
}
if appending {
cur.Fields[i].Value += value
} else {
cur.Fields[i].Value = value
}
set = true
}
if !set {
cur.Fields = append(cur.Fields, TicketFieldSet{Name: target, Value: value})
}
}
// Second pass: the discussion, and the description lifted out of the
// field table so a page can render it as prose rather than a cell.
for i := range out {
t := &out[i]
for _, ch := range t.Changes {
var body, mimetype, login, username string
for _, f := range ch.Fields {
switch f.Name {
case "icomment":
body = f.Value
case "mimetype":
mimetype = f.Value
case "login":
login = f.Value
case "username":
username = f.Value
}
}
if body == "" {
continue // fossil lists only changes that said something
}
if login == "" {
login = ch.User
}
t.Comments = append(t.Comments, TicketComment{
Hash: ch.Hash, Date: ch.Date, Login: login,
Username: username, Mimetype: mimetype, Body: body,
})
}
kept := t.Fields[:0]
for _, f := range t.Fields {
switch f.Name {
case "comment":
t.Description = f.Value
case "cmimetype":
t.DescriptionMimetype = f.Value
default:
kept = append(kept, f)
}
}
t.Fields = kept
}
return out, rows.Err()
}
// TicketPrivate reports whether a ticket's tracker marked it private.
// An id the folded table does not know answers PRIVATE: this gates a
// read, and for a value whose whole purpose is to withhold, the failure
// direction must withhold too.
func (ix *Index) TicketPrivate(id string) (bool, error) {
var private int
err := ix.db.QueryRow(`SELECT private FROM ticket WHERE id = ?`, id).Scan(&private)
if err == sql.ErrNoRows {
return true, nil
}
return private != 0, err
}
// TicketByPrefix folds one ticket resolved by id prefix; ok is false on
// zero or multiple matches — including a match its tracker marked
// private, which reports NOT FOUND so an anonymous reader cannot even
// confirm it exists (the rule repoFor applies to private repositories,
// for the same reason: a refusal confirms the thing exists).
//
// Token-gated writes resolve with TicketByPrefixAll: erring toward
// private is only safe if un-privating stays possible, and a private
// ticket nobody can address is one nobody can ever make public again.
func (ix *Index) TicketByPrefix(prefix string) (TicketState, bool, error) {
t, ok, err := ix.TicketByPrefixAll(prefix)
if err != nil || !ok {
return TicketState{}, false, err
}
for _, f := range t.Fields {
if f.Name == "is_private" && boolField(f.Value) == 1 {
return TicketState{}, false, nil
}
}
return t, true, nil
}
// TicketByPrefixAll is TicketByPrefix without the private refusal, for
// the token-gated write path only. Never serve its result to a reader.
func (ix *Index) TicketByPrefixAll(prefix string) (TicketState, bool, error) {
all, err := ix.ticketStates(`WHERE c.ticket IN (
SELECT DISTINCT ticket FROM ticket_change WHERE ticket LIKE ? || '%')`, prefix)
if err != nil {
return TicketState{}, false, err
}
var found *TicketState
for i := range all {
if strings.HasPrefix(all[i].ID, prefix) {
if found != nil {
return TicketState{}, false, nil // ambiguous
}
found = &all[i]
}
}
if found == nil {
return TicketState{}, false, nil
}
return *found, true, nil
}
// Timeline lists events newest first; ties break by hash so the
// listing is deterministic across rebuilds (the disposability
// property). kind filters to "ci", "w", or "t" ("" or "all" means
// every kind); a non-empty branch restricts to that branch's check-ins
// (fossil's rule). before is a cursor from a previous page's Next —
// "date~hash" — with a bare check-in hash prefix accepted for
// compatibility; more reports whether older events remain.
func (ix *Index) Timeline(limit int, before, branch, kind string) ([]TimelineEntry, bool, error) {
// Conditions and their arguments are appended TOGETHER so the two
// lists cannot drift out of step. The previous form built them in
// separate passes and then reassembled the arguments in a third
// place, in an order a comment had to explain.
var conds []string
var all []any
if branch != "" {
conds = append(conds, `e.branch = ?`)
all = append(all, branch)
kind = "ci" // a branch filter implies check-ins only
}
if before != "" {
bDate, bHash, ok := strings.Cut(before, "~")
if !ok {
b, _, found, err := ix.CheckinByPrefix(before)
if err != nil {
return nil, false, err
}
if !found {
return nil, false, nil // unknown cursor: empty page, not an error
}
bDate, bHash = b.Date, b.Hash
}
conds = append(conds, `(e.date < ? OR (e.date = ? AND e.hash < ?))`)
all = append(all, bDate, bDate, bHash)
}
if kind != "" && kind != "all" {
conds = append(conds, `e.kind = ?`)
all = append(all, kind)
}
where := ""
if len(conds) > 0 {
where = "WHERE " + strings.Join(conds, " AND ")
}
all = append(all, limit+1)
// One index walk over the denormalized timeline, stopping at the
// limit. The two per-row extras are evaluated for the RETURNED rows
// only, which is the whole difference from the old plan:
//
// isleaf — ALWAYS FALSE here. Leaf status is repo-state, which the
// repo-state RFC defines and zit already derives and caches;
// the API decorates these rows from zit's answer. This index
// used to compute it as "nothing lists it as a parent", which
// §7.2 names explicitly as NOT the canonical test — the real
// one is "no child on the same branch", and open leaves also
// exclude an active closed tag. On fossil that was 490 marked
// against a true 1432, and a different set. The column stays so
// the row shape is unchanged; the value is filled by the caller
// that can ask the store.
// ticket label — lives in the folded ticket doc, refreshed after
// the change rows land, so it is joined here rather than written
// by the trigger, which would capture a stale value.
rows, err := ix.db.Query(`
SELECT e.kind, e.hash, e.ref, e.date, e.user,
CASE WHEN e.kind = 't'
THEN 'Ticket [' || substr(e.ref, 1, 10) || '] ' || COALESCE(d.label, '')
ELSE e.comment END,
e.branch,
0
FROM event e
LEFT JOIN search_doc d ON e.kind = 't' AND d.hash = 'ticket:' || e.ref
`+where+` ORDER BY e.date DESC, e.hash DESC LIMIT ?`, all...)
if err != nil {
return nil, false, err
}
defer rows.Close()
var out []TimelineEntry
for rows.Next() {
var e TimelineEntry
if err := rows.Scan(&e.Kind, &e.Hash, &e.Ref, &e.Date, &e.User, &e.Comment, &e.Branch, &e.IsLeaf); err != nil {
return nil, false, err
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, false, err
}
more := false
if len(out) > limit {
out, more = out[:limit], true
}
if err := ix.fillParents(out); err != nil {
return nil, false, err
}
if err := ix.AttachCI(out); err != nil {
return nil, false, err
}
return out, more, nil
}
// fillParents attaches each entry's ordered parent list (primary first)
// and the merge flag — the graph's edge data.
func (ix *Index) fillParents(entries []TimelineEntry) error {
byHash := map[string]*TimelineEntry{}
for i := range entries {
if entries[i].Kind != "" && entries[i].Kind != "ci" {
continue // only check-ins have DAG edges
}
byHash[entries[i].Hash] = &entries[i]
}
for hash, e := range byHash {
rows, err := ix.db.Query(`SELECT parent FROM checkin_parent
WHERE child = ? ORDER BY ord`, hash)
if err != nil {
return err
}
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
rows.Close()
return err
}
e.Parents = append(e.Parents, p)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
e.IsMerge = len(e.Parents) > 1
}
return nil
}
// LastEvent returns the newest event's date — what a forge listing sorts
// activity on. One index walk stopping at the first row.
func (ix *Index) LastEvent() (string, bool, error) {
var date string
err := ix.db.QueryRow(`SELECT date FROM event ORDER BY date DESC LIMIT 1`).Scan(&date)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
return date, err == nil, err
}
// CheckinCount reports the indexed check-in total — the fleet page's
// headline number.
func (ix *Index) CheckinCount() (int, error) {
var n int
err := ix.db.QueryRow(`SELECT count(*) FROM checkin`).Scan(&n)
return n, err
}
// Attachment is one live attachment on a ticket, wiki page or technote.
type Attachment struct {
Hash string `json:"hash"`
Name string `json:"name"`
Src string `json:"src"`
Date string `json:"date"`
User string `json:"user"`
}
// AttachmentsFor returns the attachments currently live on target,
// newest first. Fossil's model is a log, not a set: each A card names a
// filename on a target, and a card with NO source removes it. So the
// answer is the latest card per filename, minus the ones whose latest
// card is a deletion — an attachment replaced twice and then deleted
// must not come back.
func (ix *Index) AttachmentsFor(target string) ([]Attachment, error) {
rows, err := ix.db.Query(`
SELECT a.hash, a.name, a.src, a.date, a.user
FROM attachment a
JOIN (SELECT name, max(date) AS d FROM attachment
WHERE target = ? GROUP BY name) latest
ON latest.name = a.name AND latest.d = a.date
WHERE a.target = ? AND a.src <> ''
ORDER BY a.date DESC, a.name`, target, target)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Attachment
for rows.Next() {
var at Attachment
if err := rows.Scan(&at.Hash, &at.Name, &at.Src, &at.Date, &at.User); err != nil {
return nil, err
}
out = append(out, at)
}
return out, rows.Err()
}
// CountAttachments reports how many attachment artifacts are indexed —
// the census figure to compare against fossil's own attachment table.
func (ix *Index) CountAttachments() (int, error) {
var n int
err := ix.db.QueryRow(`SELECT count(*) FROM attachment`).Scan(&n)
return n, err
}
// AttachmentName resolves a content hash to the filename it is attached
// under, and reports whether it is a LIVE attachment. This is the gate
// on serving attachment bytes: without it the download route would
// serve any artifact in the store by hash, including check-in manifests
// and file blobs that were never attached to anything.
//
// A hash whose newest card is a deletion answers false — the file was
// taken down, and taken-down content must not stay reachable by URL.
func (ix *Index) AttachmentName(src string) (string, bool, error) {
var name, target string
err := ix.db.QueryRow(`SELECT name, target FROM attachment
WHERE src = ? ORDER BY date DESC LIMIT 1`, src).Scan(&name, &target)
if err == sql.ErrNoRows {
return "", false, nil
}
if err != nil {
return "", false, err
}
// Live means: the newest card for this (target, name) still carries a
// source. Re-checking by name rather than trusting the row above is
// what makes a later deletion win.
var newest string
if err := ix.db.QueryRow(`SELECT src FROM attachment
WHERE target = ? AND name = ? ORDER BY date DESC LIMIT 1`,
target, name).Scan(&newest); err != nil {
return "", false, err
}
if newest == "" {
return "", false, nil
}
return name, true, nil
}
// Branch is one branch and the check-in at its tip.
//
// zit permits two branches to carry the SAME name (0002 §1: forgejo
// assumes names are unique and quarry cannot), so a selector keyed on
// name alone would silently pick one. Tip disambiguates them.
type Branch struct {
Name string `json:"name"`
Tip string `json:"tip"`
Date string `json:"date"`
// Closed is fossil's branch lifecycle: the newest "closed" tag on
// the tip, with its sign — a cancelled closure (-closed) reopens.
Closed bool `json:"closed,omitempty"`
}
// Branches lists each branch with its newest check-in, most recently
// active first.
func (ix *Index) Branches() ([]Branch, error) {
// One grouped scan, not a correlated subquery per row. SQLite's
// bare-column rule picks the row that produced max(date), so the
// hash belongs to the tip it reports.
//
// The first form of this cost 434 SECONDS on tcl (42,074 check-ins,
// 1,855 branches): the subquery re-scanned the table once per row.
// It is the reason checkin_by_branch exists.
rows, err := ix.db.Query(`
SELECT b.branch, b.hash, b.d,
COALESCE((SELECT t.sign FROM checkin_tag t
WHERE t.checkin = b.hash AND t.name = 'closed'
ORDER BY t.date DESC LIMIT 1), '') = '+'
FROM (SELECT branch, hash, max(date) AS d FROM checkin
GROUP BY branch) b
ORDER BY b.d DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Branch
for rows.Next() {
var b Branch
if err := rows.Scan(&b.Name, &b.Tip, &b.Date, &b.Closed); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
// TipOf returns the newest check-in on a branch.
func (ix *Index) TipOf(branch string) (Branch, bool, error) {
var b Branch
err := ix.db.QueryRow(`SELECT branch, hash, date FROM checkin
WHERE branch = ? ORDER BY date DESC LIMIT 1`, branch).Scan(&b.Name, &b.Tip, &b.Date)
if err == sql.ErrNoRows {
return Branch{}, false, nil
}
return b, err == nil, err
}
// CheckinsAfter returns check-ins newer than the named one, oldest
// first, so a consumer processes them in the order they were made.
//
// Ordering is by date, not by DAG reachability: a watermark is "how far
// have I looked", and a check-in that arrives by sync with an older
// date than the watermark is genuinely older work. Walking descendants
// instead would re-run branches that merged later.
//
// An unknown watermark returns only the tip — the safe answer, because
// the alternative is treating the entire history as new.
func (ix *Index) CheckinsAfter(hash string, limit int) ([]TimelineEntry, error) {
if limit <= 0 {
limit = 20
}
var date string
if hash != "" {
if err := ix.db.QueryRow(`SELECT date FROM checkin WHERE hash = ?`, hash).Scan(&date); err != nil {
date = ""
}
}
q := `SELECT hash, date, user, comment, branch FROM checkin ORDER BY date DESC LIMIT 1`
args := []any{}
if date != "" {
q = `SELECT hash, date, user, comment, branch FROM checkin
WHERE date > ? ORDER BY date ASC LIMIT ` + itoa(limit)
args = append(args, date)
}
rows, err := ix.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TimelineEntry
for rows.Next() {
var e TimelineEntry
if err := rows.Scan(&e.Hash, &e.Date, &e.User, &e.Comment, &e.Branch); err != nil {
return nil, err
}
e.Kind = "ci"
out = append(out, e)
}
return out, rows.Err()
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
// ciVerdicts returns the recorded CI verdict per check-in hash, for the
// hashes given. One query rather than one per row: a timeline page asks
// about fifty check-ins at once.
//
// The newest verdict wins — a re-run records a fresh tag, and the last
// word is the current answer.
func (ix *Index) ciVerdicts(hashes []string) (map[string]string, error) {
if len(hashes) == 0 {
return nil, nil
}
q := `SELECT checkin, name FROM checkin_tag
WHERE name IN ('ci-pass','ci-fail') AND checkin IN (?` +
strings.Repeat(",?", len(hashes)-1) + `) ORDER BY date ASC`
args := make([]any, 0, len(hashes))
for _, h := range hashes {
args = append(args, h)
}
rows, err := ix.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var checkin, name string
if err := rows.Scan(&checkin, &name); err != nil {
return nil, err
}
out[checkin] = name // ascending date: the last row seen is newest
}
return out, rows.Err()
}
// AttachCI fills the CI field on timeline entries.
func (ix *Index) AttachCI(entries []TimelineEntry) error {
hashes := make([]string, 0, len(entries))
for _, e := range entries {
if e.Kind == "ci" || e.Kind == "" {
hashes = append(hashes, e.Hash)
}
}
verdicts, err := ix.ciVerdicts(hashes)
if err != nil || len(verdicts) == 0 {
return err
}
for i := range entries {
if v, ok := verdicts[entries[i].Hash]; ok {
entries[i].CI = v
}
}
return nil
}