vienalatina/apps/board/migrations.py
Claude 5ab9e3cab5
Phase C: private messages, with blocking and photos
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
2026-09-28 16:10:52 +00:00

116 lines
5.2 KiB
Python

"""Changes to tables that already exist.
`schema.sql` is all `CREATE TABLE IF NOT EXISTS`, which handles exactly one
kind of change: a brand-new table. It silently does nothing to a table that is
already there, so every alteration to an existing one has to happen here.
That was fine until now because every change so far had been a new table. The
first change that is not — giving `attachments` a third possible parent — also
happens to be one SQLite cannot do in place, because the old row carries a
CHECK constraint and SQLite has no `DROP CONSTRAINT`. Verified rather than
assumed: `ALTER TABLE ... ADD COLUMN` succeeds, and the next insert is refused
by a constraint that can no longer be removed.
**The order matters.** `init_db` applies `schema.sql` first and then these. On
an empty database the schema creates everything in its current shape and each
step below finds its work already done, so every step must be written to check
before it acts and return quietly.
Steps are numbered, applied once, in order, each in its own transaction, and
recorded in SQLite's own `PRAGMA user_version`. Never renumber one and never
edit one that has shipped: a server that has already run it will not run it
again, so a correction is a new step.
"""
from __future__ import annotations
import sqlite3
def _columns(db: sqlite3.Connection, table: str) -> set[str]:
return {row[1] for row in db.execute(f"PRAGMA table_info({table})")}
def _attachments_accept_messages(db: sqlite3.Connection) -> None:
"""Let an attachment hang off a private message.
The table is rebuilt rather than altered because of its CHECK constraint:
`(thread_id IS NULL) <> (comment_id IS NULL)` insists that exactly one of
those two is set, so a row belonging to a message — with both of them null
— is refused. Adding the column is allowed; using it is not.
This is SQLite's documented procedure for changing a constraint: build the
new table beside the old one, copy the rows, drop the old, rename. The
foreign keys are switched off around it because dropping a table with them
on can cascade, and switched back on after, with a check that nothing was
broken in between.
"""
if "message_id" in _columns(db, "attachments"):
return # a new database: schema.sql already created it this way
db.execute("PRAGMA foreign_keys = OFF")
try:
db.execute("""
CREATE TABLE attachments_new (
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)
)""")
# Named columns, not SELECT *: the order has to survive somebody adding
# a column to one of the two tables later.
db.execute("""
INSERT INTO attachments_new
(id, thread_id, comment_id, stored_name, original_name,
content_type, bytes, uploaded_by, created_at)
SELECT id, thread_id, comment_id, stored_name, original_name,
content_type, bytes, uploaded_by, created_at
FROM attachments""")
db.execute("DROP TABLE attachments")
db.execute("ALTER TABLE attachments_new RENAME TO attachments")
db.execute("CREATE INDEX IF NOT EXISTS attachments_thread ON attachments(thread_id)")
db.execute("CREATE INDEX IF NOT EXISTS attachments_comment ON attachments(comment_id)")
db.execute("CREATE INDEX IF NOT EXISTS attachments_message ON attachments(message_id)")
broken = db.execute("PRAGMA foreign_key_check").fetchall()
if broken:
raise RuntimeError(f"migration left dangling references: {broken}")
finally:
db.execute("PRAGMA foreign_keys = ON")
# (number, description, function). The number is the value written to
# user_version once the step succeeds.
STEPS = [
(1, "attachments can belong to a private message", _attachments_accept_messages),
]
def apply(db: sqlite3.Connection) -> list[str]:
"""Run whatever this database has not run yet. Returns what was applied."""
version = db.execute("PRAGMA user_version").fetchone()[0]
done = []
for number, description, step in sorted(STEPS):
if number <= version:
continue
db.execute("BEGIN IMMEDIATE")
try:
step(db)
# Not a parameter: PRAGMA does not take them. The value is an int
# from the list above, never from anything a request can reach.
db.execute(f"PRAGMA user_version = {int(number)}")
db.execute("COMMIT")
except Exception:
db.execute("ROLLBACK")
raise
done.append(f"{number}: {description}")
return done