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
116 lines
5.2 KiB
Python
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
|