MDietermann 099315a538 feat(realtime): add monotonic cursor, change log and WebSocket fan-out
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.
2026-08-14 18:54:09 +02:00
2026-08-13 13:08:13 +02:00
2026-08-13 13:08:13 +02:00
2026-08-13 13:08:13 +02:00
2026-08-13 13:08:13 +02:00
2026-08-13 13:08:13 +02:00
2026-08-13 13:08:13 +02:00

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

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
Rustification Axum/Postgres server
Readme 202 KiB
Languages
Rust 98.7%
Dockerfile 1.3%