// read.go stores forum read-state — which threads an account has seen
// and when. Like votes and subscriptions it is forge-local social
// state: per-account, private, and never part of the artifact DAG.
//
// The row holds a TIMESTAMP, not a boolean. "Read" is not a property of
// a thread but a comparison — you have seen it AND nothing has arrived
// since — so a thread re-brightens on its next reply with no flag to
// hunt down and clear. A boolean would need every new post to walk
// every reader's row; a timestamp needs nothing.
//
// Timestamps here are stored in the INDEX's format (19 chars, UTC, no
// zone suffix) rather than the RFC3339 the rest of this package uses.
// These values exist only to be compared against index post dates, and
// two formats that must compare would be a bug waiting for its first
// mismatched hour.
package oltp
import "time"
const readSchema = `
CREATE TABLE IF NOT EXISTS forum_read (
repo TEXT NOT NULL,
account TEXT NOT NULL,
thread TEXT NOT NULL, -- thread root hash
at TEXT NOT NULL, -- when the account last viewed the thread
PRIMARY KEY (repo, account, thread)
);
-- The watermark: everything in this repo older than at is read. It
-- makes "mark all read" one write instead of one per thread, and it is
-- what lets the per-thread rows below it be pruned, so the table stays
-- bounded rather than growing as accounts x threads forever.
CREATE TABLE IF NOT EXISTS forum_read_mark (
repo TEXT NOT NULL,
account TEXT NOT NULL,
at TEXT NOT NULL,
PRIMARY KEY (repo, account)
);`
// stamp is the index's date format widened with a fractional part.
// Index dates are second-granular; marks are not, and the extra digits
// are what break the tie when a reader opens a thread in the same
// second its newest post landed. A bare second would make that view
// ambiguous and — under the strict comparison below — leave the thread
// the reader is literally looking at unread.
const stamp = "2006-01-02T15:04:05.000000"
// clip cuts an index or RFC3339 date back to the comparable 19-char
// prefix. Lexicographic order IS chronological order at that width,
// which is why none of this needs to parse a time.
//
// The comparison below is STRICT: a post is seen only if it predates
// the view, never if it merely ties with it. Marks carry a fractional
// part and index dates do not, so a post from the same second as the
// view sorts BEFORE it and reads as seen — which is right, since it was
// already in the page that got drawn. A reply in any later second sorts
// after and re-brightens the thread. A reply inside the same second as
// the view is the one case no clock here can resolve; it stays read.
func clip(s string) string {
if len(s) > 19 {
return s[:19]
}
return s
}
// MarkForumRead records that an account has seen a thread — but only
// when that changes the answer. A re-visit with nothing new since the
// last one is a read, not a write, which matters because opening a
// thread is the single most common act on a forum and SQLite has one
// writer. The guard lives in the upsert's WHERE so the decision is made
// where the row is, without a read-then-write race.
func (d *DB) MarkForumRead(repo, account, thread, lastReply string) error {
if account == "" || thread == "" {
return nil
}
_, err := d.w.Exec(`INSERT INTO forum_read (repo, account, thread, at)
VALUES (?, ?, ?, ?)
ON CONFLICT (repo, account, thread) DO UPDATE SET at = excluded.at
WHERE forum_read.at <= ?`,
repo, account, thread, time.Now().UTC().Format(stamp), clip(lastReply))
return err
}
// MarkAllForumRead sets the repo watermark to now and prunes every
// per-thread row it subsumes — the rows still say "read", the watermark
// says it for them.
func (d *DB) MarkAllForumRead(repo, account string) error {
if account == "" {
return nil
}
at := time.Now().UTC().Format(stamp)
tx, err := d.w.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`INSERT INTO forum_read_mark (repo, account, at) VALUES (?, ?, ?)
ON CONFLICT (repo, account) DO UPDATE SET at = excluded.at`, repo, account, at); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM forum_read WHERE repo = ? AND account = ? AND at <= ?`,
repo, account, at); err != nil {
return err
}
return tx.Commit()
}
// ForumRead answers which of the given threads the account has already
// seen, keyed root -> that thread's last-reply date. A thread is read
// when the account's view of it is no older than its newest post.
//
// An empty account reads nothing — that is the anonymous answer and it
// is the correct one: with no identity there is no "already seen", so
// every row renders at full ink rather than guessing.
func (d *DB) ForumRead(repo, account string, threads map[string]string) (map[string]bool, error) {
out := map[string]bool{}
if account == "" || len(threads) == 0 {
return out, nil
}
var mark string
// No row is not an error: it means this account has never swept the
// repo, so the watermark is simply the zero time.
d.db.QueryRow(`SELECT at FROM forum_read_mark WHERE repo = ? AND account = ?`,
repo, account).Scan(&mark)
roots := make([]string, 0, len(threads))
for root := range threads {
roots = append(roots, root)
}
args := append([]any{repo, account}, toAny(roots)...)
rows, err := d.db.Query(`SELECT thread, at FROM forum_read
WHERE repo = ? AND account = ? AND thread IN (`+placeholders(len(roots))+`)`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
seen := make(map[string]string, len(roots))
for rows.Next() {
var thread, at string
if err := rows.Scan(&thread, &at); err != nil {
return nil, err
}
seen[thread] = at
}
if err := rows.Err(); err != nil {
return nil, err
}
for root, lastReply := range threads {
at := seen[root]
if mark > at {
at = mark
}
if at != "" && clip(lastReply) < at {
out[root] = true
}
}
return out, nil
}