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

FRCreate a paste and receive a short shareable URL. Retrieve a paste by slug. No registration, no login, no cookies required for either.
NFRSlugs must survive a voice channel — speakable, memorable for ~30 seconds. Expired content must be unrecoverable: rows deleted, not flagged. Zero marginal cost per paste.
ConstraintsOne 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. Истечение срока применяется дважды: лениво при каждом чтении и периодической зачисткой, удаляющей то, за чем никто не вернулся.

Browser React SPA nginx static + proxy Fastify API TypeScript · :3002 SQLite single file · pastes HTTPS /api read / write expiry sweep
Container view. Solid lines are the request path; the dashed loop is the periodic sweep deleting expired rows.
Контейнерная диаграмма. Сплошные линии — путь запроса; пунктирная петля — периодическая зачистка истёкших строк.

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 curl sequences 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. Гарантия, которую пользователь не видит, — гарантия, которой он не верит.