099315a538700ee8a7be0a5197807d81c08c5500
Messages were only reachable by polling. This adds the server half of the realtime path: a cursor that cannot skip rows, a change log, and a socket that pushes "conversation X is at seq N". The cursor is a counter on conversations, drawn with UPDATE ... RETURNING inside the mutating transaction. That takes the conversation row lock, so a second writer blocks until the first commits and commit order equals seq order. A BIGSERIAL on messages does not have this property: it hands out numbers at INSERT time while rows become visible at COMMIT time, and those two orders diverge under concurrent senders — a reader following seq > n then loses a message permanently. Two tests cover this; the second one asserts that the naive design *does* lose a row, so the first is proven to measure something. created_at loses its ordering role. It is NOW(), i.e. transaction start, so it is neither unique nor commit-ordered — the existing ORDER BY created_at was already wrong, independently of realtime. Every mutation appends to conversation_events, and that row is the event. A three-line trigger on that table fires pg_notify, so no future write path can forget to signal, and NOTIFY being transactional means it fires exactly when the row becomes visible. Fan-out always goes through NOTIFY, even with a single instance. One path instead of two means the tested path is the production path, and running more than one instance needs no second implementation. Per connection the pending work is a map rather than a queue: only the newest seq per conversation matters, so a newer value overwrites an older one, there is no overflow and no overflow error path. Entries are merged with max(), because NOTIFY only orders per listener. When the listener reconnects, every client is told to resync — notifications sent during the gap are gone, as NOTIFY keeps no history. Sockets are closed by hand on shutdown: axum's graceful shutdown neither waits for nor closes upgraded connections (axum#3003, fix PR #3831 still open). Also adds membership checks to the message history endpoint, which had none, and paginates it — it previously returned entire conversations unbounded. Not yet done, marked as TODO in the code: reactions and attachments do not emit events, and create/update/delete still trust user_id from the request body.
rustification-server
REST API backend for Rustification, a chat application. Built with Axum, SQLx, and PostgreSQL.
Tech stack
| Layer | Technology |
|---|---|
| HTTP framework | Axum 0.8 |
| Database | PostgreSQL 17 via SQLx 0.9 |
| Auth | JWT (jsonwebtoken) + Argon2 password hashing |
| Runtime | Tokio |
| Migrations | SQLx built-in |
Features
- Auth — register, login, logout, JWT refresh via bearer tokens
- Workspaces — create and manage workspaces with owner/admin/member roles and invite tokens
- Conversations — DMs, group chats, and channels; public or private
- Messages — send, edit, soft-delete; per-conversation history
- Attachments — link files to messages (URL-based)
- Reactions — emoji reactions per message
- Soft deletes on users, workspaces, conversations, and messages (excluded from all list/get responses)
Prerequisites
- Rust (edition 2024)
- PostgreSQL 17, or Docker + Docker Compose
Getting started
1. Configure environment
cp .env.example .env
# Edit .env — set POSTGRES_*, DATABASE_URL, API_URL, API_PORT, JWT_SECRET
2a. Run with Docker Compose (recommended)
Starts PostgreSQL and the server together:
docker compose up --build
The server will be available at http://localhost:3000. Migrations run automatically on startup — no manual step needed.
2b. Run locally
Requires a running PostgreSQL instance matching your DATABASE_URL.
cargo run
Migrations are embedded in the binary and run automatically when the server starts.
Environment variables
| Variable | Description | Example |
|---|---|---|
DATABASE_URL |
Postgres connection string | postgres://admin:password@localhost:5432/rustification |
POSTGRES_DB |
Database name (used by Docker) | rustification |
POSTGRES_USER |
Postgres user (used by Docker) | admin |
POSTGRES_PASSWORD |
Postgres password (used by Docker) | password |
API_URL |
Bind address | 0.0.0.0 |
API_PORT |
Bind port | 3000 |
JWT_SECRET |
Secret for signing JWTs | any random string |
API
Base URL: http://localhost:3000/api/v1
All routes except /auth/* require a Authorization: Bearer <token> header.
Auth
| Method | Path | Description |
|---|---|---|
| POST | /auth/register |
Register a new user |
| POST | /auth/login |
Login, returns JWT |
| POST | /auth/refresh |
Refresh access token |
| POST | /auth/logout |
Logout (invalidates refresh token) |
Users
| Method | Path | Description |
|---|---|---|
| GET | /users |
List all users |
| POST | /users |
Create a user |
| GET | /users/{id} |
Get a user |
| PUT | /users/{id} |
Update display_name / avatar_url / bio |
| DELETE | /users/{id} |
Soft-delete a user |
Workspaces
| Method | Path | Description |
|---|---|---|
| GET | /workspaces |
List workspaces |
| POST | /workspaces |
Create a workspace |
| GET | /workspaces/{id} |
Get a workspace |
| PUT | /workspaces/{id} |
Update name / slug |
| DELETE | /workspaces/{id} |
Soft-delete |
| GET | /workspaces/{id}/members |
List members |
| POST | /workspaces/{id}/members |
Add member (role: owner | admin | member) |
| PUT | /workspaces/{workspace_id}/members/{user_id} |
Update role / display name |
| DELETE | /workspaces/{workspace_id}/members/{user_id} |
Remove member |
| GET | /workspaces/{id}/invitations |
List invite tokens |
| POST | /workspaces/{id}/invitations |
Create invite token |
| DELETE | /invitations/{id} |
Delete invite token |
Conversations
| Method | Path | Description |
|---|---|---|
| GET | /conversations |
List conversations (filter: ?workspace_id=uuid) |
| POST | /conversations |
Create (kind: dm | group | channel) |
| GET | /conversations/{id} |
Get a conversation |
| PUT | /conversations/{id} |
Update name / is_public |
| DELETE | /conversations/{id} |
Soft-delete |
| GET | /conversations/{id}/members |
List members |
| POST | /conversations/{id}/members |
Add member |
| DELETE | /conversations/{conversation_id}/members/{user_id} |
Remove member |
Messages
| Method | Path | Description |
|---|---|---|
| GET | /conversations/{id}/messages |
List messages (chronological) |
| POST | /messages |
Send a message |
| GET | /messages/{id} |
Get a message |
| PUT | /messages/{id} |
Edit content |
| DELETE | /messages/{id} |
Soft-delete |
Attachments & Reactions
| Method | Path | Description |
|---|---|---|
| GET | /messages/{id}/attachments |
List attachments |
| POST | /attachments |
Create attachment |
| DELETE | /attachments/{id} |
Delete attachment |
| GET | /messages/{id}/reactions |
List reactions |
| POST | /reactions |
Add reaction |
| DELETE | /reactions/{id} |
Remove reaction |
Full request/response shapes are documented in docs/api.md.
Response codes
| Status | Meaning |
|---|---|
| 200 | OK |
| 201 | Created |
| 204 | Deleted (no body) |
| 404 | Not found |
| 500 | Internal server error |
Project structure
src/
├── main.rs # Server setup, env, DB pool
├── route.rs # Route definitions (public + protected)
├── middleware.rs # JWT auth middleware
├── model.rs # SQLx types and response structs
├── schema.rs # Request body structs
└── handlers/ # One file per resource
├── auth.rs
├── auth_token.rs
├── user.rs
├── workspace.rs
├── workspace_member.rs
├── workspace_invitation.rs
├── conversation.rs
├── conversation_member.rs
├── message.rs
├── attachment.rs
└── reaction.rs
migrations/ # SQLx migration files
Description
Languages
Rust
98.7%
Dockerfile
1.3%