Skip to content

feat: support deleting users who own rooms or have messages#5

Merged
RagingRedRiot merged 1 commit into
mainfrom
feat/room-and-message-safe-user-deletion
May 26, 2026
Merged

feat: support deleting users who own rooms or have messages#5
RagingRedRiot merged 1 commit into
mainfrom
feat/room-and-message-safe-user-deletion

Conversation

@RagingRedRiot

Copy link
Copy Markdown
Owner

Background

DELETE FROM users previously failed at the foreign-key check for any user with an active footprint in the system:

  • messages.sender_id was NOT NULL with the default NO ACTION — so deleting a user who'd ever sent a message errored.
  • rooms.owner_id was NOT NULL with the default NO ACTION — so deleting a user who owned a room errored.

In practice this meant user deletion only worked for accounts that had done essentially nothing. Kicking a real participant required manual cleanup first, which isn't a workflow we want admins to live in.

Schema changes

Edited the single existing migration (20260519000001_init.sql) since the DB is pre-release and only exists in dev environments — no new migration is warranted.

messages now looks like:

Column Change Reason
sender_id NOT NULL removed; ON DELETE SET NULL added Preserves the message row when its author is deleted
sender_username_snapshot TEXT new, nullable Captures the username at deletion time so deleted authors stay attributable
CHECK (sender_id IS NOT NULL OR sender_username_snapshot IS NOT NULL) new Guarantees every message is always attributable to someone — live or tombstoned

rooms.owner_id stays NOT NULL with RESTRICT semantics — reassignment happens explicitly in the delete handler instead of via a cascade, so we don't risk silently
nuking rooms.

Handler changes (DeleteUserRequest)

The handler now runs inside a single transaction. After the existing auth checks (admin-or-self, default-admin guard), the new flow is:

  1. SELECT user_id FROM users WHERE user_id = \$1 FOR UPDATE — takes a row-level lock on the target. Because FK enforcement on messages.sender_id takes FOR KEY SHARE on the parent, this serializes against concurrent message inserts and closes the race where a new message could slip in between the snapshot and the cascading
    SET NULL and trip the CHECK constraint.
  2. Snapshot the username onto the target's messages via UPDATE messages SET sender_username_snapshot = u.username FROM users u WHERE messages.sender_id = \$1 AND u.user_id = \$1.
  3. Reassign owned rooms to the default admin via inline subquery: UPDATE rooms SET owner_id = (SELECT user_id FROM admins WHERE is_default = true) WHERE owner_id = \$1. No-op if the user owns no rooms; rolls back cleanly via NOT NULL violation if rooms exist and no default admin does.
  4. DELETE FROM users — cascades handle credentials/admins/last_active/memberships; ON DELETE SET NULL nulls messages.sender_id; the snapshot from step 2
    satisfies the CHECK.
  5. Commit (single path; rollback on any failure via transaction drop).

Design choices worth flagging

Snapshot-on-delete vs. snapshot-on-insert. Two viable schemas: snapshot the username on every message row at insert time (always-populated sender_username TEXT NOT NULL), or snapshot only at deletion time (nullable column populated by the delete handler). Went with the latter so users remains the single source of truth while
the author is live — username renames propagate naturally, and the snapshot only freezes identity at the moment it actually matters.

Inline subquery for room reassignment. An earlier draft fetched the default admin via the existing get_default_admin helper and then did the UPDATE. That required
a default admin to exist even when the user owned zero rooms, which broke two tests (user_deletes_self, self_deleted_user_cannot_authenticate) where no admin is
seeded. The subquery form has the right semantics for free: zero rooms → zero rows affected → no default-admin requirement.

Loss of explicit "no default admin" error. With the subquery form, the failure mode for "user owns rooms but no default admin exists" is a generic NOT NULL
violation on rooms.owner_id instead of an explicit branch. Since logging is still a TODO across this handler and the response is Failed either way, the loss of
fidelity is invisible today. Worth revisiting once structured logging lands.

Test plan

  • cargo fmt --all --check clean
  • cargo clippy --all-targets --locked -- -D warnings clean
  • cargo test --locked — all 11 tests in tests/delete_user.rs pass, including previously-failing user_deletes_self and self_deleted_user_cannot_authenticate
  • Full suite green locally

Looking forward to implementing rooms and messages inspired these updates.
DELETE FROM users previously failed at the FK for any user with messages or owned rooms.

Now:
- messages.sender_id is nullable with ON DELETE SET NULL, paired with a sender_username_snapshot column and a CHECK constraint ensuring every message stays attributable
- the delete handler snapshots the username onto the user's messages and reassigns owned rooms to the default admin via an inline subquery (no default admin required when the user owns no rooms)
- a SELECT ... FOR UPDATE on the target user serializes against concurrent message inserts, closing the race where a new row could slip in between the snapshot and the cascading SET NULL and trip the CHECK

I updated the migrations file instead of making a new one because no one is using it but me since it only became public today.
@RagingRedRiot RagingRedRiot merged commit 15c7fa6 into main May 26, 2026
4 checks passed
@RagingRedRiot RagingRedRiot deleted the feat/room-and-message-safe-user-deletion branch May 26, 2026 01:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant