Build Pumble
KINDAreplaces $2.99/mosaves $35.88/yrback to the verdict
A self-hosted team chat whose hard problems are never losing a message that was shown as sent, letting a reconnecting client work out exactly what it missed, and keeping a connection alive through the proxy sitting in front of it.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why The application, the database and the file store come up together, and a chat tool that takes an afternoon to start will not be started.
Get it Docker Desktop or your distribution's packages. open ↗
Verify
docker compose up brings the stack up and migrations run - installfree locally, a few pounds a month managed
Why Per-channel sequences and read positions both depend on transactional guarantees, and this is not a place to save money with a simpler store.
Get it Included in the compose file, or a managed instance. open ↗
Verify
The application connects and the sequence test passes - have readyfree, or the cost of the host
Why Every WebSocket in this build lives or dies by the proxy's idle timeout, and the heartbeat interval has to be chosen against the actual configured value rather than a remembered one.
Get it Any proxy that upgrades connections, with its timeout read from the current documentation and written into your notes.
Verify
A connection stays open through a period of silence longer than the default timeout - have readypennies per month at small scale
Why Attachments outgrow expectations quickly and they must be backed up on the same schedule as the database or a restore is inconsistent.
Get it An object store with credentials, or a volume that your backup already covers.
Verify
An upload lands, a download works, and both appear in the backup - decidefree
Why Both are decisions about other people's words and both are much harder to introduce once a workspace has a year of history in it.
Get it Decide how long messages are kept, and whether deleting an account removes or anonymises their messages.
Verify
Both are in the configuration and the README, and both are enforced by a test
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- workspaces(id, name, created_at) and members(workspace_id, user_id, role, joined_at, removed_at) - channels(id, workspace_id, name, kind, archived_at) · kind covers public, private and direct - messages(id, channel_id, seq, author_id, body, thread_root_id, created_at, edited_at, deleted_at) · seq is monotonic per channel and assigned by the database - reads(channel_id, user_id, last_read_seq) · one row per member per channel, which quietly becomes the busiest table you own - attachments(id, message_id, stored_key, original_name, mime_detected, bytes, sha256, scanned_at) - reports(id, target_kind, target_id, reporter_id, reason, created_at, resolved_at, action) and audit(id, actor_id, action, target, at) · moderation is a record, not a delete - A message is durable with a sequence number before any client is told about it. Everything the socket does can be reconstructed from the messages table, which is what makes a dropped connection uninteresting.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_URLsecret | required | postgres://user:redacted@db:5432/chat | Messages, sequences, read positions. The record. |
STORAGE_PATH_OR_URL | required | s3://chat-attachments | Attachments. Backed up on the same schedule as the database. |
UPLOAD_MAX_MB | required | 25 | Enforced during the stream. Stated to members before they pick a file. |
HEARTBEAT_SECONDS | required | 25 | Comfortably inside your proxy's idle timeout, which you read rather than assumed. |
CATCHUP_PAGE_SIZE | required | 200 | Messages per catch-up page. A quiet fortnight is a lot of pages. |
MESSAGE_RETENTION_DAYS | required | 0 | Zero keeps everything. Anything else removes messages and attachments together. |
INVITE_EXPIRY_HOURS | required | 72 | Invitations are the way in, so they expire and are single use. |
SESSION_SECRETsecret | required | redacted-32-byte-random | Signs sessions. Rotating it signs everyone out, which is the emergency lever. |
The build, in order
Durable first, delivered second
A message is committed with a channel sequence before anyone sees it.
The database decides order, because clocks on clients do not agree.
Files
src/messages/store.tstest/sequence.test.tsA message people saw and cannot find is worse than a message that never sent.
Files
src/realtime/broadcast.tsMobile networks retry, and a retry is not a second message.
Files
src/messages/dedupe.ts
done when · tick each as it passeswatch out- Broadcasting to connected clients and then writing to the database means a crash between the two leaves a message that several people read and nobody can find afterwards, which destroys trust in the whole tool far more than an outage would. Commit first, broadcast second, and let the sequence number be the proof the message exists.
Connections that die quietly, and catching up
A client that was disconnected for an hour knows exactly what it missed.
The sequence is the only thing both sides agree on.
Files
src/realtime/catchup.tstest/catchup.test.tsRead your proxy's current default rather than assuming this one.
Files
src/realtime/heartbeat.tsdocs/deployment.mdA thousand clients reconnecting in the same second is a second outage.
Files
src/client/reconnect.ts
done when · tick each as it passeswatch out- A WebSocket through a default nginx configuration is closed after sixty seconds of silence, because proxy_read_timeout defaults to sixty seconds and nginx's own WebSocket documentation says the connection will be closed if nothing is transmitted within it, offering a longer timeout or periodic ping frames as the two remedies. A quiet channel overnight therefore drops every client, and browsers do not always report it, so the tab looks connected and shows nothing. Send heartbeats on a shorter interval than the proxy allows, and treat a missed heartbeat as a disconnection.
Threads, mentions and what counts as unread
Replies stay attached and the unread count is right after a reconnection.
Counters drift; positions do not.
Files
src/reads/unread.tstest/unread.test.tsA mention is a fact about the moment it was written.
Files
src/messages/mentions.tsEvery chat tool answers this differently and all of them document it.
Files
README.md
done when · tick each as it passeswatch out- Keeping an unread counter as a number and incrementing it on delivery drifts the moment a client misses a broadcast or reads on a second device, and the counter that will not clear is the single most irritating bug in every chat product ever shipped. Store the last read sequence and derive the count, so reading anywhere fixes it everywhere.
Files, reports and a record of decisions
Attachments are bounded and inert, and every moderation action leaves a trail.
Both checks belong before the bytes land, not after.
Files
src/uploads/stream.tsAn uploaded file should never run as your application.
Files
src/uploads/serve.tsThe audit table is what makes a decision reviewable instead of deniable.
Files
src/moderation/audit.ts
done when · tick each as it passeswatch out- Serving an uploaded file from the same origin as the application means an uploaded page can run with the application's cookies, and letting the uploader's filename decide the extension makes that easy to arrange. Serve attachments from a separate origin or through short-lived links with an attachment disposition and a detected type, and never from the client's filename.
Backups, retention and getting everyone outproduct builder
The database and the files restore together, retention is enforced, and export is complete.
Two schedules are two restores that do not fit each other.
Files
scripts/backup.shtest/restore.test.tsAn orphaned attachment is retained data you told people you had removed.
Files
src/retention/purge.tsRemoving a person's messages breaks threads; anonymising them does not. Pick one and say so.
Files
src/admin/delete-account.tsREADME.md
done when · tick each as it passeswatch out- Backing up the database nightly and the file store weekly produces a restore where six days of messages reference attachments that do not exist, and the gap is invisible until somebody clicks a file. Snapshot both on the same schedule, and test the restore by opening an attachment from the most recent day.
That is the whole plan for Pumble. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Voice and video rooms.
- Native mobile apps and push notifications.
- Public discovery and an existing member base.
- Single sign-on and staffed abuse response.
- cross-platform apps, push, guest access, admin controls, and hosted reliability
- network effects
- native mobile apps
- video and voice infrastructure
- enterprise moderation and discovery
- Search across channels, once the message table has a realistic amount of history in it
- A read-only guest role, which the member table already almost supports
- Message editing history, which is a column and a view rather than a new subsystem
Need the files? The project pack on the verdict page hands your agent the whole brief · more community.