// attn.go holds the forge-local state RFC 0011 §5's attention stream
// needs and the stream itself cannot carry: how far each account has
// read (attn_read), the per-account preferences a person set for
// themselves (account_pref), and which events a repository's activity
// robot speaks aloud in its channel (chat_robot, §10).
//
// All three are social state, not truth: wiping them loses a read
// pointer, a checkbox and a robot's subscription, never a fact the DAG
// holds (RFC 0001 §11).
package oltp
import "time"
const attnSchema = `
CREATE TABLE IF NOT EXISTS attn_read (
account TEXT NOT NULL PRIMARY KEY,
seq INTEGER NOT NULL, -- highest attention seq the account has seen
at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS account_pref (
account TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (account, key)
);
CREATE TABLE IF NOT EXISTS chat_robot (
repo TEXT NOT NULL PRIMARY KEY,
events TEXT NOT NULL -- webhook pattern syntax; empty means off
);`
// MarkAttentionRead advances an account's attention read pointer, and
// only ever forwards. The pointer is the local unread count's floor —
// "events after this" — so a stale client replaying an older cursor
// must not resurrect mail the person has already dismissed.
func (d *DB) MarkAttentionRead(account string, seq uint64) error {
if account == "" {
return nil
}
_, err := d.db.Exec(`INSERT INTO attn_read (account, seq, at)
VALUES (?, ?, ?)
ON CONFLICT (account) DO UPDATE SET seq = excluded.seq, at = excluded.at
WHERE attn_read.seq < excluded.seq`,
account, seq, time.Now().UTC().Format(time.RFC3339Nano))
return err
}
// AttentionReadSeq answers the account's pointer — 0 for someone who
// has never marked anything, which reads as "everything in the window
// is unread", the correct answer for an inbox nobody has opened.
func (d *DB) AttentionReadSeq(account string) uint64 {
if account == "" {
return 0
}
var seq uint64
d.db.QueryRow(`SELECT seq FROM attn_read WHERE account = ?`, account).Scan(&seq)
return seq
}
// SetAccountPref stores one preference for one account; an empty value
// deletes the row, so "off" is the absence of a preference rather than
// a second spelling of it.
func (d *DB) SetAccountPref(account, key, value string) error {
if account == "" || key == "" {
return nil
}
if value == "" {
_, err := d.db.Exec(`DELETE FROM account_pref WHERE account = ? AND key = ?`, account, key)
return err
}
_, err := d.db.Exec(`INSERT INTO account_pref (account, key, value)
VALUES (?, ?, ?)
ON CONFLICT (account, key) DO UPDATE SET value = excluded.value`,
account, key, value)
return err
}
// AccountPref reads one preference, empty when unset.
func (d *DB) AccountPref(account, key string) string {
if account == "" || key == "" {
return ""
}
var v string
d.db.QueryRow(`SELECT value FROM account_pref WHERE account = ? AND key = ?`,
account, key).Scan(&v)
return v
}
// SetChatRobot stores a repository's robot event patterns (webhook
// pattern syntax). Empty turns the robot off by deleting the row: a
// repository with no row and a repository with an empty pattern must
// not be two different states for the consumer to tell apart.
func (d *DB) SetChatRobot(repo, events string) error {
if repo == "" {
return nil
}
if events == "" {
_, err := d.db.Exec(`DELETE FROM chat_robot WHERE repo = ?`, repo)
return err
}
_, err := d.db.Exec(`INSERT INTO chat_robot (repo, events) VALUES (?, ?)
ON CONFLICT (repo) DO UPDATE SET events = excluded.events`, repo, events)
return err
}
// ChatRobot answers one repository's patterns, empty when the robot is
// off.
func (d *DB) ChatRobot(repo string) string {
var v string
d.db.QueryRow(`SELECT events FROM chat_robot WHERE repo = ?`, repo).Scan(&v)
return v
}
// ChatRobots answers every opted-in repository's patterns — what the
// robot consumer reads once per event instead of once per repository.
func (d *DB) ChatRobots() (map[string]string, error) {
out := map[string]string{}
rows, err := d.db.Query(`SELECT repo, events FROM chat_robot`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var repo, events string
if err := rows.Scan(&repo, &events); err != nil {
return nil, err
}
out[repo] = events
}
return out, rows.Err()
}