// 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.db.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.db.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.db.Exec(
`INSERT OR IGNORE INTO stamp (repo, account, at) SELECT repo, account, at FROM praise`); err != nil {
return err
}
_, err = d.db.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()
}