Skip to content
quarry Artifact [814a32cb88]
Login

Artifact [814a32cb88]

Artifact 814a32cb88727f49924416fdaf120dedd7083576a59aba119c8c8ad348f82742:


// 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"

	_ "github.com/ncruces/go-sqlite3/driver" // database/sql driver "sqlite3"

	"craftdesign.group/quarry/internal/artifact"
)

const schema = `
CREATE TABLE IF NOT EXISTS ingest_state (
	id     INTEGER PRIMARY KEY CHECK (id = 1),
	cursor TEXT NOT NULL,
	verify TEXT NOT NULL
);
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() }

// Cursor returns the persisted ingest position; empty strings mean
// "never ingested — bootstrap from zero".
func (ix *Index) Cursor() (since, verify string, err error) {
	err = ix.db.QueryRow(`SELECT cursor, verify FROM ingest_state WHERE id = 1`).
		Scan(&since, &verify)
	if err == sql.ErrNoRows {
		return "", "", nil
	}
	return since, verify, err
}

// 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 frames batch and advances the cursor, atomically:
// either every artifact lands and the cursor moves, or nothing does.
func (ix *Index) Fold(batch []Parsed, since, verify string) 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 := tx.Exec(`INSERT INTO ingest_state (id, cursor, verify) VALUES (1, ?, ?)
		ON CONFLICT (id) DO UPDATE SET cursor = excluded.cursor, verify = excluded.verify`,
		since, verify); err != nil {
		return 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{
		"ingest_state", "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"`
}

// 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()
}