Artifact 54135458aff9c69fb881d6a4ebe32346971e7afdb2b9916a192300b9ddec8fed:
- File
internal/oltp/read.go
— part of check-in
[c7bf7c08f0]
at
2026-08-25 22:15:11
on branch trunk
— Forum read-state: break the same-second tie, and prove it end to end
Marks were stored second-granular like index post dates, so opening a thread in the same second its newest post landed left the thread the reader was looking at unread. Marks now carry a fractional part, which makes the two orderable: a post from the view's own second was in the page and reads as seen; any later second re-brightens.
Adds a harness test that walks the whole loop, including the part worth pinning — forum.get does not mark read, only forum.read does. (user: dmestas, size: 5977) [annotate] [blame] [check-ins using]
// 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.db.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.db.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 }