Skip to content
quarry Artifact [75c0a138ea]
Login

Artifact [75c0a138ea]

Artifact 75c0a138eadad0b8f83de7cb7b7d4954c59c450f847197fe6d917c01c48f482a:


// stamp.go stores stamp — the forge's "this is worth your attention"
// signal. Forge-local state, never part of the DAG (0001 §8), for the
// same reason forum votes are: a clone of the store must carry the work,
// not the applause.
package oltp

import (
	"errors"
	"time"
)

const stampSchema = `
CREATE TABLE IF NOT EXISTS stamp (
	repo    TEXT NOT NULL,
	account TEXT NOT NULL,
	at      TEXT NOT NULL,
	-- The key IS the idempotency, exactly as forum_vote's is: one row per
	-- account per repository. Praising twice is the same fact, and
	-- withdrawing is a delete rather than a second row saying no.
	PRIMARY KEY (repo, account)
);
CREATE INDEX IF NOT EXISTS stamp_by_account ON stamp (account, at DESC);`

// SetStamp records or withdraws one account's stamp. Returns whether
// the repository is stamped by that account afterwards, so a caller can
// name the event it raises without asking again.
func (d *DB) SetStamp(repo, account string, on bool) (bool, error) {
	if repo == "" || account == "" {
		return false, errors.New("oltp: stamp needs a repo and an account")
	}
	if !on {
		_, err := d.w.Exec(`DELETE FROM stamp WHERE repo = ? AND account = ?`, repo, account)
		return false, err
	}
	// Insert-or-ignore, not upsert: re-praising must not move the date,
	// or a listing ordered by when you stamped something would reshuffle
	// every time somebody double-clicked.
	_, err := d.w.Exec(`INSERT OR IGNORE INTO stamp (repo, account, at) VALUES (?, ?, ?)`,
		repo, account, time.Now().UTC().Format(time.RFC3339))
	return true, err
}

// carryPraise moves rows from the table this feature used to be called,
// once, on open. Stamps were praise until 2026-09-05; the rename is
// cosmetic to a reader and total to a schema, so the rows have to walk
// across rather than be lost.
//
// Guarded by the table's existence rather than run as a migration
// statement: the migration runner treats anything but a duplicate
// column as fatal, and a fresh store has no praise table to copy from.
// Idempotent, because after the drop the guard never fires again.
func (d *DB) carryPraise() error {
	var name string
	err := d.db.QueryRow(
		`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'praise'`).Scan(&name)
	if err != nil {
		return nil // no such table: a store that never knew praise
	}
	if _, err := d.w.Exec(
		`INSERT OR IGNORE INTO stamp (repo, account, at) SELECT repo, account, at FROM praise`); err != nil {
		return err
	}
	_, err = d.w.Exec(`DROP TABLE praise`)
	return err
}

// Stamped reports whether one account stamps one repository.
func (d *DB) Stamped(repo, account string) bool {
	if account == "" {
		return false
	}
	var one int
	err := d.db.QueryRow(`SELECT 1 FROM stamp WHERE repo = ? AND account = ?`,
		repo, account).Scan(&one)
	return err == nil
}

// StampCount is how many accounts stamp a repository.
func (d *DB) StampCount(repo string) int {
	var n int
	if err := d.db.QueryRow(`SELECT count(*) FROM stamp WHERE repo = ?`, repo).Scan(&n); err != nil {
		return 0
	}
	return n
}

// StampedBy lists the repositories one account stamps, newest first.
//
// It returns names only. Deciding which of them the VIEWER may see is
// the caller's job and must not be skipped: stamp is public, but a
// private repository's existence is not, and the two meet here. An
// account's own list and a stranger's view of it are different lists.
func (d *DB) StampedBy(account string) ([]string, error) {
	if account == "" {
		return nil, nil
	}
	rows, err := d.db.Query(`SELECT repo FROM stamp WHERE account = ? ORDER BY at DESC`, account)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []string
	for rows.Next() {
		var repo string
		if err := rows.Scan(&repo); err != nil {
			return nil, err
		}
		out = append(out, repo)
	}
	return out, rows.Err()
}