Build Pumble

KINDAreplaces $2.99/mosaves $35.88/yrback to the verdict

0%0 of 17 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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.

estimated effort multi-daythe files for this build are in the project pack

RuntimeNext.js 15 with TypeScript and a WebSocket server in the same deploymentStoragePostgreSQL through Drizzle, with a per-channel sequenceFilesObject storage or a mounted volume, referenced by rowDeliveryPersist first, then broadcast, with catch-up by sequenceDeploymentDocker Compose with migrations and a seeded demo

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. 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

  2. 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

  3. 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

  4. 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

  5. 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.

VariableNeededExampleWhere the value comes from
DATABASE_URLsecretrequiredpostgres://user:redacted@db:5432/chatMessages, sequences, read positions. The record.
STORAGE_PATH_OR_URLrequireds3://chat-attachmentsAttachments. Backed up on the same schedule as the database.
UPLOAD_MAX_MBrequired25Enforced during the stream. Stated to members before they pick a file.
HEARTBEAT_SECONDSrequired25Comfortably inside your proxy's idle timeout, which you read rather than assumed.
CATCHUP_PAGE_SIZErequired200Messages per catch-up page. A quiet fortnight is a lot of pages.
MESSAGE_RETENTION_DAYSrequired0Zero keeps everything. Anything else removes messages and attachments together.
INVITE_EXPIRY_HOURSrequired72Invitations are the way in, so they expire and are single use.
SESSION_SECRETsecretrequiredredacted-32-byte-randomSigns sessions. Rotating it signs everyone out, which is the emergency lever.

The build, in order

  1. Durable first, delivered second

    A message is committed with a channel sequence before anyone sees it.

    1. The database decides order, because clocks on clients do not agree.

      Files src/messages/store.tstest/sequence.test.ts

    2. A message people saw and cannot find is worse than a message that never sent.

      Files src/realtime/broadcast.ts

    3. Mobile networks retry, and a retry is not a second message.

      Files src/messages/dedupe.ts

    done when · tick each as it passes
    watch 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.
  2. Connections that die quietly, and catching up

    A client that was disconnected for an hour knows exactly what it missed.

    1. The sequence is the only thing both sides agree on.

      Files src/realtime/catchup.tstest/catchup.test.ts

    2. Read your proxy's current default rather than assuming this one.

      Files src/realtime/heartbeat.tsdocs/deployment.md

    3. A thousand clients reconnecting in the same second is a second outage.

      Files src/client/reconnect.ts

    done when · tick each as it passes
    watch 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.
  3. Threads, mentions and what counts as unread

    Replies stay attached and the unread count is right after a reconnection.

    1. Counters drift; positions do not.

      Files src/reads/unread.tstest/unread.test.ts

    2. A mention is a fact about the moment it was written.

      Files src/messages/mentions.ts

    3. Every chat tool answers this differently and all of them document it.

      Files README.md

    done when · tick each as it passes
    watch 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.
  4. Files, reports and a record of decisions

    Attachments are bounded and inert, and every moderation action leaves a trail.

    1. Both checks belong before the bytes land, not after.

      Files src/uploads/stream.ts

    2. An uploaded file should never run as your application.

      Files src/uploads/serve.ts

    3. The audit table is what makes a decision reviewable instead of deniable.

      Files src/moderation/audit.ts

    done when · tick each as it passes
    watch 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.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more community.