Skip to content

feat: room subsystem (visibility, multi-owner membership, invites/requests) + time-based reaper#7

Merged
RagingRedRiot merged 2 commits into
mainfrom
feat/room-actor-and-reaper
May 31, 2026
Merged

feat: room subsystem (visibility, multi-owner membership, invites/requests) + time-based reaper#7
RagingRedRiot merged 2 commits into
mainfrom
feat/room-actor-and-reaper

Conversation

@RagingRedRiot

Copy link
Copy Markdown
Owner

Summary

Builds out the room subsystem on top of the existing users/messages schema, and adds a background reaper so room-related data can't accumulate unbounded. Two commits:

  1. Rooms — room lifecycle, a multi-owner membership model, room visibility, and the two ways into a private room (request-to-join and invite).
  2. Reaper — a time-based maintenance loop that ages out messages, empty rooms, and stale invites/join requests.

Everything is client-facing and wired end to end (client → server dispatch → room actor → Postgres).

Schema

(init migration edited in place — no release exists yet)

  • rooms: add is_public / is_discoverable, drop owner_id.
  • memberships: add is_owner and joined_at, with a partial index on owners.
  • New room_invites and room_join_requests (pending rows, created_at, ON DELETE CASCADE; invited_by is ON DELETE SET NULL for audit).

Access-control model

Enforced in the actor (SQL can't assert it cheaply):

  • An owner is always a member. A room may have zero owners (e.g. after its sole owner is deleted) — allowed, because every owner-gated action also permits admins, so an ownerless room stays manageable. DeleteUser no longer escalates owned rooms to the default admin.
  • Visibility drives joining: public → join now; private + discoverable → queue a join request; private + non-discoverable → invite-only, reported as NoRoomExists so existence stays hidden.
  • Existence hiding: read gets return NoRoomExists for an unauthorized private room (indistinguishable from a missing one); owner-gated mutations collapse "no such room" and "not authorized" both to Failed.
  • No PK leak: membership listings use a user_id-free PublicUser shape; request/invite views are name-only.

Commands

  • NewRoom, AddRoomOwner, SetRoomName
  • GetRoom, GetRoomMembership
  • JoinRoom, LeaveRoom
  • Join requests: ApproveJoinRequest, RejectJoinRequest, GetMyJoinRequests, GetIncomingJoinRequests
  • Invites: InviteToRoom, AcceptInvite, DeclineInvite, GetMyInvites

The owner-or-admin gate is a single gate_room() helper.

Reaper

  • One transaction deletes, all past retention_days: old messages, empty rooms whose id (uuidv7) predates the window, and stale room_invites / room_join_requests.
  • Config: retention_days and reap_interval_secs (defaults 30d / hourly), both optional env vars.
  • Spawned from main alongside auth, on the shared shutdown token.

Test plan

  • cargo test124 passing (41 new room/reaper tests + existing suite), via #[sqlx::test] per-test databases.
  • New test files: new_room, add_room_owner, set_room_name, get_room, get_room_membership, join_leave, join_requests, invites, reaper — each covering happy path, authorization, idempotency/NoChange, and existence-hiding; a wire-level test asserts membership payloads carry no user_id.
  • cargo fmt --all --check and cargo clippy --all-targets -- -D warnings clean.

Notes / follow-ups

  • A single retention_days governs every table; a separate, shorter window for pending invites/requests can be added later as an additive knob.
  • Message send/broadcast is still TODO and out of scope here.

…ests

Builds out the room subsystem: room lifecycle, an ownership/membership model, and the two ways into a private room (request-to-join and invite).

Schema:
- rooms gain is_public and is_discoverable and drop owner_id; ownership moves to memberships.is_owner, so a room can have many owners.
- memberships gain is_owner and joined_at, with a partial index on owners.
- new room_invites and room_join_requests tables (pending rows keyed by IDs, created_at, ON DELETE CASCADE); invited_by is ON DELETE SET NULL for audit.

Access-control contract:
- An owner is always a member. A room may have ZERO owners (e.g. after its sole owner is deleted) — allowed, because every owner-gated action also permits admins, so an ownerless room stays manageable.
- Visibility drives joining: public → join now; private+discoverable → queue a join request; private+non-discoverable → invite-only, reported as NoRoomExists so existence stays hidden.
- Read gets hide unauthorized private rooms; owner-gated mutations collapse missing-room and not-authorized both to Failed. Neither leaks existence.

Commands:
- NewRoom (visibility flags; seeds the creator as first owner-member), AddRoomOwner (additive grant to an existing member), SetRoomName.
- GetRoom / GetRoomMembership, both gated; membership returns a user_id-free PublicUser shape so the internal PK never reaches the wire.
- JoinRoom / LeaveRoom; join requests via Approve/Reject plus GetMy/GetIncoming invites via Accept/Decline plus GetMyInvites.
- The owner-or-admin gate is a single gate_room() helper, not five copies.

DeleteUser no longer escalates owned rooms to the default admin — ownership is a membership flag that cascades with the user, and ownerless rooms are now a supported state.

Tests: websocket-level integration coverage for every command (happy path, authorization, idempotency/NoChange, existence-hiding), and a wire-level assertion that membership payloads carry no user_id. Room seeders added to tests/common as pure additions.
Several tables hold data that would otherwise grow without bound: messages accumulate, rooms linger after they empty out, and pending invites/join requests are never cleared unless a client acts on them. Introduce a background reaper that ages all of it out on a timer, so retention doesn't depend on client action.

- reap() runs a single transaction that deletes, all past retention_days:
  - messages older than their timestamp,
  - rooms with no messages whose id (uuidv7) predates the window,
  - room_invites and room_join_requests by created_at. (Invites/requests for a deleted room already go via ON DELETE CASCADE; the explicit deletes catch stale rows in rooms that survive.)
- reap() is public so it can be invoked directly and unit-tested.
- Config gains retention_days and reap_interval_secs (defaults 30d / hourly), both optional env vars.
- Spawned from main alongside auth.
- tests/reaper.rs drives reap() directly: stale (40d) invites/requests are swept, fresh (1d) rows survive, and a within-window run is a no-op.
@RagingRedRiot RagingRedRiot merged commit 4281d7a into main May 31, 2026
4 checks passed
@RagingRedRiot RagingRedRiot deleted the feat/room-actor-and-reaper branch May 31, 2026 17:04
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