Artifact 406d6c9dc8ea871b3d90aa742e6aec51221f747e7e86837e72683452a1aeb090:
- File
internal/index/index.go
— part of check-in
[ae50247ab0]
at
2026-08-15 11:42:49
on branch trunk
— wiki: list/get ops, rendered pages, index-backed resolver
Latest-edit-wins queries; markup.Render behind the wiki.get op; [[name]] resolves only to existing pages; /wiki in harness surfaces. (user: dmestas, size: 6612) [annotate] [blame] [check-ins using]
// 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. package index import ( "database/sql" "fmt" "strings" _ "github.com/ncruces/go-sqlite3/driver" // database/sql driver "sqlite3" "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 ); CREATE TABLE IF NOT EXISTS checkin_parent ( child TEXT NOT NULL, parent TEXT NOT NULL, ord INTEGER NOT NULL, PRIMARY KEY (child, ord) ); 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 TABLE IF NOT EXISTS ticket_field ( hash TEXT NOT NULL, name TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (hash, name) ); ` // Index is one repository's derived index. type Index struct { db *sql.DB } // Open opens (creating if needed) the index database at path. func Open(path string) (*Index, error) { db, err := sql.Open("sqlite3", "file:"+path) if err != nil { return nil, err } if _, err := db.Exec(schema); err != nil { db.Close() return nil, fmt.Errorf("index: schema: %w", err) } return &Index{db: db}, nil } 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 } // 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. func (ix *Index) Fold(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) } } return tx.Commit() } func fold(tx *sql.Tx, hash string, a *artifact.Artifact) error { switch a.Type { case artifact.Manifest: if _, err := tx.Exec(`INSERT OR REPLACE INTO checkin (hash, date, user, comment) VALUES (?, ?, ?, ?)`, 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 } } 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.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 } } } // Control, cluster, technote, forum, attachment: folded when their // pages land (phase 2); skipping them is safe because a reindex // replays every artifact. return nil } // Reset drops all derived state — the reindex path (0001 §5). The next // Fold rebuilds from a zero cursor. func (ix *Index) Reset() error { for _, table := range []string{ "checkin", "checkin_parent", "wiki_edit", "ticket_change", "ticket_field", } { if _, err := ix.db.Exec("DELETE FROM " + table); err != nil { return err } } return nil } // TimelineEntry is one check-in for the timeline op. type TimelineEntry struct { Hash string `json:"hash"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` } // 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. func (ix *Index) WikiTitles() ([]WikiTitle, error) { rows, err := ix.db.Query(`SELECT title, max(date || ' ' || hash), user FROM wiki_edit GROUP BY title 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 current content. type WikiEdit struct { 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) { var w WikiEdit err := ix.db.QueryRow(`SELECT title, date, user, mimetype, body FROM wiki_edit WHERE title = ? ORDER BY date DESC, hash DESC LIMIT 1`, title). Scan(&w.Title, &w.Date, &w.User, &w.Mimetype, &w.Body) if err == sql.ErrNoRows { return w, false, nil } return w, err == nil, err } // Timeline lists check-ins newest first; ties break by hash so the // listing is deterministic across rebuilds (the disposability property). func (ix *Index) Timeline(limit int) ([]TimelineEntry, error) { rows, err := ix.db.Query(`SELECT hash, date, user, comment FROM checkin ORDER BY date DESC, hash DESC LIMIT ?`, limit) 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); err != nil { return nil, err } out = append(out, e) } return out, rows.Err() }