Vibecode Pumble
track this build4 phases, 12 steps, beginner friendly0%The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Pumble, run straightforward team channels, direct messages, threads, and file sharing. The hard boundary is cross-platform apps, push, guest access, admin controls, and hosted reliability, plus network, moderation, and communication infrastructure.
You are building a lean indie version of Pumble. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Pumble · indie build 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-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript and a WebSocket server in the same deployment | one thing to run and one thing to restart | | Storage | PostgreSQL through Drizzle, with a per-channel sequence | ordering is a database problem, not a timestamp problem | | Files | Object storage or a mounted volume, referenced by row | attachments outgrow a database faster than messages do | | Delivery | Persist first, then broadcast, with catch-up by sequence | the socket is an optimisation and the database is the record | | Deployment | Docker Compose with migrations and a seeded demo | a chat nobody can start is a chat nobody uses | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Docker and Docker Compose** · free - 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. - Verify: docker compose up brings the stack up and migrations run - [ ] **PostgreSQL** · free 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. - Verify: The application connects and the sequence test passes - [ ] **A reverse proxy with TLS and a known idle timeout** · free, 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 - [ ] **Object storage or a persistent volume for attachments** · pennies 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 - [ ] **Retention and account deletion rules** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Pumble, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Pumble The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a private team chat you host, with channels, threads and files to replace Pumble. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript and a WebSocket server in the same deployment · one thing to run and one thing to restart - Storage: PostgreSQL through Drizzle, with a per-channel sequence · ordering is a database problem, not a timestamp problem - Files: Object storage or a mounted volume, referenced by row · attachments outgrow a database faster than messages do - Delivery: Persist first, then broadcast, with catch-up by sequence · the socket is an optimisation and the database is the record - Deployment: Docker Compose with migrations and a seeded demo · a chat nobody can start is a chat nobody uses ### Data model (create this before Phase 1) - 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. ### Phase 1 · Durable first, delivered second Build: Assign each message a per-channel monotonic sequence in the same transaction that stores it, then broadcast. Have the sender's client show a message as pending until the server returns the sequence. Carry a client-generated identifier so a retry is recognised rather than duplicated. Trap: 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. Done when: Killing the server between commit and broadcast loses no message, and the missing one arrives on reconnection, tested by injecting the crash. Sequences are strictly increasing per channel with no gaps, asserted over a concurrent write test. Sending the same client identifier twice produces one message, tested with a retry. A message is shown as pending until its sequence returns, so nothing appears sent that is not stored. Do not build yet: No reconnection, threads, files or moderation. ### Phase 2 · Connections that die quietly, and catching up Build: Have each client remember the highest sequence it has seen per channel and ask for everything after it on reconnect, in pages. Send application-level heartbeats on an interval well inside the proxy timeout, and reconnect with backoff and jitter. Trap: 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. Done when: A client disconnected for an hour receives every message it missed, in order, in pages, tested by holding a connection closed while writing messages. A missed heartbeat marks the connection dead and triggers reconnection within a stated interval, tested by pausing the server. Reconnection storms are spread by jitter, verified by reconnecting many clients at once. Catch-up never replays a message the client already has, asserted by sequence. Do not build yet: No threads, mentions, files or moderation yet. ### Phase 3 · Threads, mentions and what counts as unread Build: Add threads keyed on a root message, mentions parsed at write time into a stored list, and per-member read positions by sequence. Compute unread counts from the read position rather than from a stored counter. Trap: 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. Done when: An unread count is derived from the last read sequence and self-corrects after a missed broadcast, tested by dropping a delivery. Reading a channel on one client clears it on another within one heartbeat. A thread reply does not raise the channel unread count unless the member is in the thread or mentioned, by a rule stated in the README. Mentions are resolved at write time so renaming a member later does not rewrite history. Do not build yet: No files, moderation or retention yet. ### Phase 4 · Files, reports and a record of decisions Build: Stream uploads to storage under generated keys with the size cap enforced during the stream. Detect the type from content, serve downloads as attachments through short-lived links, and record every report, hide, removal and role change in an audit table. Trap: 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. Done when: An upload over the cap is refused during the stream rather than after buffering, tested with a large file. A file whose content contradicts its claimed type is served with the detected type and an attachment disposition, verified by inspecting the response headers. Every hide, removal and role change appears in the audit table with actor and time. A member removed from a workspace immediately loses access to channel history, tested with an open connection. Do not build yet: No retention, export or deletion yet. ### Phase 5 · Backups, retention and getting everyone out Build: Back up the database and the file store on the same schedule and test a restore of the pair. Apply owner-configured retention that removes messages and their attachments together. Export a workspace as JSON plus files, and support deleting a member's account. Trap: 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. Done when: A restore of database and files together is exercised and an attachment from the last day before the backup opens correctly. Retention removes messages and their attachments together, verified by scanning the file store for orphans. Export produces JSON plus files whose message count matches the database. Deleting an account removes or anonymises that member's messages by a rule stated in the README, and the choice is applied consistently. Do not build yet: Nothing after this. ### Out of scope (and why) - Voice and video rooms, which are an entirely different infrastructure problem with its own servers, bandwidth bill and failure modes. - Native mobile applications with push notifications, each of which is a store account, a review process and a certificate that expires. - Public discovery and an existing member base, which is the actual thing a hosted community product sells and cannot be built. - Enterprise single sign-on and round the clock abuse response, both of which are staffing questions wearing a software costume. ### README must contain - That a message is committed with a per-channel sequence before anyone is told about it - The heartbeat interval, and that it must stay inside whatever idle timeout your proxy applies - The thread notification rule and how unread counts are derived - That backups cover the database and the file store on the same schedule, with a tested paired restore - The account deletion rule, retention periods, and what export contains ===== AGENTS.md ===== # Agent instructions · Pumble indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript and a WebSocket server in the same deployment, PostgreSQL through Drizzle, with a per-channel sequence, Object storage or a mounted volume, referenced by row, Persist first, then broadcast, with catch-up by sequence, Docker Compose with migrations and a seeded demo. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Pumble 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Durable first, delivered second A message is committed with a channel sequence before anyone sees it. ### Steps 1. Assign the sequence in the write transaction The database decides order, because clocks on clients do not agree. Files: `src/messages/store.ts`, `test/sequence.test.ts` 2. Broadcast only after commit A message people saw and cannot find is worse than a message that never sent. Files: `src/realtime/broadcast.ts` 3. Deduplicate on a client identifier Mobile networks retry, and a retry is not a second message. Files: `src/messages/dedupe.ts` ### Done when - [ ] A crash between commit and broadcast loses nothing - [ ] Sequences are strictly increasing with no gaps - [ ] A retried send produces one message - [ ] Pending messages are visibly pending ### 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. ## Phase 2 · Connections that die quietly, and catching up A client that was disconnected for an hour knows exactly what it missed. ### Steps 1. Catch up by sequence, not by timestamp The sequence is the only thing both sides agree on. Files: `src/realtime/catchup.ts`, `test/catchup.test.ts` 2. Heartbeat inside the proxy timeout Read your proxy's current default rather than assuming this one. Files: `src/realtime/heartbeat.ts`, `docs/deployment.md` 3. Back off with jitter A thousand clients reconnecting in the same second is a second outage. Files: `src/client/reconnect.ts` ### Done when - [ ] An hour of missed messages arrives in order - [ ] A missed heartbeat triggers reconnection - [ ] Reconnections are spread by jitter - [ ] Catch-up never replays what the client has ### 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. ## Phase 3 · Threads, mentions and what counts as unread Replies stay attached and the unread count is right after a reconnection. ### Steps 1. Derive unread from a read position Counters drift; positions do not. Files: `src/reads/unread.ts`, `test/unread.test.ts` 2. Resolve mentions at write time A mention is a fact about the moment it was written. Files: `src/messages/mentions.ts` 3. State the thread notification rule Every chat tool answers this differently and all of them document it. Files: `README.md` ### Done when - [ ] Unread counts self-correct after a dropped delivery - [ ] Reading on one client clears it on another - [ ] Thread replies follow the stated notification rule - [ ] Mentions survive a member rename ### 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. ## Phase 4 · Files, reports and a record of decisions Attachments are bounded and inert, and every moderation action leaves a trail. ### Steps 1. Cap during the stream, detect the type Both checks belong before the bytes land, not after. Files: `src/uploads/stream.ts` 2. Serve through short-lived links off-origin An uploaded file should never run as your application. Files: `src/uploads/serve.ts` 3. Audit every moderation action The audit table is what makes a decision reviewable instead of deniable. Files: `src/moderation/audit.ts` ### Done when - [ ] Oversized uploads are cut off mid-stream - [ ] Downloads use a detected type and attachment disposition - [ ] Moderation actions are audited with actor and time - [ ] Removing a member ends access immediately ### 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. ## Not in this build - Voice and video rooms. - Native mobile apps and push notifications. - Public discovery and an existing member base. - Single sign-on and staffed abuse response. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Messages, sequences, read positions. The record. DATABASE_URL=postgres://user:redacted@db:5432/chat # Required. Attachments. Backed up on the same schedule as the database. STORAGE_PATH_OR_URL=s3://chat-attachments # Required. Enforced during the stream. Stated to members before they pick a file. UPLOAD_MAX_MB=25 # Required. Comfortably inside your proxy's idle timeout, which you read rather than assumed. HEARTBEAT_SECONDS=25 # Required. Messages per catch-up page. A quiet fortnight is a lot of pages. CATCHUP_PAGE_SIZE=200 # Required. Zero keeps everything. Anything else removes messages and attachments together. MESSAGE_RETENTION_DAYS=0 # Required. Invitations are the way in, so they expire and are single use. INVITE_EXPIRY_HOURS=72 # Required · secret. Signs sessions. Rotating it signs everyone out, which is the emergency lever. SESSION_SECRET=redacted-32-byte-random
You are building a lean indie version of Pumble. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Pumble · indie build 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-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript and a WebSocket server in the same deployment | one thing to run and one thing to restart | | Storage | PostgreSQL through Drizzle, with a per-channel sequence | ordering is a database problem, not a timestamp problem | | Files | Object storage or a mounted volume, referenced by row | attachments outgrow a database faster than messages do | | Delivery | Persist first, then broadcast, with catch-up by sequence | the socket is an optimisation and the database is the record | | Deployment | Docker Compose with migrations and a seeded demo | a chat nobody can start is a chat nobody uses | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Docker and Docker Compose** · free - 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. - Verify: docker compose up brings the stack up and migrations run - [ ] **PostgreSQL** · free 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. - Verify: The application connects and the sequence test passes - [ ] **A reverse proxy with TLS and a known idle timeout** · free, 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 - [ ] **Object storage or a persistent volume for attachments** · pennies 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 - [ ] **Retention and account deletion rules** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Pumble, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Pumble The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a private team chat you host, with channels, threads and files to replace Pumble. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript and a WebSocket server in the same deployment · one thing to run and one thing to restart - Storage: PostgreSQL through Drizzle, with a per-channel sequence · ordering is a database problem, not a timestamp problem - Files: Object storage or a mounted volume, referenced by row · attachments outgrow a database faster than messages do - Delivery: Persist first, then broadcast, with catch-up by sequence · the socket is an optimisation and the database is the record - Deployment: Docker Compose with migrations and a seeded demo · a chat nobody can start is a chat nobody uses ### Data model (create this before Phase 1) - 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. ### Phase 1 · Durable first, delivered second Build: Assign each message a per-channel monotonic sequence in the same transaction that stores it, then broadcast. Have the sender's client show a message as pending until the server returns the sequence. Carry a client-generated identifier so a retry is recognised rather than duplicated. Trap: 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. Done when: Killing the server between commit and broadcast loses no message, and the missing one arrives on reconnection, tested by injecting the crash. Sequences are strictly increasing per channel with no gaps, asserted over a concurrent write test. Sending the same client identifier twice produces one message, tested with a retry. A message is shown as pending until its sequence returns, so nothing appears sent that is not stored. Do not build yet: No reconnection, threads, files or moderation. ### Phase 2 · Connections that die quietly, and catching up Build: Have each client remember the highest sequence it has seen per channel and ask for everything after it on reconnect, in pages. Send application-level heartbeats on an interval well inside the proxy timeout, and reconnect with backoff and jitter. Trap: 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. Done when: A client disconnected for an hour receives every message it missed, in order, in pages, tested by holding a connection closed while writing messages. A missed heartbeat marks the connection dead and triggers reconnection within a stated interval, tested by pausing the server. Reconnection storms are spread by jitter, verified by reconnecting many clients at once. Catch-up never replays a message the client already has, asserted by sequence. Do not build yet: No threads, mentions, files or moderation yet. ### Phase 3 · Threads, mentions and what counts as unread Build: Add threads keyed on a root message, mentions parsed at write time into a stored list, and per-member read positions by sequence. Compute unread counts from the read position rather than from a stored counter. Trap: 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. Done when: An unread count is derived from the last read sequence and self-corrects after a missed broadcast, tested by dropping a delivery. Reading a channel on one client clears it on another within one heartbeat. A thread reply does not raise the channel unread count unless the member is in the thread or mentioned, by a rule stated in the README. Mentions are resolved at write time so renaming a member later does not rewrite history. Do not build yet: No files, moderation or retention yet. ### Phase 4 · Files, reports and a record of decisions Build: Stream uploads to storage under generated keys with the size cap enforced during the stream. Detect the type from content, serve downloads as attachments through short-lived links, and record every report, hide, removal and role change in an audit table. Trap: 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. Done when: An upload over the cap is refused during the stream rather than after buffering, tested with a large file. A file whose content contradicts its claimed type is served with the detected type and an attachment disposition, verified by inspecting the response headers. Every hide, removal and role change appears in the audit table with actor and time. A member removed from a workspace immediately loses access to channel history, tested with an open connection. Do not build yet: No retention, export or deletion yet. ### Phase 5 · Backups, retention and getting everyone out Build: Back up the database and the file store on the same schedule and test a restore of the pair. Apply owner-configured retention that removes messages and their attachments together. Export a workspace as JSON plus files, and support deleting a member's account. Trap: 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. Done when: A restore of database and files together is exercised and an attachment from the last day before the backup opens correctly. Retention removes messages and their attachments together, verified by scanning the file store for orphans. Export produces JSON plus files whose message count matches the database. Deleting an account removes or anonymises that member's messages by a rule stated in the README, and the choice is applied consistently. Do not build yet: Nothing after this. ### Out of scope (and why) - Voice and video rooms, which are an entirely different infrastructure problem with its own servers, bandwidth bill and failure modes. - Native mobile applications with push notifications, each of which is a store account, a review process and a certificate that expires. - Public discovery and an existing member base, which is the actual thing a hosted community product sells and cannot be built. - Enterprise single sign-on and round the clock abuse response, both of which are staffing questions wearing a software costume. ### README must contain - That a message is committed with a per-channel sequence before anyone is told about it - The heartbeat interval, and that it must stay inside whatever idle timeout your proxy applies - The thread notification rule and how unread counts are derived - That backups cover the database and the file store on the same schedule, with a tested paired restore - The account deletion rule, retention periods, and what export contains ===== AGENTS.md ===== # Agent instructions · Pumble indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript and a WebSocket server in the same deployment, PostgreSQL through Drizzle, with a per-channel sequence, Object storage or a mounted volume, referenced by row, Persist first, then broadcast, with catch-up by sequence, Docker Compose with migrations and a seeded demo. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Pumble 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Durable first, delivered second A message is committed with a channel sequence before anyone sees it. ### Steps 1. Assign the sequence in the write transaction The database decides order, because clocks on clients do not agree. Files: `src/messages/store.ts`, `test/sequence.test.ts` 2. Broadcast only after commit A message people saw and cannot find is worse than a message that never sent. Files: `src/realtime/broadcast.ts` 3. Deduplicate on a client identifier Mobile networks retry, and a retry is not a second message. Files: `src/messages/dedupe.ts` ### Done when - [ ] A crash between commit and broadcast loses nothing - [ ] Sequences are strictly increasing with no gaps - [ ] A retried send produces one message - [ ] Pending messages are visibly pending ### 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. ## Phase 2 · Connections that die quietly, and catching up A client that was disconnected for an hour knows exactly what it missed. ### Steps 1. Catch up by sequence, not by timestamp The sequence is the only thing both sides agree on. Files: `src/realtime/catchup.ts`, `test/catchup.test.ts` 2. Heartbeat inside the proxy timeout Read your proxy's current default rather than assuming this one. Files: `src/realtime/heartbeat.ts`, `docs/deployment.md` 3. Back off with jitter A thousand clients reconnecting in the same second is a second outage. Files: `src/client/reconnect.ts` ### Done when - [ ] An hour of missed messages arrives in order - [ ] A missed heartbeat triggers reconnection - [ ] Reconnections are spread by jitter - [ ] Catch-up never replays what the client has ### 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. ## Phase 3 · Threads, mentions and what counts as unread Replies stay attached and the unread count is right after a reconnection. ### Steps 1. Derive unread from a read position Counters drift; positions do not. Files: `src/reads/unread.ts`, `test/unread.test.ts` 2. Resolve mentions at write time A mention is a fact about the moment it was written. Files: `src/messages/mentions.ts` 3. State the thread notification rule Every chat tool answers this differently and all of them document it. Files: `README.md` ### Done when - [ ] Unread counts self-correct after a dropped delivery - [ ] Reading on one client clears it on another - [ ] Thread replies follow the stated notification rule - [ ] Mentions survive a member rename ### 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. ## Phase 4 · Files, reports and a record of decisions Attachments are bounded and inert, and every moderation action leaves a trail. ### Steps 1. Cap during the stream, detect the type Both checks belong before the bytes land, not after. Files: `src/uploads/stream.ts` 2. Serve through short-lived links off-origin An uploaded file should never run as your application. Files: `src/uploads/serve.ts` 3. Audit every moderation action The audit table is what makes a decision reviewable instead of deniable. Files: `src/moderation/audit.ts` ### Done when - [ ] Oversized uploads are cut off mid-stream - [ ] Downloads use a detected type and attachment disposition - [ ] Moderation actions are audited with actor and time - [ ] Removing a member ends access immediately ### 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. ## Not in this build - Voice and video rooms. - Native mobile apps and push notifications. - Public discovery and an existing member base. - Single sign-on and staffed abuse response. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Messages, sequences, read positions. The record. DATABASE_URL=postgres://user:redacted@db:5432/chat # Required. Attachments. Backed up on the same schedule as the database. STORAGE_PATH_OR_URL=s3://chat-attachments # Required. Enforced during the stream. Stated to members before they pick a file. UPLOAD_MAX_MB=25 # Required. Comfortably inside your proxy's idle timeout, which you read rather than assumed. HEARTBEAT_SECONDS=25 # Required. Messages per catch-up page. A quiet fortnight is a lot of pages. CATCHUP_PAGE_SIZE=200 # Required. Zero keeps everything. Anything else removes messages and attachments together. MESSAGE_RETENTION_DAYS=0 # Required. Invitations are the way in, so they expire and are single use. INVITE_EXPIRY_HOURS=72 # Required · secret. Signs sessions. Rotating it signs everyone out, which is the emergency lever. SESSION_SECRET=redacted-32-byte-random
You are building a production product version of Pumble. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Pumble · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Pumble, run straightforward team channels, direct messages, threads, and file sharing. The hard boundary is cross-platform apps, push, guest access, admin controls, and hosted reliability, plus network, moderation, and communication infrastructure. ## Product outcome A team chat where a message shown as sent is in the database, a client that dropped overnight catches up exactly, an unread badge clears when you read it, and a restore brings back messages and their attachments together. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Docker - PostgreSQL - public HTTPS deployment - email provider - object storage for attachments ## Explicit non-goals for v1 - 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 ## Success criteria - A crash between commit and broadcast is confirmed to lose nothing - An hour of missed messages is confirmed to arrive in order - Unread counts are confirmed to self-correct after a dropped delivery - An oversized upload is confirmed cut off mid-stream - A paired restore is confirmed to open a recent attachment ===== BRIEF.md ===== # Build brief · Pumble The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a private team chat you host, with channels, threads and files to replace Pumble. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript and a WebSocket server in the same deployment · one thing to run and one thing to restart - Storage: PostgreSQL through Drizzle, with a per-channel sequence · ordering is a database problem, not a timestamp problem - Files: Object storage or a mounted volume, referenced by row · attachments outgrow a database faster than messages do - Delivery: Persist first, then broadcast, with catch-up by sequence · the socket is an optimisation and the database is the record - Deployment: Docker Compose with migrations and a seeded demo · a chat nobody can start is a chat nobody uses ### Data model (create this before Phase 1) - 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. ### Phase 1 · Durable first, delivered second Build: Assign each message a per-channel monotonic sequence in the same transaction that stores it, then broadcast. Have the sender's client show a message as pending until the server returns the sequence. Carry a client-generated identifier so a retry is recognised rather than duplicated. Trap: 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. Done when: Killing the server between commit and broadcast loses no message, and the missing one arrives on reconnection, tested by injecting the crash. Sequences are strictly increasing per channel with no gaps, asserted over a concurrent write test. Sending the same client identifier twice produces one message, tested with a retry. A message is shown as pending until its sequence returns, so nothing appears sent that is not stored. Do not build yet: No reconnection, threads, files or moderation. ### Phase 2 · Connections that die quietly, and catching up Build: Have each client remember the highest sequence it has seen per channel and ask for everything after it on reconnect, in pages. Send application-level heartbeats on an interval well inside the proxy timeout, and reconnect with backoff and jitter. Trap: 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. Done when: A client disconnected for an hour receives every message it missed, in order, in pages, tested by holding a connection closed while writing messages. A missed heartbeat marks the connection dead and triggers reconnection within a stated interval, tested by pausing the server. Reconnection storms are spread by jitter, verified by reconnecting many clients at once. Catch-up never replays a message the client already has, asserted by sequence. Do not build yet: No threads, mentions, files or moderation yet. ### Phase 3 · Threads, mentions and what counts as unread Build: Add threads keyed on a root message, mentions parsed at write time into a stored list, and per-member read positions by sequence. Compute unread counts from the read position rather than from a stored counter. Trap: 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. Done when: An unread count is derived from the last read sequence and self-corrects after a missed broadcast, tested by dropping a delivery. Reading a channel on one client clears it on another within one heartbeat. A thread reply does not raise the channel unread count unless the member is in the thread or mentioned, by a rule stated in the README. Mentions are resolved at write time so renaming a member later does not rewrite history. Do not build yet: No files, moderation or retention yet. ### Phase 4 · Files, reports and a record of decisions Build: Stream uploads to storage under generated keys with the size cap enforced during the stream. Detect the type from content, serve downloads as attachments through short-lived links, and record every report, hide, removal and role change in an audit table. Trap: 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. Done when: An upload over the cap is refused during the stream rather than after buffering, tested with a large file. A file whose content contradicts its claimed type is served with the detected type and an attachment disposition, verified by inspecting the response headers. Every hide, removal and role change appears in the audit table with actor and time. A member removed from a workspace immediately loses access to channel history, tested with an open connection. Do not build yet: No retention, export or deletion yet. ### Phase 5 · Backups, retention and getting everyone out Build: Back up the database and the file store on the same schedule and test a restore of the pair. Apply owner-configured retention that removes messages and their attachments together. Export a workspace as JSON plus files, and support deleting a member's account. Trap: 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. Done when: A restore of database and files together is exercised and an attachment from the last day before the backup opens correctly. Retention removes messages and their attachments together, verified by scanning the file store for orphans. Export produces JSON plus files whose message count matches the database. Deleting an account removes or anonymises that member's messages by a rule stated in the README, and the choice is applied consistently. Do not build yet: Nothing after this. ### Out of scope (and why) - Voice and video rooms, which are an entirely different infrastructure problem with its own servers, bandwidth bill and failure modes. - Native mobile applications with push notifications, each of which is a store account, a review process and a certificate that expires. - Public discovery and an existing member base, which is the actual thing a hosted community product sells and cannot be built. - Enterprise single sign-on and round the clock abuse response, both of which are staffing questions wearing a software costume. ### README must contain - That a message is committed with a per-channel sequence before anyone is told about it - The heartbeat interval, and that it must stay inside whatever idle timeout your proxy applies - The thread notification rule and how unread counts are derived - That backups cover the database and the file store on the same schedule, with a tested paired restore - The account deletion rule, retention periods, and what export contains ===== ARCHITECTURE.md ===== # Architecture · Pumble ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript and a WebSocket server in the same deployment | one thing to run and one thing to restart | | Storage | PostgreSQL through Drizzle, with a per-channel sequence | ordering is a database problem, not a timestamp problem | | Files | Object storage or a mounted volume, referenced by row | attachments outgrow a database faster than messages do | | Delivery | Persist first, then broadcast, with catch-up by sequence | the socket is an optimisation and the database is the record | | Deployment | Docker Compose with migrations and a seeded demo | a chat nobody can start is a chat nobody uses | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Messages | Durable writes with per-channel sequences. | Any store with transactional sequences; never a timestamp ordering. | | Realtime | Broadcast after commit, heartbeats, catch-up. | Any transport, provided catch-up is by sequence. | | Reads | Per-member read positions and derived unread counts. | Any scheme, provided counts are derived rather than incremented. | | Files | Streamed caps, detected types, off-origin serving. | Any storage, provided it is backed up with the database. | | Moderation | Reports, reversible hiding and an audit trail. | Any workflow, provided actions stay recorded. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Messages, sequences, read positions. The record. - `STORAGE_PATH_OR_URL` · required · Attachments. Backed up on the same schedule as the database. - `UPLOAD_MAX_MB` · required · Enforced during the stream. Stated to members before they pick a file. - `HEARTBEAT_SECONDS` · required · Comfortably inside your proxy's idle timeout, which you read rather than assumed. - `CATCHUP_PAGE_SIZE` · required · Messages per catch-up page. A quiet fortnight is a lot of pages. - `MESSAGE_RETENTION_DAYS` · required · Zero keeps everything. Anything else removes messages and attachments together. - `INVITE_EXPIRY_HOURS` · required · Invitations are the way in, so they expire and are single use. - `SESSION_SECRET` · required, secret · Signs sessions. Rotating it signs everyone out, which is the emergency lever. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Pumble product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript and a WebSocket server in the same deployment, PostgreSQL through Drizzle, with a per-channel sequence, Object storage or a mounted volume, referenced by row, Persist first, then broadcast, with catch-up by sequence, Docker Compose with migrations and a seeded demo. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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. ===== MILESTONES.md ===== # Delivery milestones · Pumble Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Durable first, delivered second A message is committed with a channel sequence before anyone sees it. ### Steps 1. Assign the sequence in the write transaction The database decides order, because clocks on clients do not agree. Files: `src/messages/store.ts`, `test/sequence.test.ts` 2. Broadcast only after commit A message people saw and cannot find is worse than a message that never sent. Files: `src/realtime/broadcast.ts` 3. Deduplicate on a client identifier Mobile networks retry, and a retry is not a second message. Files: `src/messages/dedupe.ts` ### Done when - [ ] A crash between commit and broadcast loses nothing - [ ] Sequences are strictly increasing with no gaps - [ ] A retried send produces one message - [ ] Pending messages are visibly pending ### 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. ## M2 · Connections that die quietly, and catching up A client that was disconnected for an hour knows exactly what it missed. ### Steps 1. Catch up by sequence, not by timestamp The sequence is the only thing both sides agree on. Files: `src/realtime/catchup.ts`, `test/catchup.test.ts` 2. Heartbeat inside the proxy timeout Read your proxy's current default rather than assuming this one. Files: `src/realtime/heartbeat.ts`, `docs/deployment.md` 3. Back off with jitter A thousand clients reconnecting in the same second is a second outage. Files: `src/client/reconnect.ts` ### Done when - [ ] An hour of missed messages arrives in order - [ ] A missed heartbeat triggers reconnection - [ ] Reconnections are spread by jitter - [ ] Catch-up never replays what the client has ### 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. ## M3 · Threads, mentions and what counts as unread Replies stay attached and the unread count is right after a reconnection. ### Steps 1. Derive unread from a read position Counters drift; positions do not. Files: `src/reads/unread.ts`, `test/unread.test.ts` 2. Resolve mentions at write time A mention is a fact about the moment it was written. Files: `src/messages/mentions.ts` 3. State the thread notification rule Every chat tool answers this differently and all of them document it. Files: `README.md` ### Done when - [ ] Unread counts self-correct after a dropped delivery - [ ] Reading on one client clears it on another - [ ] Thread replies follow the stated notification rule - [ ] Mentions survive a member rename ### 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. ## M4 · Files, reports and a record of decisions Attachments are bounded and inert, and every moderation action leaves a trail. ### Steps 1. Cap during the stream, detect the type Both checks belong before the bytes land, not after. Files: `src/uploads/stream.ts` 2. Serve through short-lived links off-origin An uploaded file should never run as your application. Files: `src/uploads/serve.ts` 3. Audit every moderation action The audit table is what makes a decision reviewable instead of deniable. Files: `src/moderation/audit.ts` ### Done when - [ ] Oversized uploads are cut off mid-stream - [ ] Downloads use a detected type and attachment disposition - [ ] Moderation actions are audited with actor and time - [ ] Removing a member ends access immediately ### 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. ## M5 · Backups, retention and getting everyone out (production only) The database and the files restore together, retention is enforced, and export is complete. ### Steps 1. Snapshot database and files together Two schedules are two restores that do not fit each other. Files: `scripts/backup.sh`, `test/restore.test.ts` 2. Delete messages and attachments together An orphaned attachment is retained data you told people you had removed. Files: `src/retention/purge.ts` 3. Decide and document the account deletion rule Removing a person's messages breaks threads; anonymising them does not. Pick one and say so. Files: `src/admin/delete-account.ts`, `README.md` ### Done when - [ ] A paired restore opens a recent attachment - [ ] Retention leaves no orphaned files - [ ] Export counts match the database - [ ] Account deletion follows the documented rule ### Watch 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. ===== OPERATIONS.md ===== # Operations · Pumble ## Backup Database and file store on one schedule, snapshotted close enough together that a restore is internally consistent. Test by opening an attachment from the last day before the snapshot, which is the case a mismatched schedule breaks. ## Restore Restore both, run migrations, and check that the highest sequence per channel matches what clients last saw. Clients will ask for everything after their own position, so a restore that goes backwards means people see messages arrive twice. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Connected client count against members, heartbeat failures, catch-up page volume and the moderation queue. A spike in catch-up volume usually means the proxy is closing connections and nobody has noticed. ## Incident checklist A missing message is checked in the database by sequence first, because the socket is an optimisation and the table is the record. Repeated disconnections are almost always an idle timeout somewhere in front of you rather than anything in the application. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A crash between commit and broadcast is confirmed to lose nothing - [ ] An hour of missed messages is confirmed to arrive in order - [ ] Unread counts are confirmed to self-correct after a dropped delivery - [ ] An oversized upload is confirmed cut off mid-stream - [ ] A paired restore is confirmed to open a recent attachment ## Launch constraint Do not market omitted Pumble capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Messages, sequences, read positions. The record. DATABASE_URL=postgres://user:redacted@db:5432/chat # Required. Attachments. Backed up on the same schedule as the database. STORAGE_PATH_OR_URL=s3://chat-attachments # Required. Enforced during the stream. Stated to members before they pick a file. UPLOAD_MAX_MB=25 # Required. Comfortably inside your proxy's idle timeout, which you read rather than assumed. HEARTBEAT_SECONDS=25 # Required. Messages per catch-up page. A quiet fortnight is a lot of pages. CATCHUP_PAGE_SIZE=200 # Required. Zero keeps everything. Anything else removes messages and attachments together. MESSAGE_RETENTION_DAYS=0 # Required. Invitations are the way in, so they expire and are single use. INVITE_EXPIRY_HOURS=72 # Required · secret. Signs sessions. Rotating it signs everyone out, which is the emergency lever. SESSION_SECRET=redacted-32-byte-random
# Pumble · indie build 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-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript and a WebSocket server in the same deployment | one thing to run and one thing to restart | | Storage | PostgreSQL through Drizzle, with a per-channel sequence | ordering is a database problem, not a timestamp problem | | Files | Object storage or a mounted volume, referenced by row | attachments outgrow a database faster than messages do | | Delivery | Persist first, then broadcast, with catch-up by sequence | the socket is an optimisation and the database is the record | | Deployment | Docker Compose with migrations and a seeded demo | a chat nobody can start is a chat nobody uses | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Docker and Docker Compose** · free - 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. - Verify: docker compose up brings the stack up and migrations run - [ ] **PostgreSQL** · free 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. - Verify: The application connects and the sequence test passes - [ ] **A reverse proxy with TLS and a known idle timeout** · free, 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 - [ ] **Object storage or a persistent volume for attachments** · pennies 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 - [ ] **Retention and account deletion rules** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Pumble, and the README should say so rather than pretend.
# Build brief · Pumble The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a private team chat you host, with channels, threads and files to replace Pumble. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript and a WebSocket server in the same deployment · one thing to run and one thing to restart - Storage: PostgreSQL through Drizzle, with a per-channel sequence · ordering is a database problem, not a timestamp problem - Files: Object storage or a mounted volume, referenced by row · attachments outgrow a database faster than messages do - Delivery: Persist first, then broadcast, with catch-up by sequence · the socket is an optimisation and the database is the record - Deployment: Docker Compose with migrations and a seeded demo · a chat nobody can start is a chat nobody uses ### Data model (create this before Phase 1) - 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. ### Phase 1 · Durable first, delivered second Build: Assign each message a per-channel monotonic sequence in the same transaction that stores it, then broadcast. Have the sender's client show a message as pending until the server returns the sequence. Carry a client-generated identifier so a retry is recognised rather than duplicated. Trap: 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. Done when: Killing the server between commit and broadcast loses no message, and the missing one arrives on reconnection, tested by injecting the crash. Sequences are strictly increasing per channel with no gaps, asserted over a concurrent write test. Sending the same client identifier twice produces one message, tested with a retry. A message is shown as pending until its sequence returns, so nothing appears sent that is not stored. Do not build yet: No reconnection, threads, files or moderation. ### Phase 2 · Connections that die quietly, and catching up Build: Have each client remember the highest sequence it has seen per channel and ask for everything after it on reconnect, in pages. Send application-level heartbeats on an interval well inside the proxy timeout, and reconnect with backoff and jitter. Trap: 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. Done when: A client disconnected for an hour receives every message it missed, in order, in pages, tested by holding a connection closed while writing messages. A missed heartbeat marks the connection dead and triggers reconnection within a stated interval, tested by pausing the server. Reconnection storms are spread by jitter, verified by reconnecting many clients at once. Catch-up never replays a message the client already has, asserted by sequence. Do not build yet: No threads, mentions, files or moderation yet. ### Phase 3 · Threads, mentions and what counts as unread Build: Add threads keyed on a root message, mentions parsed at write time into a stored list, and per-member read positions by sequence. Compute unread counts from the read position rather than from a stored counter. Trap: 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. Done when: An unread count is derived from the last read sequence and self-corrects after a missed broadcast, tested by dropping a delivery. Reading a channel on one client clears it on another within one heartbeat. A thread reply does not raise the channel unread count unless the member is in the thread or mentioned, by a rule stated in the README. Mentions are resolved at write time so renaming a member later does not rewrite history. Do not build yet: No files, moderation or retention yet. ### Phase 4 · Files, reports and a record of decisions Build: Stream uploads to storage under generated keys with the size cap enforced during the stream. Detect the type from content, serve downloads as attachments through short-lived links, and record every report, hide, removal and role change in an audit table. Trap: 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. Done when: An upload over the cap is refused during the stream rather than after buffering, tested with a large file. A file whose content contradicts its claimed type is served with the detected type and an attachment disposition, verified by inspecting the response headers. Every hide, removal and role change appears in the audit table with actor and time. A member removed from a workspace immediately loses access to channel history, tested with an open connection. Do not build yet: No retention, export or deletion yet. ### Phase 5 · Backups, retention and getting everyone out Build: Back up the database and the file store on the same schedule and test a restore of the pair. Apply owner-configured retention that removes messages and their attachments together. Export a workspace as JSON plus files, and support deleting a member's account. Trap: 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. Done when: A restore of database and files together is exercised and an attachment from the last day before the backup opens correctly. Retention removes messages and their attachments together, verified by scanning the file store for orphans. Export produces JSON plus files whose message count matches the database. Deleting an account removes or anonymises that member's messages by a rule stated in the README, and the choice is applied consistently. Do not build yet: Nothing after this. ### Out of scope (and why) - Voice and video rooms, which are an entirely different infrastructure problem with its own servers, bandwidth bill and failure modes. - Native mobile applications with push notifications, each of which is a store account, a review process and a certificate that expires. - Public discovery and an existing member base, which is the actual thing a hosted community product sells and cannot be built. - Enterprise single sign-on and round the clock abuse response, both of which are staffing questions wearing a software costume. ### README must contain - That a message is committed with a per-channel sequence before anyone is told about it - The heartbeat interval, and that it must stay inside whatever idle timeout your proxy applies - The thread notification rule and how unread counts are derived - That backups cover the database and the file store on the same schedule, with a tested paired restore - The account deletion rule, retention periods, and what export contains
# Agent instructions · Pumble indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript and a WebSocket server in the same deployment, PostgreSQL through Drizzle, with a per-channel sequence, Object storage or a mounted volume, referenced by row, Persist first, then broadcast, with catch-up by sequence, Docker Compose with migrations and a seeded demo. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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.
# Build plan · Pumble 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Durable first, delivered second A message is committed with a channel sequence before anyone sees it. ### Steps 1. Assign the sequence in the write transaction The database decides order, because clocks on clients do not agree. Files: `src/messages/store.ts`, `test/sequence.test.ts` 2. Broadcast only after commit A message people saw and cannot find is worse than a message that never sent. Files: `src/realtime/broadcast.ts` 3. Deduplicate on a client identifier Mobile networks retry, and a retry is not a second message. Files: `src/messages/dedupe.ts` ### Done when - [ ] A crash between commit and broadcast loses nothing - [ ] Sequences are strictly increasing with no gaps - [ ] A retried send produces one message - [ ] Pending messages are visibly pending ### 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. ## Phase 2 · Connections that die quietly, and catching up A client that was disconnected for an hour knows exactly what it missed. ### Steps 1. Catch up by sequence, not by timestamp The sequence is the only thing both sides agree on. Files: `src/realtime/catchup.ts`, `test/catchup.test.ts` 2. Heartbeat inside the proxy timeout Read your proxy's current default rather than assuming this one. Files: `src/realtime/heartbeat.ts`, `docs/deployment.md` 3. Back off with jitter A thousand clients reconnecting in the same second is a second outage. Files: `src/client/reconnect.ts` ### Done when - [ ] An hour of missed messages arrives in order - [ ] A missed heartbeat triggers reconnection - [ ] Reconnections are spread by jitter - [ ] Catch-up never replays what the client has ### 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. ## Phase 3 · Threads, mentions and what counts as unread Replies stay attached and the unread count is right after a reconnection. ### Steps 1. Derive unread from a read position Counters drift; positions do not. Files: `src/reads/unread.ts`, `test/unread.test.ts` 2. Resolve mentions at write time A mention is a fact about the moment it was written. Files: `src/messages/mentions.ts` 3. State the thread notification rule Every chat tool answers this differently and all of them document it. Files: `README.md` ### Done when - [ ] Unread counts self-correct after a dropped delivery - [ ] Reading on one client clears it on another - [ ] Thread replies follow the stated notification rule - [ ] Mentions survive a member rename ### 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. ## Phase 4 · Files, reports and a record of decisions Attachments are bounded and inert, and every moderation action leaves a trail. ### Steps 1. Cap during the stream, detect the type Both checks belong before the bytes land, not after. Files: `src/uploads/stream.ts` 2. Serve through short-lived links off-origin An uploaded file should never run as your application. Files: `src/uploads/serve.ts` 3. Audit every moderation action The audit table is what makes a decision reviewable instead of deniable. Files: `src/moderation/audit.ts` ### Done when - [ ] Oversized uploads are cut off mid-stream - [ ] Downloads use a detected type and attachment disposition - [ ] Moderation actions are audited with actor and time - [ ] Removing a member ends access immediately ### 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. ## Not in this build - Voice and video rooms. - Native mobile apps and push notifications. - Public discovery and an existing member base. - Single sign-on and staffed abuse response. ## After v1, if you want it - 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
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Messages, sequences, read positions. The record. DATABASE_URL=postgres://user:redacted@db:5432/chat # Required. Attachments. Backed up on the same schedule as the database. STORAGE_PATH_OR_URL=s3://chat-attachments # Required. Enforced during the stream. Stated to members before they pick a file. UPLOAD_MAX_MB=25 # Required. Comfortably inside your proxy's idle timeout, which you read rather than assumed. HEARTBEAT_SECONDS=25 # Required. Messages per catch-up page. A quiet fortnight is a lot of pages. CATCHUP_PAGE_SIZE=200 # Required. Zero keeps everything. Anything else removes messages and attachments together. MESSAGE_RETENTION_DAYS=0 # Required. Invitations are the way in, so they expire and are single use. INVITE_EXPIRY_HOURS=72 # Required · secret. Signs sessions. Rotating it signs everyone out, which is the emergency lever. SESSION_SECRET=redacted-32-byte-random
# Pumble · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Pumble, run straightforward team channels, direct messages, threads, and file sharing. The hard boundary is cross-platform apps, push, guest access, admin controls, and hosted reliability, plus network, moderation, and communication infrastructure. ## Product outcome A team chat where a message shown as sent is in the database, a client that dropped overnight catches up exactly, an unread badge clears when you read it, and a restore brings back messages and their attachments together. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Docker - PostgreSQL - public HTTPS deployment - email provider - object storage for attachments ## Explicit non-goals for v1 - 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 ## Success criteria - A crash between commit and broadcast is confirmed to lose nothing - An hour of missed messages is confirmed to arrive in order - Unread counts are confirmed to self-correct after a dropped delivery - An oversized upload is confirmed cut off mid-stream - A paired restore is confirmed to open a recent attachment
# Build brief · Pumble The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a private team chat you host, with channels, threads and files to replace Pumble. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript and a WebSocket server in the same deployment · one thing to run and one thing to restart - Storage: PostgreSQL through Drizzle, with a per-channel sequence · ordering is a database problem, not a timestamp problem - Files: Object storage or a mounted volume, referenced by row · attachments outgrow a database faster than messages do - Delivery: Persist first, then broadcast, with catch-up by sequence · the socket is an optimisation and the database is the record - Deployment: Docker Compose with migrations and a seeded demo · a chat nobody can start is a chat nobody uses ### Data model (create this before Phase 1) - 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. ### Phase 1 · Durable first, delivered second Build: Assign each message a per-channel monotonic sequence in the same transaction that stores it, then broadcast. Have the sender's client show a message as pending until the server returns the sequence. Carry a client-generated identifier so a retry is recognised rather than duplicated. Trap: 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. Done when: Killing the server between commit and broadcast loses no message, and the missing one arrives on reconnection, tested by injecting the crash. Sequences are strictly increasing per channel with no gaps, asserted over a concurrent write test. Sending the same client identifier twice produces one message, tested with a retry. A message is shown as pending until its sequence returns, so nothing appears sent that is not stored. Do not build yet: No reconnection, threads, files or moderation. ### Phase 2 · Connections that die quietly, and catching up Build: Have each client remember the highest sequence it has seen per channel and ask for everything after it on reconnect, in pages. Send application-level heartbeats on an interval well inside the proxy timeout, and reconnect with backoff and jitter. Trap: 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. Done when: A client disconnected for an hour receives every message it missed, in order, in pages, tested by holding a connection closed while writing messages. A missed heartbeat marks the connection dead and triggers reconnection within a stated interval, tested by pausing the server. Reconnection storms are spread by jitter, verified by reconnecting many clients at once. Catch-up never replays a message the client already has, asserted by sequence. Do not build yet: No threads, mentions, files or moderation yet. ### Phase 3 · Threads, mentions and what counts as unread Build: Add threads keyed on a root message, mentions parsed at write time into a stored list, and per-member read positions by sequence. Compute unread counts from the read position rather than from a stored counter. Trap: 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. Done when: An unread count is derived from the last read sequence and self-corrects after a missed broadcast, tested by dropping a delivery. Reading a channel on one client clears it on another within one heartbeat. A thread reply does not raise the channel unread count unless the member is in the thread or mentioned, by a rule stated in the README. Mentions are resolved at write time so renaming a member later does not rewrite history. Do not build yet: No files, moderation or retention yet. ### Phase 4 · Files, reports and a record of decisions Build: Stream uploads to storage under generated keys with the size cap enforced during the stream. Detect the type from content, serve downloads as attachments through short-lived links, and record every report, hide, removal and role change in an audit table. Trap: 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. Done when: An upload over the cap is refused during the stream rather than after buffering, tested with a large file. A file whose content contradicts its claimed type is served with the detected type and an attachment disposition, verified by inspecting the response headers. Every hide, removal and role change appears in the audit table with actor and time. A member removed from a workspace immediately loses access to channel history, tested with an open connection. Do not build yet: No retention, export or deletion yet. ### Phase 5 · Backups, retention and getting everyone out Build: Back up the database and the file store on the same schedule and test a restore of the pair. Apply owner-configured retention that removes messages and their attachments together. Export a workspace as JSON plus files, and support deleting a member's account. Trap: 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. Done when: A restore of database and files together is exercised and an attachment from the last day before the backup opens correctly. Retention removes messages and their attachments together, verified by scanning the file store for orphans. Export produces JSON plus files whose message count matches the database. Deleting an account removes or anonymises that member's messages by a rule stated in the README, and the choice is applied consistently. Do not build yet: Nothing after this. ### Out of scope (and why) - Voice and video rooms, which are an entirely different infrastructure problem with its own servers, bandwidth bill and failure modes. - Native mobile applications with push notifications, each of which is a store account, a review process and a certificate that expires. - Public discovery and an existing member base, which is the actual thing a hosted community product sells and cannot be built. - Enterprise single sign-on and round the clock abuse response, both of which are staffing questions wearing a software costume. ### README must contain - That a message is committed with a per-channel sequence before anyone is told about it - The heartbeat interval, and that it must stay inside whatever idle timeout your proxy applies - The thread notification rule and how unread counts are derived - That backups cover the database and the file store on the same schedule, with a tested paired restore - The account deletion rule, retention periods, and what export contains
# Architecture · Pumble ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript and a WebSocket server in the same deployment | one thing to run and one thing to restart | | Storage | PostgreSQL through Drizzle, with a per-channel sequence | ordering is a database problem, not a timestamp problem | | Files | Object storage or a mounted volume, referenced by row | attachments outgrow a database faster than messages do | | Delivery | Persist first, then broadcast, with catch-up by sequence | the socket is an optimisation and the database is the record | | Deployment | Docker Compose with migrations and a seeded demo | a chat nobody can start is a chat nobody uses | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Messages | Durable writes with per-channel sequences. | Any store with transactional sequences; never a timestamp ordering. | | Realtime | Broadcast after commit, heartbeats, catch-up. | Any transport, provided catch-up is by sequence. | | Reads | Per-member read positions and derived unread counts. | Any scheme, provided counts are derived rather than incremented. | | Files | Streamed caps, detected types, off-origin serving. | Any storage, provided it is backed up with the database. | | Moderation | Reports, reversible hiding and an audit trail. | Any workflow, provided actions stay recorded. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Messages, sequences, read positions. The record. - `STORAGE_PATH_OR_URL` · required · Attachments. Backed up on the same schedule as the database. - `UPLOAD_MAX_MB` · required · Enforced during the stream. Stated to members before they pick a file. - `HEARTBEAT_SECONDS` · required · Comfortably inside your proxy's idle timeout, which you read rather than assumed. - `CATCHUP_PAGE_SIZE` · required · Messages per catch-up page. A quiet fortnight is a lot of pages. - `MESSAGE_RETENTION_DAYS` · required · Zero keeps everything. Anything else removes messages and attachments together. - `INVITE_EXPIRY_HOURS` · required · Invitations are the way in, so they expire and are single use. - `SESSION_SECRET` · required, secret · Signs sessions. Rotating it signs everyone out, which is the emergency lever. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Pumble product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript and a WebSocket server in the same deployment, PostgreSQL through Drizzle, with a per-channel sequence, Object storage or a mounted volume, referenced by row, Persist first, then broadcast, with catch-up by sequence, Docker Compose with migrations and a seeded demo. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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.
# Delivery milestones · Pumble Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Durable first, delivered second A message is committed with a channel sequence before anyone sees it. ### Steps 1. Assign the sequence in the write transaction The database decides order, because clocks on clients do not agree. Files: `src/messages/store.ts`, `test/sequence.test.ts` 2. Broadcast only after commit A message people saw and cannot find is worse than a message that never sent. Files: `src/realtime/broadcast.ts` 3. Deduplicate on a client identifier Mobile networks retry, and a retry is not a second message. Files: `src/messages/dedupe.ts` ### Done when - [ ] A crash between commit and broadcast loses nothing - [ ] Sequences are strictly increasing with no gaps - [ ] A retried send produces one message - [ ] Pending messages are visibly pending ### 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. ## M2 · Connections that die quietly, and catching up A client that was disconnected for an hour knows exactly what it missed. ### Steps 1. Catch up by sequence, not by timestamp The sequence is the only thing both sides agree on. Files: `src/realtime/catchup.ts`, `test/catchup.test.ts` 2. Heartbeat inside the proxy timeout Read your proxy's current default rather than assuming this one. Files: `src/realtime/heartbeat.ts`, `docs/deployment.md` 3. Back off with jitter A thousand clients reconnecting in the same second is a second outage. Files: `src/client/reconnect.ts` ### Done when - [ ] An hour of missed messages arrives in order - [ ] A missed heartbeat triggers reconnection - [ ] Reconnections are spread by jitter - [ ] Catch-up never replays what the client has ### 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. ## M3 · Threads, mentions and what counts as unread Replies stay attached and the unread count is right after a reconnection. ### Steps 1. Derive unread from a read position Counters drift; positions do not. Files: `src/reads/unread.ts`, `test/unread.test.ts` 2. Resolve mentions at write time A mention is a fact about the moment it was written. Files: `src/messages/mentions.ts` 3. State the thread notification rule Every chat tool answers this differently and all of them document it. Files: `README.md` ### Done when - [ ] Unread counts self-correct after a dropped delivery - [ ] Reading on one client clears it on another - [ ] Thread replies follow the stated notification rule - [ ] Mentions survive a member rename ### 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. ## M4 · Files, reports and a record of decisions Attachments are bounded and inert, and every moderation action leaves a trail. ### Steps 1. Cap during the stream, detect the type Both checks belong before the bytes land, not after. Files: `src/uploads/stream.ts` 2. Serve through short-lived links off-origin An uploaded file should never run as your application. Files: `src/uploads/serve.ts` 3. Audit every moderation action The audit table is what makes a decision reviewable instead of deniable. Files: `src/moderation/audit.ts` ### Done when - [ ] Oversized uploads are cut off mid-stream - [ ] Downloads use a detected type and attachment disposition - [ ] Moderation actions are audited with actor and time - [ ] Removing a member ends access immediately ### 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. ## M5 · Backups, retention and getting everyone out (production only) The database and the files restore together, retention is enforced, and export is complete. ### Steps 1. Snapshot database and files together Two schedules are two restores that do not fit each other. Files: `scripts/backup.sh`, `test/restore.test.ts` 2. Delete messages and attachments together An orphaned attachment is retained data you told people you had removed. Files: `src/retention/purge.ts` 3. Decide and document the account deletion rule Removing a person's messages breaks threads; anonymising them does not. Pick one and say so. Files: `src/admin/delete-account.ts`, `README.md` ### Done when - [ ] A paired restore opens a recent attachment - [ ] Retention leaves no orphaned files - [ ] Export counts match the database - [ ] Account deletion follows the documented rule ### Watch 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.
# Operations · Pumble ## Backup Database and file store on one schedule, snapshotted close enough together that a restore is internally consistent. Test by opening an attachment from the last day before the snapshot, which is the case a mismatched schedule breaks. ## Restore Restore both, run migrations, and check that the highest sequence per channel matches what clients last saw. Clients will ask for everything after their own position, so a restore that goes backwards means people see messages arrive twice. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Connected client count against members, heartbeat failures, catch-up page volume and the moderation queue. A spike in catch-up volume usually means the proxy is closing connections and nobody has noticed. ## Incident checklist A missing message is checked in the database by sequence first, because the socket is an optimisation and the table is the record. Repeated disconnections are almost always an idle timeout somewhere in front of you rather than anything in the application. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A crash between commit and broadcast is confirmed to lose nothing - [ ] An hour of missed messages is confirmed to arrive in order - [ ] Unread counts are confirmed to self-correct after a dropped delivery - [ ] An oversized upload is confirmed cut off mid-stream - [ ] A paired restore is confirmed to open a recent attachment ## Launch constraint Do not market omitted Pumble capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Messages, sequences, read positions. The record. DATABASE_URL=postgres://user:redacted@db:5432/chat # Required. Attachments. Backed up on the same schedule as the database. STORAGE_PATH_OR_URL=s3://chat-attachments # Required. Enforced during the stream. Stated to members before they pick a file. UPLOAD_MAX_MB=25 # Required. Comfortably inside your proxy's idle timeout, which you read rather than assumed. HEARTBEAT_SECONDS=25 # Required. Messages per catch-up page. A quiet fortnight is a lot of pages. CATCHUP_PAGE_SIZE=200 # Required. Zero keeps everything. Anything else removes messages and attachments together. MESSAGE_RETENTION_DAYS=0 # Required. Invitations are the way in, so they expire and are single use. INVITE_EXPIRY_HOURS=72 # Required · secret. Signs sessions. Rotating it signs everyone out, which is the emergency lever. SESSION_SECRET=redacted-32-byte-random
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Pumble because community software is valuable when members are already there and the operator does not have to own realtime reliability and abuse response. The recurring cost buys realtime delivery, notifications, abuse handling, search, media, permissions, mobile clients, backups, and community operations, not just the visible interface.
xcross-platform apps, push, guest access, admin controls, and hosted reliability
xnetwork effects
xnative mobile apps
xvideo and voice infrastructure
xenterprise moderation and discovery
Don't feel like building it? These folks already made it free.
all 6 free alternatives to Pumble →· no votes, no pay-to-list · just what's real
Pumble pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | Unlimited users, message history and channels; 10 GB storage/workspace; 1:1 meetings; 3 third-party integrations plus Clockify and Plaky |
| pro | $2.99/user | $2.49/user | 10 GB storage/paid seat; 10 integrations; group meetings |
| business | $4.99/user | $3.99/user | 20 GB storage/paid seat; unlimited integrations; meeting recording; 5 free single-channel guests per paid seat |
| enterprise | $7.99/user | $6.99/user | 100 GB storage/paid seat; SSO and retention controls |
| cake.com bundle | $15.99/user | $12.99/user | Pumble plus Clockify and Plaky; charged once per unique user across the bundled products |
free tierunlimited users, messages and channels; 10 GB/workspace; 1:1 meetings; 3 external integrations plus Clockify and Plaky
billingmonthly + annual
hidden costsOwners, admins, members and multi-channel guests count as paid seats; deactivated users do not. Business includes 5 free single-channel guests per paid seat, but multi-channel guests are paid. Bundle billing counts each unique user across the included apps.
verified 2026-08-12 · source ↗
Vibecode Pumble
Kinda. The core of Pumble is buildable in a weekend with the prompt on this page, but there are real gaps: cross-platform apps, push, guest access, admin controls, and hosted reliability, network effects. Read the honest list above before committing.
How much does Pumble cost?
Pumble costs about $2.99/month (Pro, checked 2026-07-31), which is $35.88 per year.
What do I lose by replacing Pumble?
Honestly: cross-platform apps, push, guest access, admin controls, and hosted reliability; network effects; native mobile apps; video and voice infrastructure; enterprise moderation and discovery. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Pumble?
Yes: Rocket.Chat Community (Slack-shaped chat for up to a hundred concurrent users; MongoDB and upgrades are now yours.) Mattermost Team Edition (Full self-hosted team chat for under 250 users; free software, non-free evenings.) Zulip (Channels become streams and every conversation gets a topic; async teams usually call that an upgrade.) All 6 curated free alternatives are at vibecodeit.com/pumble/alternatives. The prompt is for when you want it exactly your way.