Skip to content
quarry Artifact [54135458af]
Login

Artifact [54135458af]

Artifact 54135458aff9c69fb881d6a4ebe32346971e7afdb2b9916a192300b9ddec8fed:


// 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
}