Files
buzz/migrations/0056_personal_read_state.sql
hMurderbot 0e0b02520f relay: prototype unread counts by relay-assigned sequence numbers
Not for merge. Side-by-side comparison with #8138: ingest numbers each
eligible message under a per-channel counter row, unread is the counter
less the reader's position, posting marks the author read, deleted
messages count until read past. Includes a shared benchmark test.

Signed-off-by: hMurderbot <219c58afff8ac2c76e4e527a329aa61c2ebbb896786b1916d4f9cca5140ecd04@buzz.block.builderlab.xyz>
2026-10-06 19:54:55 -07:00

93 lines
4.5 KiB
PL/PgSQL

-- Private accessory read progress. Never included in Nostr event queries.
--
-- Unread is counted by subtraction over relay-assigned message numbers.
-- Ingest numbers every eligible message in its channel under one locked
-- counter row:
-- events.channel_seq every eligible message in the channel, in commit order;
-- events.timeline_seq the channel's timeline count once the message is
-- stored (its own number when it is on the timeline);
-- thread_metadata.reply_seq on a root: the thread's eligible replies so far,
-- broadcast depth-1 replies excluded (they count on the
-- timeline), with reply_channel_seq and reply_last_id
-- the latest one's.
-- A position is the numbers a context was read through. Unread is the latest
-- number minus the position, so every numbered message counts for every
-- reader: posting marks the author's position, and deleted messages count
-- until read past.
--
-- started_at is the actor's first read intent: until then nothing counts.
-- Starting writes a caught-up position for every joined channel and thread,
-- and joining a channel afterwards writes one at its current count.
CREATE TABLE personal_read_accounts (
community_id UUID NOT NULL REFERENCES communities(id),
actor BYTEA NOT NULL CHECK (octet_length(actor) = 32),
started_at TIMESTAMPTZ,
PRIMARY KEY (community_id, actor)
);
CREATE TABLE personal_read_counters (
community_id UUID NOT NULL,
channel_id UUID NOT NULL,
timeline_seq BIGINT NOT NULL DEFAULT 0,
channel_seq BIGINT NOT NULL DEFAULT 0,
-- The message numbered channel_seq: marking the channel read through it
-- reads everything, so a badge has an anchor without a scan.
latest_id BYTEA,
PRIMARY KEY (community_id, channel_id),
FOREIGN KEY (community_id, channel_id)
REFERENCES channels (community_id, id) ON DELETE CASCADE
);
-- An empty root_id is the channel timeline. A thread row exists exactly for
-- the actor's threads. through_seq is in the context's own numbering
-- (timeline or thread); through_channel_seq is the channel-wide cut it was
-- read through. through_timestamp is the anchor's arrival, used only to
-- bound the mention scan.
CREATE TABLE personal_read_frontiers (
community_id UUID NOT NULL,
actor BYTEA NOT NULL,
channel_id UUID NOT NULL,
root_id BYTEA NOT NULL DEFAULT ''::bytea CHECK (octet_length(root_id) IN (0, 32)),
through_seq BIGINT NOT NULL,
through_channel_seq BIGINT NOT NULL,
through_timestamp TIMESTAMPTZ NOT NULL,
PRIMARY KEY (community_id, actor, channel_id, root_id),
FOREIGN KEY (community_id, actor)
REFERENCES personal_read_accounts (community_id, actor) ON DELETE CASCADE,
FOREIGN KEY (community_id, channel_id)
REFERENCES channels (community_id, id) ON DELETE CASCADE
);
ALTER TABLE events ADD COLUMN channel_seq BIGINT, ADD COLUMN timeline_seq BIGINT;
ALTER TABLE thread_metadata ADD COLUMN reply_seq BIGINT NOT NULL DEFAULT 0,
ADD COLUMN reply_channel_seq BIGINT, ADD COLUMN reply_last_id BYTEA;
-- Joining (or rejoining) a channel after starting begins caught up.
CREATE FUNCTION personal_read_on_join() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.removed_at IS NULL AND (TG_OP = 'INSERT' OR OLD.removed_at IS NOT NULL) THEN
INSERT INTO personal_read_frontiers (community_id, actor, channel_id,
through_seq, through_channel_seq, through_timestamp)
SELECT NEW.community_id, NEW.pubkey, NEW.channel_id,
COALESCE(k.timeline_seq, 0), COALESCE(k.channel_seq, 0), now()
FROM personal_read_accounts a
LEFT JOIN personal_read_counters k
ON k.community_id = NEW.community_id AND k.channel_id = NEW.channel_id
WHERE a.community_id = NEW.community_id AND a.actor = NEW.pubkey
AND a.started_at IS NOT NULL
ON CONFLICT (community_id, actor, channel_id, root_id) DO UPDATE SET
through_seq = EXCLUDED.through_seq,
through_channel_seq = EXCLUDED.through_channel_seq,
through_timestamp = EXCLUDED.through_timestamp;
END IF;
RETURN NULL;
END $$;
CREATE TRIGGER trg_channel_members_personal_read_join
AFTER INSERT OR UPDATE OF removed_at ON channel_members
FOR EACH ROW EXECUTE FUNCTION personal_read_on_join();
SELECT attach_community_write_fence('personal_read_accounts');
SELECT attach_community_write_fence('personal_read_counters');
SELECT attach_community_write_fence('personal_read_frontiers');