CANDIDATE=[email protected]
Сервис на TypeScript: выгружает ленту писем из provider, складывает в SQLite и раскладывает письма по разговорам.
cp .env.example .env # прописать свой CANDIDATE
docker compose up worker
docker compose run --rm exporterРезультат — ./out/result.jsonl. Самопроверка: node selfcheck.js out/result.jsonl.
Локальные тесты (вне Docker): npm install && npm test. Линтер и форматтер — Biome: npm run lint, npm run format (конфиг — biome.json). Lint, сборка и тесты также гоняются в CI (.github/workflows/ci.yml) на каждый push и pull request.
SQLite в режиме WAL, файл в named volume mail-data — отдельная служба БД не нужна, миграции это CREATE TABLE IF NOT EXISTS при старте приложения. Две таблицы:
messages— одна строка на письмо, первичный ключexternal_id; повторные письма от провайдера гасятся черезINSERT OR IGNORE. Ссылки (in_reply_to,references) хранятся в том виде, в котором пришли;thread_keyиparent_idзаполняются финальным разбором.state— key-value: текущий курсор пагинации и флаг завершения обхода.
Страница писем и новый курсор сохраняются одной транзакцией, поэтому после остановки обход продолжается с последней полностью сохранённой страницы.
Новый источник присылает письма сам (push), отдаёт готовые разговоры, но не имеет своих message-id. Без изменений останутся: таблица messages, экспортер и формат result.jsonl, дедупликация по первичному ключу, docker-инфраструктура. Придётся изменить: вместо цикла пагинации — приёмник push-сообщений; добавить колонку source и генерировать суррогатный external_id (например, хеш содержимого с префиксом источника); для этого источника не нужны union-find и выбор parent_id — разговор берём готовый, пространства thread_key источников разводим префиксом.
Данные не испортятся: вставки защищены первичным ключом, SQLite сериализует транзакции, курсор по сути детерминирован (одна и та же страница даёт тот же next_cursor), а финальный разбор идемпотентен. Нарушится экономика обхода: оба процесса запросят одни и те же страницы, удвоив частоту обращений к провайдеру, — это почти гарантированно приведёт к 429 и удлинит паузы. Защита — один экземпляр worker (в compose он и запускается одиночным) либо advisory lock в БД, если бы БД была клиент-серверной.
Такого шага нет. Вставка писем — INSERT OR IGNORE по первичному ключу; сохранение курсора — перезапись одного значения; финальный разбор разговоров — чистая функция от содержимого messages, её можно гонять сколько угодно раз; экспорт — полная перезапись файла.
- SQLite вместо клиент-серверной БД: один писатель и десятки тысяч строк — серверная БД добавила бы службу, не дав ничего взамен. Решение изменится, если появится второй активный писатель или конкурентные читатели.
- Разбор разговоров одним финальным проходом в памяти вместо инкрементального union-find при вставке каждого письма: письма приходят вразнобой и «мосты» всё равно требуют слияний, а полный пересчёт проще, идемпотентен и занимает секунды. Пришлось бы перейти на инкремент, если бы результат был нужен онлайн, по ходу обхода.
Первым упрётся финальный разбор: он держит в памяти все письма и union-find целиком — на миллионах писем это сотни мегабайт; лечится потоковой обработкой. Следом — полная перезапись thread_key/parent_id одной транзакцией. Сам обход ленты масштабируется линейно и памятью не ограничен, экспортер уже потоковый (курсорная выборка + backpressure).