Case study
Разбор проекта
Pastebin.Monster
Ephemeral paste sharing with slugs you can read out loud. Designed to decay: every paste is gone minutes after it stops being needed.
Эфемерный обмен сниппетами со слагами, которые можно продиктовать вслух. Спроектирован на исчезновение: каждая запись удаляется через считанные минуты после того, как перестала быть нужной.
Node.jsFastifyTypeScriptSQLiteReactVitenginx
01 · Context
01 · Контекст
The problem
Moving a snippet of text between two machines — or two people — is still oddly heavy. Mainstream pastebins want accounts, show ads, and hand back permanent URLs made of random base62 characters. That last part is the real failure: try dictating xK9mQ2vTr8 over a voice call.
The insight this project is built on: a paste is a transfer, not a document. Its useful life is measured in minutes. Once the recipient has it, the honest thing for the system to do is destroy it — permanence is a liability, not a feature.
The scope is deliberately tiny. The exercise was to take a weekend-sized system and run it with full engineering ceremony — explicit requirements, seven ADRs, verified expiry semantics — as a study in right-sizing: applying exactly as much architecture as the problem deserves, and no more.
Проблема
Передать фрагмент текста между двумя машинами — или двумя людьми — до сих пор достаточно неудобно. Популярные пастбины требуют аккаунт, показывают рекламу и выдают вечные URL из случайных base62-символов. Последнее — главный провал: попробуйте продиктовать xK9mQ2vTr8 по голосовой связи.
Идея, на которой построен проект: паста — это передача, а не документ. Её полезная жизнь измеряется минутами. Как только получатель её открыл, честное поведение системы — уничтожить содержимое. Постоянство здесь — не фича, а риск.
Масштаб сознательно минимальный. Задача была взять систему «на выходные» и провести её через полный инженерный цикл — явные требования, семь ADR, верифицированная семантика истечения — как упражнение в соразмерности: ровно столько архитектуры, сколько заслуживает задача, и ни граммом больше.
02 · Requirements & constraints
02 · Требования и ограничения
What it must do — and under what conditions
| FR | Create a paste and receive a short shareable URL. Retrieve a paste by slug. No registration, no login, no cookies required for either. |
|---|---|
| NFR | Slugs must survive a voice channel — speakable, memorable for ~30 seconds. Expired content must be unrecoverable: rows deleted, not flagged. Zero marginal cost per paste. |
| Constraints | One shared VPS hosting other Softwarean projects; nginx in front; API on port 3002; SQLite only — no database daemon; no auth system (the friction budget is zero). |
Что система обязана делать — и в каких условиях
| FR | Создать пасту и получить короткий URL для передачи. Получить пасту по слагу. Без регистрации, без логина, без обязательных cookies. |
|---|---|
| NFR | Слаг должен переживать голосовой канал — произносимый, запоминаемый на ~30 секунд. Истёкший контент невосстановим: строки удаляются, а не помечаются. Нулевая предельная стоимость одной пасты. |
| Ограничения | Один общий VPS с другими проектами Softwarean; nginx на фронте; API на порту 3002; только SQLite — без отдельного демона БД; без системы аутентификации (бюджет — ноль). |
03 · Architecture
03 · Архитектура
Three moving parts, one file of state
A React SPA served as static files by nginx; a Fastify API in TypeScript behind the same nginx on port 3002; a single SQLite file as the only state. Expiry is enforced twice — lazily on every read, and by a periodic sweep that deletes what nobody came back for.
Три компонента, состояние в одном файле
React-SPA раздаётся как статика через nginx; Fastify-API на TypeScript за тем же nginx на порту 3002; единственное состояние — один файл SQLite. Истечение срока применяется дважды: лениво при каждом чтении и периодической зачисткой, удаляющей то, за чем никто не вернулся.
04 · Key decisions
04 · Ключевые решения
Four decisions that shaped the system
Digests of the ADRs that mattered. Each one names what the decision cost — a trade-off you don't acknowledge is a trade-off you didn't see.
Четыре решения, определившие систему
Выжимки из значимых ADR. В каждой названа цена решения — трейд-офф, который не назван, это трейд-офф, который не был замечен.
Two-timer expiry
Двухтаймерное истечение
- Options
- Fixed TTL from creation · burn-after-reading · user-selected TTL.
- Chosen
- Two timers: 5 minutes from creation if never viewed, otherwise 5 minutes from the last view.
- Why
- Fixed TTL fails the "recipient opens the link late" case; burn-after-reading fails the multi-device and multi-recipient case. Two timers preserve the actual guarantee: content decays minutes after it stops being needed — not before, not long after.
- What it cost
- The semantics need a sentence to explain instead of a number. Two timestamps instead of one. And every read becomes a write, because viewing resets the clock.
- Варианты
- Фиксированный TTL от создания · burn-after-reading · TTL по выбору пользователя.
- Выбрано
- Два таймера: 5 минут от создания, если пасту не открывали, иначе 5 минут от последнего просмотра.
- Почему
- Фиксированный TTL ломается на сценарии «получатель открыл ссылку поздно»; burn-after-reading — на сценарии с несколькими устройствами или получателями. Два таймера сохраняют настоящую гарантию: контент исчезает через минуты после того, как перестал быть нужным — не раньше и не сильно позже.
- Цена
- Семантику приходится объяснять предложением, а не числом. Два таймстемпа вместо одного. И каждое чтение становится записью — просмотр сбрасывает таймер.
Speakable slugs
Произносимые слаги
- Options
- Random base62 · UUID · user-chosen names · adjective-noun-number.
- Chosen
adjective-noun-number, e.g.blue-tree-421.- Why
- Dictation over a voice channel is the founding use case. A slug built from real words survives a phone call; ten characters of base62 do not.
- What it cost
- A drastically smaller namespace — slugs are guessable in principle. Accepted because the enumeration window is minutes long, the product is positioned for non-sensitive content, and inserts retry on collision.
- Варианты
- Случайный base62 · UUID · имена от пользователя · прилагательное-существительное-число.
- Выбрано
прилагательное-существительное-число, напримерblue-tree-421.- Почему
- Диктовка по голосовой связи — исходный сценарий продукта. Слаг из настоящих слов переживает телефонный звонок; десять символов base62 — нет.
- Цена
- Резко меньшее пространство имён — слаги в принципе перебираемы. Принято: окно для перебора живёт минуты, продукт позиционирован для нечувствительного контента, вставка повторяется при коллизии.
SQLite, not Postgres
SQLite, а не Postgres
- Options
- PostgreSQL · Redis with TTL · SQLite.
- Chosen
- SQLite, one file, accessed in-process.
- Why
- Write volume is tiny, data is ephemeral by design, and the VPS already runs enough daemons. Redis TTL was tempting — expiry for free — but it can't express the two-timer model without application logic anyway, so it buys nothing.
- What it cost
- A single-writer ceiling. Irrelevant at this scale, and the exit path (same SQL, different driver) is documented rather than pre-built.
- Варианты
- PostgreSQL · Redis с TTL · SQLite.
- Выбрано
- SQLite: один файл, доступ внутри процесса.
- Почему
- Объём записи крошечный, данные эфемерны по замыслу, а на VPS и так хватает демонов. Redis с TTL был соблазнителен — истечение «из коробки», — но двухтаймерную модель он всё равно не выражает без логики в приложении, так что не даёт ничего.
- Цена
- Потолок «один писатель». На этом масштабе не имеет значения; путь миграции (тот же SQL, другой драйвер) задокументирован, но не построен заранее.
No accounts, ever
Никаких аккаунтов
- Options
- Optional accounts for paste management · anonymous with edit tokens · fully anonymous.
- Chosen
- Fully anonymous. Nothing to sign up for, nothing to manage.
- Why
- Identity adds nothing to an artifact that lives five minutes, and every field on a form spends the friction budget — which is zero.
- What it cost
- No abuse control through identity. Abuse control has to come from the expiry model and size caps instead — a real limitation, acknowledged in the retrospective.
- Варианты
- Опциональные аккаунты для управления пастами · анонимно с edit-токенами · полностью анонимно.
- Выбрано
- Полная анонимность. Нечего регистрировать, нечем управлять.
- Почему
- Идентичность ничего не даёт артефакту, живущему пять минут, а каждое поле формы тратит бюджет на трение — а он нулевой.
- Цена
- Нет контроля злоупотреблений через идентичность. Он должен обеспечиваться моделью истечения и лимитами размера — реальное ограничение, признанное в ретроспективе.
05 · Data model
05 · Модель данных
One table is the whole truth
pastes(slug PK, content, created_at, last_viewed_at). Expiry is computed from the two timestamps at read time, never stored: a nullable last_viewed_at is what distinguishes the "never opened" branch of the timer from the "viewed" branch. Storing a precomputed expires_at would have meant updating it on every view anyway — deriving it keeps one source of truth.
Одна таблица — вся правда
pastes(slug PK, content, created_at, last_viewed_at). Срок истечения вычисляется из двух таймстемпов при чтении и нигде не хранится: nullable-поле last_viewed_at и есть развилка между ветками «не открывали» и «просмотрено». Хранение готового expires_at всё равно потребовало бы обновления при каждом просмотре — вычисление оставляет один источник истины.
06 · Hard problems
06 · Трудные места
What counts as a "view"?
The two-timer model rests on one word — viewed — and that word turned out to be load-bearing. Paste a link into a messenger and the messenger's link-preview bot fetches the URL before any human does. If a bot fetch counts as a view, it silently resets (or effectively consumes) the recipient's timer. The fix is definitional, not technical: a "view" is the API call the SPA makes for content, not a hit on the HTML shell — and preview bots never execute the SPA.
The expiry race
A read arriving in the same instant the sweep deletes the row is a classic check-then-act race. Resolved by making the read path authoritative: it evaluates expiry itself and returns 404 for a stale row even if the sweep hasn't collected it yet. The sweep is garbage collection, not the enforcement mechanism.
Что считается «просмотром»?
Двухтаймерная модель держится на одном слове — просмотр — и это слово оказалось несущим. Вставьте ссылку в мессенджер — и бот превью ссылок запросит URL раньше любого человека. Если запрос бота считается просмотром, он незаметно сбрасывает (а по сути расходует) таймер получателя. Решение определением, а не технологией: «просмотр» — это API-запрос контента из SPA, а не обращение к HTML-оболочке; боты превью SPA не исполняют.
Гонка на истечении
Чтение, приходящее в тот же момент, когда зачистка удаляет строку, — классическая гонка check-then-act. Решено тем, что путь чтения сделан авторитетным: он сам вычисляет истечение и возвращает 404 для просроченной строки, даже если зачистка её ещё не собрала. Зачистка — это сборка мусора, а не механизм гарантии.
07 · Verification
07 · Верификация
The NFRs were tested, not assumed
- Timed
curlsequences against production confirmed both timer branches: a never-viewed paste returns 404 shortly after t+5:00; a paste polled continuously survives, then dies five minutes after the last poll. - Unrecoverability checked at the storage layer: after expiry, the row is absent from the SQLite file — deleted, not soft-flagged.
- The read-path-authoritative rule verified by querying a paste in the window between logical expiry and the next sweep: 404, as designed.
NFR проверены, а не предположены
- Хронометрированные последовательности
curlк продакшену подтвердили обе ветки таймера: непросмотренная паста отдаёт 404 вскоре после t+5:00; паста под непрерывным опросом живёт — и умирает через пять минут после последнего запроса. - Невосстановимость проверена на уровне хранилища: после истечения строка отсутствует в файле SQLite — удалена, а не помечена.
- Правило «путь чтения авторитетен» проверено запросом в окне между логическим истечением и следующей зачисткой: 404, как и спроектировано.
08 · Retrospective
08 · Ретроспектива
If I rebuilt it
- Rate limiting and size caps would come before slug design, not after. With no accounts, they are the entire abuse-control story, and I underweighted that.
- Client-side encryption with the key in the URL fragment would make the server zero-knowledge — the natural v2, and it composes cleanly with the existing model since the server never needs to read content.
- The two-timer semantics deserve a visible countdown in the UI. A guarantee the user can't see is a guarantee they don't trust.
Если бы строил заново
- Rate limiting и лимиты размера шли бы до дизайна слагов, а не после. Без аккаунтов они — весь контроль злоупотреблений, и я недооценил это.
- Шифрование на клиенте с ключом во фрагменте URL сделало бы сервер zero-knowledge — естественная v2, чисто состыкуется с текущей моделью: серверу и так не нужно читать контент.
- Двухтаймерная семантика заслуживает видимого обратного отсчёта в UI. Гарантия, которую пользователь не видит, — гарантия, которой он не верит.