An inbox between two members — conversations, per-person unread marks,
photos, blocking. Not live chat: that needs a connection held open per
signed-in member, which the sync workers cannot do.
Membership of the conversation is the whole access rule and is checked on
every hit, answering 404 rather than 403 so a member cannot tell a
conversation that is not theirs from one that does not exist. A picture
in a private message is checked the same way: on the board being signed
in is enough, here it is nowhere near.
Blocking is symmetric. One row stops both directions, and you can only
lift your own. A block that silenced only the blocked person would leave
the blocker writing freely, which is a megaphone rather than a safety
feature. Enforced in the handlers, with a test that posts from a page
held open from before the block.
Erasing a member deletes their private messages, both sides, and their
pictures off disk. A thread outlives its author because other people
replied; a two-party exchange has no remainder, and keeping half of
erased correspondence is what erasure exists to prevent. The guard added
in c9c549e did its job: it failed the moment the new tables landed and
named all four columns.
The part that needed care: schema.sql is all CREATE TABLE IF NOT EXISTS,
so it can add a table and nothing else. Every change so far happened to
be a new table. Letting an attachment belong to a message is not — and
SQLite cannot do it in place, because the table carries a CHECK
constraint and there is no DROP CONSTRAINT. Verified before building on
it: ALTER TABLE ADD COLUMN succeeds and the next insert is refused.
So migrations.py, numbered steps recorded in PRAGMA user_version, run
after the schema so a fresh database finds its work already done. Step 1
rebuilds attachments the documented way. Tested against a database built
in the old shape with rows in it, because a migration tested only on a
fresh database is tested against the one case it was never needed for —
including that the rebuilt CHECK is as strict as the one it replaced.
229 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
199 lines
8.6 KiB
SQL
199 lines
8.6 KiB
SQL
-- Members area schema.
|
|
--
|
|
-- Applied at every startup and written to be idempotent, so deploying a new
|
|
-- version of the app needs no migration step for as long as the schema only
|
|
-- grows. A change that alters an existing column will need a real migration;
|
|
-- there is deliberately no framework here to pretend otherwise.
|
|
|
|
CREATE TABLE IF NOT EXISTS members (
|
|
id INTEGER PRIMARY KEY,
|
|
-- COLLATE NOCASE because Gitea treats logins case-insensitively; without it
|
|
-- "Pablo" and "pablo" would be two members with one Gitea account.
|
|
gitea_login TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
display_name TEXT NOT NULL DEFAULT '',
|
|
email TEXT NOT NULL DEFAULT '',
|
|
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'user', 'tombstone')),
|
|
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
created_by INTEGER REFERENCES members(id),
|
|
last_seen_at TEXT
|
|
);
|
|
|
|
-- The one-owner rule, held by the database rather than by the application, so
|
|
-- a mistake in a handler cannot produce a second owner. SQLite enforces a
|
|
-- partial unique index exactly like a full one.
|
|
CREATE UNIQUE INDEX IF NOT EXISTS members_one_owner
|
|
ON members(role) WHERE role = 'owner';
|
|
|
|
CREATE TABLE IF NOT EXISTS threads (
|
|
id INTEGER PRIMARY KEY,
|
|
author_id INTEGER NOT NULL REFERENCES members(id),
|
|
title TEXT NOT NULL,
|
|
body_md TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
edited_at TEXT,
|
|
pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)),
|
|
locked INTEGER NOT NULL DEFAULT 0 CHECK (locked IN (0, 1)),
|
|
-- Soft delete: a moderator's mistake stays recoverable, and removing one
|
|
-- comment does not tear a hole in the conversation around it.
|
|
deleted_at TEXT
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS threads_live
|
|
ON threads(pinned DESC, created_at DESC) WHERE deleted_at IS NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS comments (
|
|
id INTEGER PRIMARY KEY,
|
|
thread_id INTEGER NOT NULL REFERENCES threads(id),
|
|
author_id INTEGER NOT NULL REFERENCES members(id),
|
|
body_md TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
edited_at TEXT,
|
|
deleted_at TEXT
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS comments_thread
|
|
ON comments(thread_id, created_at) WHERE deleted_at IS NULL;
|
|
|
|
-- Private messages between two members.
|
|
--
|
|
-- An inbox, not live chat: gunicorn's sync workers cannot hold a connection
|
|
-- open per signed-in member, and that would be the first thing on this box
|
|
-- with a real scaling limit.
|
|
--
|
|
-- Membership is its own table rather than two columns on `conversations`
|
|
-- because the unread mark is per person: each side keeps its own
|
|
-- `last_read_at`, and the badge counts messages newer than it that somebody
|
|
-- else wrote. Two columns would need two last-read fields and a rule about
|
|
-- which is which.
|
|
CREATE TABLE IF NOT EXISTS conversations (
|
|
id INTEGER PRIMARY KEY,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS conversation_members (
|
|
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
member_id INTEGER NOT NULL REFERENCES members(id),
|
|
last_read_at TEXT,
|
|
PRIMARY KEY (conversation_id, member_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS conversation_members_member
|
|
ON conversation_members(member_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
id INTEGER PRIMARY KEY,
|
|
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
author_id INTEGER NOT NULL REFERENCES members(id),
|
|
body_md TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
deleted_at TEXT
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS messages_conversation
|
|
ON messages(conversation_id, created_at) WHERE deleted_at IS NULL;
|
|
|
|
-- Blocking is symmetric: one row stops messages in both directions.
|
|
--
|
|
-- The alternative — the blocker may still write, the blocked may not reply —
|
|
-- turns a safety feature into a one-way megaphone, which is worse than not
|
|
-- having one. Somebody who blocks a person and then wants to talk to them can
|
|
-- unblock. The CHECK is there because blocking yourself is meaningless and
|
|
-- would quietly disable your own inbox.
|
|
CREATE TABLE IF NOT EXISTS blocks (
|
|
blocker_id INTEGER NOT NULL REFERENCES members(id),
|
|
blocked_id INTEGER NOT NULL REFERENCES members(id),
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
PRIMARY KEY (blocker_id, blocked_id),
|
|
CHECK (blocker_id <> blocked_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS blocks_blocked ON blocks(blocked_id);
|
|
|
|
-- Pictures attached to a thread, a comment or a private message.
|
|
--
|
|
-- The file itself lives in /data/uploads; this is the record of what it is and
|
|
-- what it belongs to. `stored_name` is generated, never the name the browser
|
|
-- sent, and is UNIQUE because it is also the URL.
|
|
--
|
|
-- The CHECK is the shape of the thing: an attachment hangs off exactly one of
|
|
-- the two, never both and never neither. Without it a row with both columns
|
|
-- set would be served under whichever parent was still alive, which is a
|
|
-- quiet way for a deleted thread's photo to stay readable.
|
|
CREATE TABLE IF NOT EXISTS attachments (
|
|
id INTEGER PRIMARY KEY,
|
|
thread_id INTEGER REFERENCES threads(id),
|
|
comment_id INTEGER REFERENCES comments(id),
|
|
message_id INTEGER REFERENCES messages(id),
|
|
stored_name TEXT NOT NULL UNIQUE,
|
|
original_name TEXT NOT NULL,
|
|
content_type TEXT NOT NULL,
|
|
bytes INTEGER NOT NULL,
|
|
uploaded_by INTEGER NOT NULL REFERENCES members(id),
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
CHECK ((thread_id IS NOT NULL) + (comment_id IS NOT NULL)
|
|
+ (message_id IS NOT NULL) = 1)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS attachments_thread ON attachments(thread_id);
|
|
CREATE INDEX IF NOT EXISTS attachments_comment ON attachments(comment_id);
|
|
CREATE INDEX IF NOT EXISTS attachments_message ON attachments(message_id);
|
|
|
|
-- Gitea access tokens for the editor.
|
|
--
|
|
-- Kept here rather than in the session cookie. Flask signs cookies but does not
|
|
-- encrypt them, so a live token sitting in one is readable by anything that can
|
|
-- read the cookie — and a token is enough to commit to the repository as its
|
|
-- owner. ON DELETE CASCADE ties the token to the membership: erasing a member
|
|
-- takes their token with it, with nothing to remember.
|
|
CREATE TABLE IF NOT EXISTS gitea_tokens (
|
|
member_id INTEGER PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE,
|
|
access_token TEXT NOT NULL,
|
|
refresh_token TEXT NOT NULL DEFAULT '',
|
|
expires_at TEXT,
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
-- Frontmatter of content files, keyed by the git blob sha.
|
|
--
|
|
-- Listing a folder through Gitea's contents API returns names and shas but no
|
|
-- bodies, so showing titles and dates means fetching every file. Caching on the
|
|
-- sha turns that from one request per post on every page load into one request
|
|
-- in total, because a sha changes only when the file does. Nothing needs
|
|
-- invalidating: a row is only ever read for a path the listing still returns.
|
|
CREATE TABLE IF NOT EXISTS content_cache (
|
|
path TEXT PRIMARY KEY,
|
|
sha TEXT NOT NULL,
|
|
title TEXT NOT NULL DEFAULT '',
|
|
date TEXT NOT NULL DEFAULT '',
|
|
categories TEXT NOT NULL DEFAULT '',
|
|
-- Files carrying `translated_from` are the pipeline's output, not anyone's
|
|
-- draft. Recorded here so the listing can skip them without re-reading
|
|
-- every file to find out what it already knew.
|
|
generated INTEGER NOT NULL DEFAULT 0 CHECK (generated IN (0, 1)),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
-- One-time links: invitations to set a first password, and password resets.
|
|
--
|
|
-- A token here is enough to take over an account, so only its SHA-256 lives in
|
|
-- this table. A database backup that leaks is then a list of useless hashes
|
|
-- rather than a set of live keys.
|
|
--
|
|
-- SHA-256 rather than a password hash on purpose: these are 32 random bytes
|
|
-- from secrets.token_urlsafe, not something a person chose. There is no
|
|
-- dictionary to run against them, so the slow hashing that protects weak
|
|
-- passwords buys nothing and costs a round trip on every click.
|
|
CREATE TABLE IF NOT EXISTS invites (
|
|
id INTEGER PRIMARY KEY,
|
|
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
purpose TEXT NOT NULL CHECK (purpose IN ('invite', 'reset')),
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
expires_at TEXT NOT NULL,
|
|
used_at TEXT
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS invites_open
|
|
ON invites(member_id, purpose) WHERE used_at IS NULL;
|