[{"data":1,"prerenderedAt":941},["ShallowReactive",2],{"api:\u002Fapi\u002Fconfig:ru":3,"api:\u002Fapi\u002Fengineering:ru":8},{"email":4,"lang":5,"name":6,"role":7},"hello@ewsirdev.example","ru","Ewsirdev","Веб-разработчик: сайты, лендинги и сервисы на Go",[9,98,175,244,298,348,393,478,552,629,705,790,863],{"slug":10,"title":11,"stack":12,"summary":19,"nodes":20,"edges":49,"flows":63,"decisions":71,"tradeoffs":84,"tests":87,"source":88,"stats":89},"job-queue","Конвейер: очередь задач",[13,14,15,16,17,18],"Go","SQLite","REST API","SSE","Prometheus","Docker","Очередь фоновых задач на SQLite: приоритеты, ретраи с backoff, cron, идемпотентные ключи. Главное — гарантии: at-least-once, lease и fencing.",[21,27,31,36,39,42,46],{"id":22,"label":23,"row":24,"col":24,"span":25,"kind":26},"prod","Клиент API\nPOST \u002Fapi\u002Fjobs",0,1,"client",{"id":28,"label":29,"row":24,"col":25,"span":25,"kind":30},"cron","Планировщик\ncron-расписания","core",{"id":32,"label":33,"row":25,"col":24,"span":34,"kind":35},"db","SQLite (WAL)\njobs · runs · schedules",2,"store",{"id":37,"label":38,"row":34,"col":24,"span":25,"kind":30},"claim","Захват задачи\nUPDATE … RETURNING",{"id":40,"label":41,"row":34,"col":25,"span":25,"kind":30},"reaper","Reaper\nистёкшие lease",{"id":43,"label":44,"row":45,"col":24,"span":25,"kind":30},"pool","Пул воркеров\nheartbeat аренды",3,{"id":47,"label":48,"row":45,"col":25,"span":25,"kind":26},"dash","Дашборд и \u002Fmetrics\nSSE · Prometheus",[50,51,52,53,55,58,60],{"n":25,"from":22,"to":32},{"n":34,"from":28,"to":32},{"n":45,"from":32,"to":37},{"n":54,"from":37,"to":43},4,{"n":56,"from":43,"to":32,"side":57},5,"L",{"n":59,"from":40,"to":32},6,{"n":61,"from":32,"to":47,"side":62},7,"R",[64,65,66,67,68,69,70],"Задача ставится через REST под Bearer-токеном; повторная постановка с тем же идемпотентным ключом возвращает уже существующую задачу.","Каждый запуск cron ставится с ключом cron:\u003Cid>:\u003Cвремя>, а next_run сдвигается условным UPDATE, поэтому несколько процессов не создадут дубль.","Самая приоритетная готовая задача выбирается по priority DESC, run_at, id.","Один оператор выставляет статус, счётчик попыток, срок lease и уникальный токен аренды и отдаёт задачу воркеру.","Воркер продлевает аренду heartbeat-ом каждую треть срока и завершает задачу (Complete\u002FFail), предъявляя токен: «зомби»-воркер получает ErrLeaseLost.","Reaper возвращает задачи с истёкшей арендой в очередь с backoff или в dead, если попытки кончились.","Снимок состояния раз в секунду уходит в дашборд по SSE; счётчики и гистограмма доступны на \u002Fmetrics.",[72,75,78,81],{"title":73,"text":74},"Атомарный захват одним UPDATE … RETURNING","Выбор задачи и выдача аренды происходят в одном операторе, поэтому два воркера, даже из разных процессов на одном файле БД, не получат одну задачу.",{"title":76,"text":77},"Lease, heartbeat и fencing-токен","Упавший воркер не теряет задачу: аренда истекает, а Complete\u002FFail\u002FExtend проверяют токен, так что очнувшийся «зомби» не может испортить результат.",{"title":79,"text":80},"At-least-once, а не exactly-once","Обработчик может быть вызван повторно, поэтому обработчики должны быть идемпотентными. Очередь не обещает того, чего не может гарантировать.",{"title":82,"text":83},"Backoff с jitter","Задержка BACKOFF_BASE · 2^(n-1) с потолком, умноженная на случайный множитель 0,5–1; Permanent-ошибка и неизвестный тип сразу уходят в dead.",[85,86],"Один файл SQLite: масштабирование только на одной машине (несколько процессов на одном томе допустимы, по сети нет).","Счётчики метрик живут в памяти и обнуляются при перезапуске; нет цепочек задач, rate limit по очередям и ролей (один токен).","Конкурентный захват (400 задач, два «процесса» на одном файле, 12 воркеров — ни одного двойного исполнения), истечение lease и fencing, идемпотентные ключи в гонке, cron из двух процессов, graceful shutdown.","README проекта job-queue: «Архитектура», «Гарантии доставки», «Ограничения»",{"slug":10,"kind":90,"go_lines":91,"go_test_lines":92,"tests":93,"endpoints":94,"html":95,"css":96,"js":97},"go",2161,833,25,19,138,188,331,{"slug":99,"title":100,"stack":101,"summary":102,"nodes":103,"edges":127,"flows":139,"decisions":149,"tradeoffs":162,"tests":165,"source":166,"stats":167},"uptime-monitor","Пульс: монитор доступности",[13,14,16,15,18],"Монитор доступности: HTTP и TCP проверки, аптайм, p50\u002Fp95, инциденты, живой дашборд и публичная статус-страница.",[104,107,110,113,116,118,121,124],{"id":105,"label":106,"row":24,"col":24,"span":25,"kind":30},"mgr","Manager\nгорутина на проверку",{"id":108,"label":109,"row":24,"col":25,"span":25,"kind":26},"target","Цели\nHTTP \u002F TCP",{"id":111,"label":112,"row":25,"col":24,"span":25,"kind":30},"run","Run\nзамер проверки",{"id":114,"label":115,"row":25,"col":25,"span":25,"kind":30},"hub","Hub (pub\u002Fsub)\nнеблокирующий",{"id":35,"label":117,"row":34,"col":24,"span":34,"kind":35},"SQLite (WAL)\nпроверки · результаты · инциденты",{"id":119,"label":120,"row":45,"col":24,"span":25,"kind":30},"stats","stats\np50\u002Fp95 · аптайм",{"id":122,"label":123,"row":45,"col":25,"span":25,"kind":30},"det","Detector\nинциденты",{"id":125,"label":126,"row":54,"col":24,"span":34,"kind":26},"api","REST + SSE\nдашборд · \u002Fstatus",[128,129,130,131,132,133,134,135,137],{"n":25,"from":105,"to":111},{"n":34,"from":111,"to":108},{"n":45,"from":111,"to":35},{"n":54,"from":111,"to":114},{"n":56,"from":114,"to":122,"side":62},{"n":59,"from":122,"to":35},{"n":61,"from":35,"to":119},{"n":136,"from":119,"to":125},8,{"n":138,"from":114,"to":125,"side":62},9,[140,141,142,143,144,145,146,147,148],"Manager держит отдельную горутину на каждую проверку; следующий запуск планируется после завершения предыдущего, поэтому проверки одного адреса не накладываются.","Run делает HTTP GET (до 5 редиректов, ожидаемый код или любой \u003C 400) либо TCP connect с таймаутом.","Результат записывается в SQLite.","После записи результат публикуется в Hub.","Detector получает результат: fail_threshold падений подряд открывают инцидент, первая успешная проверка его закрывает.","Смена состояния пишется как инцидент; открытый инцидент переживает перезапуск.","Аптайм за 24 ч, 7 и 30 дней и p50\u002Fp95 считаются запросами по сырой истории (чистые функции в internal\u002Fstats).","REST отдаёт цифры дашборду и публичной статус-странице (только public-проверки, без адресов и текстов ошибок).","Hub рассылает события SSE-подписчикам неблокирующе: медленный клиент теряет события, а не тормозит проверки.",[150,153,156,159],{"title":151,"text":152},"Перцентили методом ближайшего ранга","p-й процентиль — элемент отсортированного среза с номером ceil(p\u002F100·N). Простая чистая функция без интерполяции, легко проверить тестом.",{"title":154,"text":155},"Не накладывать проверки","Следующий запуск планируется после завершения предыдущего: медленный адрес не копит параллельные проверки сам на себя.",{"title":157,"text":158},"Медленный клиент не тормозит мониторинг","Рассылка в Hub неблокирующая; после переподключения дашборд заново читает состояние.",{"title":160,"text":161},"SSE через fetch, а не EventSource","Так токен передаётся в заголовке Authorization, а не в URL.",[163,164],"Аптайм считается по числу проверок, а не по длительности: у проверок с разным интервалом цифры несопоставимы по точности.","Агрегаты считаются по сырой истории: для десятков проверок мгновенно, для тысяч понадобятся предагрегаты. Одна нода, один токен, уведомлений нет.","Планировщик, перцентили и аптайм, жизненный цикл инцидента (в том числе после перезапуска), HTTP\u002FTCP проверки с таймаутом, API, SSE.","README проекта uptime-monitor: «Возможности», «Архитектура», «Ограничения»",{"slug":99,"kind":90,"go_lines":168,"go_test_lines":169,"tests":170,"endpoints":171,"html":172,"css":173,"js":174},1445,589,18,15,126,172,369,{"slug":176,"title":177,"stack":178,"summary":181,"nodes":182,"edges":203,"flows":211,"decisions":219,"tradeoffs":232,"tests":235,"source":236,"stats":237},"realtime-rooms","Комнаты: чат и доска в реальном времени",[13,179,14,180,18],"WebSocket","Canvas","Комнаты на WebSocket: чат с историей, присутствие, общая доска. Протокол RFC 6455 написан на stdlib, чтобы показать, что происходит под new WebSocket(…).",[183,186,189,191,194,197,200],{"id":184,"label":185,"row":24,"col":24,"span":34,"kind":26},"browsers","Браузеры\nWebSocket-клиенты",{"id":187,"label":188,"row":25,"col":24,"span":34,"kind":30},"ws","internal\u002Fws\nрукопожатие · фреймы · лимиты",{"id":114,"label":190,"row":34,"col":24,"span":34,"kind":30},"Hub комнат\nмьютекс на комнату",{"id":192,"label":193,"row":45,"col":24,"span":25,"kind":30},"queue","Очередь клиента\n256 сообщений",{"id":195,"label":196,"row":45,"col":25,"span":25,"kind":30},"pump","write pump\nдедлайн записи 10 с",{"id":198,"label":199,"row":54,"col":24,"span":25,"kind":35},"sql","SQLite (WAL)\nистория чата",{"id":201,"label":202,"row":54,"col":25,"span":25,"kind":35},"board","Доска в памяти\nштрихи",[204,205,206,207,208,209,210],{"n":25,"from":184,"to":187},{"n":34,"from":187,"to":114},{"n":45,"from":114,"to":192},{"n":54,"from":192,"to":195},{"n":56,"from":195,"to":184,"side":62},{"n":59,"from":114,"to":198,"side":57},{"n":61,"from":114,"to":201,"side":62},[212,213,214,215,216,217,218],"Браузер открывает WebSocket: GET с Upgrade, версия 13, ключ, проверка Origin; ответ 101 с Sec-WebSocket-Accept.","Фреймы разбираются в internal\u002Fws: маскирование, фрагментация, лимит размера, UTF-8, ping\u002Fpong; JSON-сообщение уходит в хаб (read pump).","Рассылка кладёт сообщение в ограниченную очередь каждого клиента; при переполнении медленный клиент отключается кодом 1008, остальные не ждут.","write pump забирает сообщения из очереди и пишет фреймы с дедлайном 10 с.","Фреймы доходят до браузеров; эфемерные сообщения (курсоры, «печатает») пропускаются при заполнении очереди на 3\u002F4.","Сообщение рассылается только после успешной записи в SQLite; при ошибке отправитель получает error: store.","Штрихи доски хранятся в памяти комнаты и отдаются вошедшим позже.",[220,223,226,229],{"title":221,"text":222},"Свой слой WebSocket","Около 530 строк в internal\u002Fws: рукопожатие, фреймы, маскирование, фрагментация, ping\u002Fpong и close-коды. Цель — понять протокол; для продакшена обычно берут проверенную библиотеку.",{"title":224,"text":225},"Backpressure вместо ожидания","Ограниченная очередь на клиента и отключение медленного клиента: одна медленная вкладка не задерживает комнату.",{"title":227,"text":228},"Одинаковый порядок для всех","Сохранение и рассылка выполняются под одним мьютексом комнаты; история в welcome читается под тем же мьютексом, поэтому нет ни пересечения, ни разрыва с живым потоком.",{"title":230,"text":231},"Лимиты на соединение","Токен-бакет для чата (2 сообщения\u002Fс, пачка 5) и общий лимит (200\u002Fс, пачка 400); флуд закрывает соединение кодом 1008.",[233,234],"Один процесс: хаб в памяти, горизонтального масштабирования нет. Доска не переживает перезапуск сервера, очистить её может любой участник.","Нет permessage-deflate, TLS и аутентификации; Autobahn Test Suite не запускался. Ники анонимные и не защищены.","Пример рукопожатия из RFC, 12 видов нарушений протокола с проверкой close-кода, фрагментация с ping посередине, отключение медленного клиента, а также нагрузочный тест: 360 соединений в 6 комнатах.","README проекта realtime-rooms: «Почему свой слой WebSocket», «Возможности», «Гарантии», «Ограничения»",{"slug":176,"kind":90,"go_lines":238,"go_test_lines":239,"tests":240,"endpoints":61,"html":241,"css":242,"js":243},1822,1313,48,103,184,517,{"slug":245,"title":246,"stack":247,"summary":248,"nodes":249,"edges":265,"flows":272,"decisions":279,"tradeoffs":286,"tests":289,"source":290,"stats":291},"booking-slots","Запись на приём",[13,14,18],"Онлайн-запись: свободные слоты по длительности услуги и рабочим часам, защита от двойной брони, админка.",[250,252,255,257,260,263],{"id":26,"label":251,"row":24,"col":24,"span":25,"kind":26},"Клиент\nуслуга · день · слот",{"id":253,"label":254,"row":24,"col":25,"span":25,"kind":26},"admin","Админка \u002Fadmin\nBearer-токен",{"id":125,"label":256,"row":25,"col":24,"span":34,"kind":30},"HTTP API\nслоты · брони · админ",{"id":258,"label":259,"row":34,"col":24,"span":25,"kind":30},"slots","Расчёт слотов\nчасы минус занятое",{"id":261,"label":262,"row":34,"col":25,"span":25,"kind":30},"book","Бронирование\nодна транзакция",{"id":32,"label":264,"row":45,"col":24,"span":34,"kind":35},"SQLite\nUNIQUE (date, start_min) WHERE active",[266,267,268,269,270,271],{"n":25,"from":26,"to":125},{"n":34,"from":253,"to":125},{"n":45,"from":125,"to":258},{"n":54,"from":125,"to":261},{"n":56,"from":258,"to":32},{"n":59,"from":261,"to":32},[273,274,275,276,277,278],"Клиент запрашивает 14 дней вперёд и слоты дня, затем отправляет POST \u002Fapi\u002Fbookings.","Владелец управляет записями, услугами, часами работы и закрытыми датами.","Слоты считаются по длительности услуги, часам дня недели и занятым интервалам, шаг сетки 30 минут.","Book проверяет свободность слота и вставляет запись в одной транзакции.","Расчёт читает часы работы, закрытые даты и активные записи.","Вставка защищена частичным UNIQUE-индексом; при конфликте клиент получает 409.",[280,283],{"title":281,"text":282},"Две линии защиты от двойной брони","Проверка свободности и вставка идут в одной транзакции, а частичный UNIQUE-индекс по (date, start_min) для активных записей ловит то, что проверка пропустила. Отменённая запись освобождает время.",{"title":284,"text":285},"Часовой пояс в данных","Пояс берётся из TZ_NAME, база tzdata встроена в бинарник: расписание не зависит от настроек машины.",[287,288],"Один специалист\u002Fресурс: все услуги делят одно расписание.","Закрытие даты не отменяет созданные записи; клиент не может отменить запись сам, уведомлений нет; админка за общим токеном.","Расчёт слотов и границы периода, пересечения, конкурентная бронь: 30 горутин на одно время — успешна ровно одна.","README проекта booking-slots: «Возможности», «Тесты», «Ограничения» и код internal\u002Fbooking",{"slug":245,"kind":90,"go_lines":292,"go_test_lines":293,"tests":294,"endpoints":170,"html":295,"css":296,"js":297},891,338,12,146,123,309,{"slug":299,"title":300,"stack":301,"summary":302,"nodes":303,"edges":319,"flows":326,"decisions":333,"tradeoffs":337,"tests":340,"source":341,"stats":342},"link-shortener","Короче: сократитель ссылок",[13,14,18],"Сократитель ссылок со статистикой переходов: алиасы, срок действия, редирект 302, дашборд под токеном.",[304,307,309,312,315,317],{"id":305,"label":306,"row":24,"col":24,"span":25,"kind":26},"visitor","Посетитель\nGET \u002F{code}",{"id":253,"label":308,"row":24,"col":25,"span":25,"kind":26},"Админ\nдашборд · токен",{"id":310,"label":311,"row":25,"col":24,"span":34,"kind":30},"http","net\u002Fhttp\nлимит создания ссылок",{"id":313,"label":314,"row":34,"col":24,"span":25,"kind":30},"redirect","Редирект 302\nи учёт клика",{"id":125,"label":316,"row":34,"col":25,"span":25,"kind":30},"API ссылок\nCRUD · статистика",{"id":32,"label":318,"row":45,"col":24,"span":34,"kind":35},"SQLite\nссылки · клики",[320,321,322,323,324,325],{"n":25,"from":305,"to":310},{"n":34,"from":253,"to":310},{"n":45,"from":310,"to":313},{"n":54,"from":310,"to":125},{"n":56,"from":313,"to":32},{"n":59,"from":125,"to":32},[327,328,329,330,331,332],"Посетитель открывает короткую ссылку.","Администратор создаёт ссылки и смотрит статистику под Bearer-токеном.","Публичный маршрут отвечает редиректом 302.","Создание ссылок ограничено по частоте с одного адреса.","Клик записывается с хэшем посетителя (суточная соль): по дням, браузерам, ОС и устройствам; IP-адреса не сохраняются.","Статистика строится по сохранённым кликам.",[334],{"title":335,"text":336},"Приватная статистика","Уникальные посетители считаются по хэшу с суточной солью, IP-адреса не сохраняются.",[338,339],"Учебный пример: один админ-токен, пользователей нет.","Демо-статистика сгенерирована и не отражает реальный трафик.","go vet и go test в CI; подробности покрытия в README проекта не перечислены.","README проекта link-shortener: «Возможности», «Ограничения»",{"slug":299,"kind":90,"go_lines":292,"go_test_lines":343,"tests":294,"endpoints":344,"html":345,"css":346,"js":347},328,11,119,162,317,{"slug":349,"title":350,"stack":351,"summary":352,"nodes":353,"edges":367,"flows":373,"decisions":379,"tradeoffs":383,"tests":386,"source":387,"stats":388},"kanban-tasks","Янтарь: канбан-доска",[13,14,18],"Многопользовательская канбан-доска: регистрация, доски, колонки, карточки с перетаскиванием.",[354,357,359,362,365],{"id":355,"label":356,"row":24,"col":24,"span":34,"kind":26},"browser","Браузер\nванильный JS · drag and drop",{"id":310,"label":358,"row":25,"col":24,"span":34,"kind":30},"HTTP API (JSON)\nContent-Type · same-origin",{"id":360,"label":361,"row":34,"col":24,"span":25,"kind":30},"auth","Аутентификация\nPBKDF2 · сессии",{"id":363,"label":364,"row":34,"col":25,"span":25,"kind":30},"boards","Доски и карточки\nданные по пользователю",{"id":32,"label":366,"row":45,"col":24,"span":34,"kind":35},"SQLite\nбез CGO",[368,369,370,371,372],{"n":25,"from":355,"to":310},{"n":34,"from":310,"to":360},{"n":45,"from":310,"to":363},{"n":54,"from":360,"to":32},{"n":56,"from":363,"to":32},[374,375,376,377,378],"Браузер ходит в JSON API; изменяющие запросы проверяются на Content-Type и same-origin.","Регистрация и вход по почте и паролю: PBKDF2-HMAC-SHA256, сессия в HttpOnly-cookie с SameSite=Lax.","Доски, колонки и карточки доступны только владельцу: данные пользователей изолированы.","Пользователи и сессии хранятся в SQLite.","Доски, колонки, карточки и их порядок хранятся в SQLite.",[380],{"title":381,"text":382},"Защита без внешних библиотек","PBKDF2 из стандартной библиотеки, HttpOnly-сессия, проверка Content-Type и same-origin для изменяющих запросов.",[384,385],"Учебный пример: нет сброса пароля, подтверждения почты и ограничения частоты входа.","Не выставлять в интернет без HTTPS и обратного прокси.","Тесты API (go vet и go test в CI).","README проекта kanban-tasks: «Возможности», «Ограничения»",{"slug":349,"kind":90,"go_lines":389,"go_test_lines":390,"tests":61,"endpoints":170,"html":391,"css":392,"js":343},871,272,102,156,{"slug":394,"title":395,"stack":396,"summary":405,"nodes":406,"edges":434,"flows":444,"decisions":454,"tradeoffs":467,"tests":470,"source":471,"stats":472},"api-gateway","Шлюз: API-gateway",[13,397,398,399,400,16,17,401,402,403,18,404],"stdlib only","Reverse proxy","Circuit breaker","Token bucket","slog","Vanilla JS","embed","GitHub Actions","API-шлюз на чистой stdlib: маршруты из JSON-конфига с горячей перезагрузкой, балансировка, health-checks, circuit breaker на узел, ретраи, rate limit. Главное — неизменяемое состояние и защита от open-proxy и SSRF.",[407,409,412,415,418,421,424,427,430,431],{"id":26,"label":408,"row":24,"col":24,"span":25,"kind":26},"Клиент\nHTTP на :8087",{"id":410,"label":411,"row":24,"col":25,"span":25,"kind":26},"ops","Оператор\nSIGHUP · PUT \u002Fapi\u002Fconfig",{"id":413,"label":414,"row":25,"col":24,"span":34,"kind":30},"entry","Вход шлюза\nrequest-id · host + prefix",{"id":416,"label":417,"row":34,"col":24,"span":25,"kind":30},"guard","Auth и rate limit\nsha256 ключа · token bucket",{"id":419,"label":420,"row":34,"col":25,"span":25,"kind":35},"state","Состояние\natomic.Pointer",{"id":422,"label":423,"row":45,"col":24,"span":25,"kind":30},"bal","Балансировщик\nround-robin · least-conn",{"id":425,"label":426,"row":45,"col":25,"span":25,"kind":30},"health","Health-checks\nGET \u002Fhealth на узел",{"id":428,"label":429,"row":54,"col":24,"span":25,"kind":30},"brk","Circuit breaker\nclosed · open · half-open",{"id":47,"label":48,"row":54,"col":25,"span":25,"kind":26},{"id":432,"label":433,"row":56,"col":24,"span":34,"kind":30},"up","Апстримы\nSSRF-guard · ретраи",[435,436,437,438,439,440,441,442,443],{"n":25,"from":26,"to":413},{"n":34,"from":410,"to":419,"side":62},{"n":45,"from":419,"to":413},{"n":54,"from":413,"to":416},{"n":56,"from":416,"to":422},{"n":59,"from":425,"to":422},{"n":61,"from":422,"to":428},{"n":136,"from":428,"to":432},{"n":138,"from":432,"to":47,"side":62},[445,446,447,448,449,450,451,452,453],"Запрос приходит на :8087, получает request-id, маршрут ищется по хосту и самому длинному префиксу; не найден — 404, шлюз не работает как open-proxy.","Оператор меняет конфиг через SIGHUP (только Unix) или PUT\u002FPOST в админке на :8088; конфиг проверяется целиком, ошибочный отклоняется, старый продолжает работать.","Новое состояние (маршруты, апстримы, ключи) подменяется одним atomic.Pointer: слушающий сокет не трогается, запрос в полёте доезжает на старой версии.","Ключ API хэшируется sha256 и сверяется с конфигом; rate limit по token bucket ведётся по IP (IPv6 по \u002F64) или по ключу, лишний запрос не доходит до апстрима.","Балансировщик выбирает узел round-robin или least-conn, но только среди узлов в состоянии healthy.","Отдельный health-loop на каждый узел опрашивает GET \u002Fhealth и по порогам помечает узел healthy или нет.","У каждого узла свой breaker (closed, open, half-open); ошибкой считаются сбой соединения, таймаут и любой 5xx кроме 501, обрыв запроса клиентом не считается.","Соединение идёт через guarded Dialer, который проверяет IP в момент соединения (SSRF, DNS-rebinding); при 502\u002F503\u002F504 или сбое сети GET, HEAD, OPTIONS, PUT и DELETE повторяются на другом узле.","Окно статистики и счётчики уходят в дашборд по SSE и на \u002Fmetrics в формате Prometheus; метки ограничены конфигом, путь и ключи в метки не попадают.",[455,458,461,464],{"title":456,"text":457},"Неизменяемое состояние под atomic.Pointer","Маршруты, апстримы и ключи собираются в новое состояние и подменяются одним указателем. Апстримы с неизменённым описанием переиспользуются целиком, поэтому health, breaker и счётчики не сбрасываются при перезагрузке.",{"title":459,"text":460},"Не open-proxy и защита от SSRF","Адрес назначения берётся только из конфига, Host, absolute-URI, CONNECT\u002FTRACE и пути с «..» не дают выйти за маршруты. IP, в который резолвится имя, проверяется ещё раз при соединении, а разрешение приватных сетей задаётся окружением, а не конфигом.",{"title":462,"text":463},"Breaker на каждый узел","Состояния closed, open и half-open ведутся отдельно у каждого узла: один больной узел выпадает из ротации, остальные продолжают принимать трафик.",{"title":465,"text":466},"Ретраи только идемпотентных запросов","POST не повторяется, чтобы не выполнить операцию дважды; тело буферизуется до 1 МиБ, а более крупные запросы идут без ретраев.",[468,469],"Rate limit и состояние breaker живут в памяти одного процесса: при нескольких репликах лимиты не общие, а за балансировщиком все клиенты выглядят одним IP, потому что X-Forwarded-For шлюз не доверяет.","Нет TLS на входе, WebSocket и Upgrade не поддерживаются (501); перцентили по гистограмме с погрешностью до 25%; админка с одним токеном; SIGHUP только на Unix.","Балансировщики, состояния breaker на фейковых часах, token bucket, SSRF-кейсы, ретраи (GET, PUT с телом; POST не ретраится), горячая перезагрузка с запросом «в полёте», защита от open-proxy, нагрузочный тест (4000 запросов, 40 клиентов); go test -race в CI.","README проекта api-gateway: «Архитектура», «Гарантии», «Что считается ошибкой», «Ограничения»",{"slug":394,"kind":90,"go_lines":473,"go_test_lines":474,"tests":240,"endpoints":344,"html":475,"css":476,"js":477},2334,1179,115,139,163,{"slug":479,"title":480,"stack":481,"summary":487,"nodes":488,"edges":511,"flows":520,"decisions":529,"tradeoffs":542,"tests":545,"source":546,"stats":547},"notes-search","Картотека: заметки с поиском",[13,14,482,483,484,15,485,486,402,403,18,404],"FTS5","bm25","modernc.org\u002Fsqlite (без CGO)","Markdown (свой безопасный рендер)","SVG force-layout","База Markdown-заметок на Go и SQLite: вики-ссылки с backlinks и графом, поиск FTS5 с bm25, версии с собственным diff. Главное — безопасный собственный рендер и поиск по стемам.",[489,492,495,497,500,503,505,508],{"id":490,"label":491,"row":24,"col":24,"span":25,"kind":26},"editor","Редактор\nавтосохранение · rev",{"id":493,"label":494,"row":24,"col":25,"span":25,"kind":26},"ui","Поиск и граф\nCtrl+K · SVG-раскладка",{"id":125,"label":496,"row":25,"col":24,"span":34,"kind":30},"REST API \u002Fapi\nBearer-токен",{"id":498,"label":499,"row":34,"col":24,"span":25,"kind":30},"md","Markdown-рендер\nбелый список · [[ссылки]]",{"id":501,"label":502,"row":34,"col":25,"span":25,"kind":30},"text","Стеммер и запрос\nстемы · префиксы",{"id":32,"label":504,"row":45,"col":24,"span":34,"kind":35},"SQLite\nnotes · links · versions · notes_fts",{"id":506,"label":507,"row":54,"col":24,"span":25,"kind":30},"ver","Версии и diff\nLCS · окно слияния",{"id":509,"label":510,"row":54,"col":25,"span":25,"kind":26},"io","Экспорт\nzip из .md",[512,513,514,515,516,517,518,519],{"n":25,"from":490,"to":125},{"n":34,"from":493,"to":125},{"n":45,"from":125,"to":498},{"n":54,"from":125,"to":501},{"n":56,"from":498,"to":32},{"n":59,"from":501,"to":32},{"n":61,"from":32,"to":506},{"n":136,"from":32,"to":509},[521,522,523,524,525,526,527,528],"Редактор шлёт PUT с rev; список слева обновляется сразу при вводе и откатывается, если сервер вернул ошибку.","Поиск, Ctrl+K и граф запрашивают \u002Fapi\u002Fsearch и \u002Fapi\u002Fgraph под Bearer-токеном; граф раскладывается в браузере (Фрухтерман — Рейнгольд, SVG).","Текст заметки идёт в собственный рендер: исходник экранируется целиком, теги создаёт только рендер из закрытого набора, схемы ссылок из белого списка.","Название, тело и теги проходят токенизацию и стеммер (около 70 окончаний, ё → е); запрос превращается в термы «стем»* через AND, и ввод пользователя не попадает в синтаксис FTS.","Из текста выделяются [[ссылки]] и пишутся в links по title_key; backlinks — это соединение по dst_key, поэтому ссылка на ещё не созданную заметку оживает сама.","В notes_fts пишутся стемы, ранжирование bm25(notes_fts, 8, 1, 4) (название, тело, теги), сниппет строит Go-код; сохранение — одна транзакция: заметка, теги, ссылки, FTS и версия.","Правки автосохранения в пределах 5 минут вливаются в последнюю версию, Ctrl+S создаёт новую; diff построчный на LCS; при устаревшем rev сервер отвечает 409 с актуальной заметкой.","Экспорт отдаёт zip из .md с front matter; импорт папки, файлов или zip идёт через API, пути из архива никуда не пишутся, поэтому zip-slip невозможен.",[530,533,536,539],{"title":531,"text":532},"Свой Markdown-рендер вместо goldmark","Безопасность — свойство конструкции, а не очистка после: исходник экранируется, теги из закрытого набора. Цена — меньше возможностей, чем у CommonMark (нет таблиц и картинок); зато зависимости остаются на уровне stdlib и SQLite.",{"title":534,"text":535},"FTS5 над стемами, сниппет в Go","В индекс пишутся стемы, а не исходный текст, поэтому штатный snippet() FTS5 видел бы только стемы. Совпадения подсвечивает Go-код, он же экранирует HTML.",{"title":537,"text":538},"title_key вместо NOCASE","Уникальность и вики-ссылки строятся на названии в нижнем регистре с нормализованными пробелами, потому что NOCASE в SQLite для кириллицы не работает.",{"title":540,"text":541},"rev для конфликтов, ver для истории","rev считает любые изменения и защищает от затирания чужой правки, ver нумерует версии. «Перезаписать моей» фиксирует версию, так что чужая правка остаётся в истории.",[543,544],"Морфология упрощённая: беглые гласные, чередования и неправильные формы не работают, у коротких слов возможны ложные совпадения. Переименование заметки не переписывает [[ссылки]] в других заметках.","Один общий токен, одно соединение с БД, конфликты разрешаются целиком; граф рассчитан на сотни заметок (раскладка O(n²) на шаг). В Markdown нет таблиц, картинок и сносок.","Ранжирование bm25 и вес названия, морфология, мусорные запросы, backlinks и граф, версии и слияние правок, конфликт rev, XSS (около 30 нагрузок), diff, экспорт → импорт, безопасность zip; go test -race в CI.","README проекта notes-search: «Архитектура», «Схема БД», «Поиск», «Решения», «Ограничения»",{"slug":479,"kind":90,"go_lines":548,"go_test_lines":389,"tests":549,"endpoints":94,"html":550,"css":551,"js":243},2027,32,174,192,{"slug":553,"title":554,"stack":555,"summary":564,"nodes":565,"edges":586,"flows":595,"decisions":604,"tradeoffs":617,"tests":620,"source":621,"stats":622},"raft-kv","Плот: KV на Raft",[13,556,557,558,397,559,16,560,561,562,563,402,18,404],"Raft","Distributed systems","Consensus","HTTP\u002FJSON","WAL + fsync","Snapshots","Linearizability","Deterministic simulation testing","Учебная реализация Raft на чистой stdlib: три узла, выборы, репликация, коммит по кворуму, WAL с fsync, снапшоты, read-index. Главное — ядро без времени и сети, которое один и тот же код гоняет и в рантайме, и в детерминированном симуляторе.",[566,568,570,573,575,578,580,583],{"id":26,"label":567,"row":24,"col":24,"span":25,"kind":26},"Клиент\nPUT · GET · DELETE",{"id":47,"label":569,"row":24,"col":25,"span":25,"kind":26},"Дашборд :8098\nSSE · аварии (демо)",{"id":571,"label":572,"row":25,"col":24,"span":34,"kind":30},"node","Рантайм узла\nHTTP\u002FJSON · тик 25 мс · 307",{"id":30,"label":574,"row":34,"col":24,"span":25,"kind":30},"raft.Node\nTick() · Step(msg)",{"id":576,"label":577,"row":34,"col":25,"span":25,"kind":26},"peers","Два других узла\nvote · append · snapshot",{"id":35,"label":579,"row":45,"col":24,"span":25,"kind":35},"Хранилище\nWAL + fsync · state.json",{"id":581,"label":582,"row":45,"col":25,"span":25,"kind":30},"kv","KV-автомат\nput · del · снапшот",{"id":584,"label":585,"row":54,"col":24,"span":34,"kind":30},"sim","Симулятор\nвиртуальное время · seed",[587,588,589,590,591,592,593,594],{"n":25,"from":26,"to":571},{"n":34,"from":47,"to":571},{"n":45,"from":571,"to":30},{"n":54,"from":30,"to":576},{"n":56,"from":30,"to":581},{"n":59,"from":30,"to":35,"side":57},{"n":61,"from":581,"to":35},{"n":136,"from":584,"to":35},[596,597,598,599,600,601,602,603],"Клиент шлёт PUT, GET или DELETE на любой из узлов 8095–8097; не лидер отвечает 307 с Location на лидера (и заголовком X-Raft-Leader), а если лидер неизвестен, то 503 с Retry-After.","Дашборд раз в 200 мс получает по SSE роль, term, commit\u002Fapplied и хвост лога; кнопки «остановить узел», «оборвать сеть» и «убить лидера» работают только при SEED_DEMO=1 и идут по настоящему HTTP.","Рантайм тикает ядро каждые 25 мс (Tick) и передаёт ему входящие сообщения (Step); исходящие сообщения возвращаются вызывающему, сам raft.Node не знает ни о времени, ни о сети.","Выборы (vote), репликация (append) и snapshot идут HTTP\u002FJSON-сообщениями, ответы это отдельные сообщения, поэтому сеть может терять и переупорядочивать пакеты; индекс коммитится, когда его хранит большинство и запись относится к текущему term.","Закоммиченные записи применяются к автомату состояний (словарь, команды put и del); ответ PUT уходит после коммита на кворуме, а линеаризуемый GET ждёт applied >= readIndex.","Ответ уходит только после fsync: term и votedFor пишутся в state.json (временный файл, fsync, rename), записи лога в WAL с CRC32; битый хвост после крэша обрезается при загрузке.","После SNAPSHOT_EVERY применённых записей (по умолчанию 200) снапшот словаря пишется новым поколением файлов: новый WAL, затем атомарная публикация snapshot.bin, затем удаление старого WAL.","Тот же raft.Node работает в симуляторе: время виртуальное, сеть управляемая (задержки, потери, обрывы), крэш и рестарт узла идут из его хранилища; один seed воспроизводит запуск побайтно, а инварианты и линеаризуемость проверяются после каждого шага.",[605,608,611,614],{"title":606,"text":607},"Ядро без горутин и часов","raft.Node получает время только через Tick() и сообщения через Step(), а исходящие возвращает вызывающему. Поэтому один и тот же код работает в рантайме (тик 25 мс, HTTP) и в симуляторе, а сбой воспроизводится по seed.",{"title":609,"text":610},"Коммит только записей текущего term","Индекс коммитится, когда его хранит большинство и запись относится к текущему term (раздел 5.4.2 статьи), поэтому новый лидер сразу кладёт в лог no-op. Целенаправленного теста на фигуру 8 нет, и это записано в ограничениях.",{"title":612,"text":613},"Read-index вместо лизов","Лидер запоминает commitIndex, рассылает heartbeat, ждёт подтверждения большинства и только потом отвечает на GET. Корректность не зависит от часов, а отрезанный лидер ответить не может.",{"title":615,"text":616},"Снапшот «поколениями»","Новый WAL, затем атомарная публикация snapshot.bin (точка коммита), затем удаление старого WAL. Крэш на любом шаге оставляет целое старое или целое новое состояние.",[618,619],"Состав фиксирован (ровно три узла), нет динамического членства, pre-vote и check-quorum, клиентских сессий и exactly-once: после 504 исход неизвестен. Снапшот идёт одним сообщением и целиком в памяти, каждый PUT это отдельный fsync, TLS и аутентификации нет.","Проверка собственная: симулятор с идеальным диском и проверка линеаризуемости на малых историях, это не Jepsen. Одна ручная мутация, коммит записей прошлых term, тестами не ловится.","Выбор одного лидера на term (40 seed-ов с потерями, крэшами и обрывами), переизбрание после падения лидера, репликация, отсутствие коммита без кворума, откат хвоста отрезанного лидера, рестарт с диска, голос не отдаётся дважды после рестарта, снапшоты и догон, линеаризуемость под хаосом (30 seed-ов), детерминизм трассы; HTTP-тесты узла и сам чекер линеаризуемости; go test -race в CI.","README проекта raft-kv: «Архитектура», «Как устроены части», «Инварианты», «Тесты», «Чего здесь нет»",{"slug":553,"kind":90,"go_lines":623,"go_test_lines":624,"tests":625,"endpoints":626,"html":627,"css":550,"js":628},2646,895,27,13,91,213,{"slug":630,"title":631,"stack":632,"summary":641,"nodes":642,"edges":663,"flows":671,"decisions":679,"tradeoffs":692,"tests":695,"source":696,"stats":697},"auth-service","Ключница: сервис аутентификации",[13,633,14,484,634,635,636,637,638,639,640,402,403,18,404],"stdlib crypto","JWT EdDSA\u002FEd25519","JWKS","PBKDF2","TOTP","RBAC","REST","OpenAPI 3","Сервис аутентификации на stdlib crypto и SQLite: EdDSA-JWT с ротацией ключей и JWKS, refresh-токены с ротацией и reuse-detection, TOTP, RBAC, lockout, аудит. Главное — потоки токенов и честная модель угроз с тем, что остаётся незакрытым.",[643,645,648,650,652,655,657,660],{"id":355,"label":644,"row":24,"col":24,"span":25,"kind":26},"Браузер и клиенты\nBearer · cookie rt",{"id":646,"label":647,"row":24,"col":25,"span":25,"kind":26},"consumer","Сторонний сервис\nпроверяет по JWKS",{"id":310,"label":649,"row":25,"col":24,"span":34,"kind":30},"HTTP-слой\nCSP · Origin · CSRF · лимит IP",{"id":30,"label":651,"row":34,"col":24,"span":25,"kind":30},"Ядро auth\nPBKDF2 · TOTP · RBAC",{"id":653,"label":654,"row":34,"col":25,"span":25,"kind":30},"jwt","JWT EdDSA\nkid · кольцо ключей",{"id":32,"label":656,"row":45,"col":24,"span":25,"kind":35},"SQLite\nсессии · токены · ключи",{"id":658,"label":659,"row":45,"col":25,"span":25,"kind":30},"jwks","JWKS\n\u002F.well-known\u002Fjwks.json",{"id":661,"label":662,"row":54,"col":24,"span":34,"kind":35},"audit","Журнал аудита\nтолько дозапись",[664,665,666,667,668,669,670],{"n":25,"from":355,"to":310},{"n":34,"from":310,"to":30},{"n":45,"from":30,"to":653},{"n":54,"from":30,"to":32},{"n":56,"from":653,"to":658},{"n":59,"from":658,"to":646,"side":62},{"n":61,"from":32,"to":661},[672,673,674,675,676,677,678],"Вход: логин и пароль, при включённой 2FA второй шаг с кодом TOTP или резервным кодом; в ответе access JWT на 10 минут, а refresh приходит в HttpOnly-cookie SameSite=Strict вместе с CSRF-токеном.","Middleware проверяет Origin, CSRF и лимит запросов с IP, затем подпись и срок access-токена, потом по БД что сессия жива, а права берутся из БД на каждом запросе, поэтому отзыв и смена роли действуют сразу.","Токены подписывает кольцо ключей Ed25519: принимается только EdDSA, kid берётся из белого списка; POST \u002Fapi\u002Fadmin\u002Fkeys\u002Frotate выпускает новый ключ, а старый остаётся для проверки ещё access_ttl + 1 минуту.","В БД лежат хеши пароля (PBKDF2, 600 000 итераций), refresh-токенов (SHA-256) и резервных кодов; refresh гасит старый токен и выдаёт новый, а повторное предъявление использованного отзывает всю семью и пишет token.reuse_detected.","Публичные ключи отдаются на \u002F.well-known\u002Fjwks.json (RFC 7517\u002F8037) вместе с kid.","Сторонний сервис берёт kid из заголовка JWT, достаёт ключ из JWKS (кеш на минуту) и проверяет подпись, iss, aud, typ и exp; об отзыве сессии он узнает не раньше конца жизни access-токена.","Вход, неудачи, блокировки, 2FA, сброс пароля, отзывы сессий, действия админа, ротация ключей и отказы по правам пишутся в таблицу audit; триггеры БД запрещают UPDATE и DELETE, пароли и токены в журнал не попадают.",[680,683,686,689],{"title":681,"text":682},"Проверка сессии по БД на каждом запросе","Access-токен сверяется с БД (сессия не отозвана, пользователь не отключён, права актуальны). Это стоит одного запроса к SQLite и взамен даёт немедленные «выйти на всех устройствах», блокировку и смену роли.",{"title":684,"text":685},"Семья refresh-токенов и reuse-detection","Каждое обновление гасит старый токен и выдаёт новый. Если использованный токен предъявлен снова, считается, что его украли: отзывается вся сессия, мёртвыми становятся и украденный, и легитимный токен.",{"title":687,"text":688},"Только EdDSA и свой разбор JWT на stdlib","Алгоритмы none и HS256 отклоняются до проверки подписи (защита от alg-confusion), kid из белого списка, длина токена ограничена. Тесты включают HS256 с публичным ключом в роли секрета.",{"title":690,"text":691},"Одинаковый ответ и время для неизвестного логина","Для несуществующего пользователя считается фиктивный хеш, ответ и время совпадают с неверным паролем, а forgot всегда отвечает 202. Исключение осталось: регистрация сообщает, что логин или почта заняты.",[693,694],"Секрет TOTP и приватные ключи подписи лежат в БД открытым текстом: с дампом БД можно подделать токены и 2FA. Пароли хешируются PBKDF2, а не argon2id, резервные коды быстрым SHA-256.","Не прошёл независимый аудит, нет OAuth\u002FOIDC, WebAuthn и подтверждения почты; письмо сброса это заглушка в логе. Лимитер и блокировки живут в памяти и в SQLite одного процесса, а access-токен не отозвать у сторонних потребителей до exp.","JWT (подделка, exp\u002Fnbf\u002Fiss\u002Faud\u002Ftyp, alg=none, HS256 с публичным ключом, ротация), TOTP по векторам RFC 6238 и RFC 4226, ротация refresh и reuse-detection, блокировка с экспоненциальной задержкой, 2FA и резервные коды, RBAC, сброс пароля, аудит (запрет UPDATE\u002FDELETE), cookie-флаги, CSRF, CORS, Origin, rate limit; JWKS проверяется независимым кодом; go test -race в CI.","README проекта auth-service: «Возможности», «Потоки токенов», «Модель угроз», «Схема БД», «Ограничения»",{"slug":630,"kind":90,"go_lines":698,"go_test_lines":699,"tests":700,"endpoints":701,"html":702,"css":703,"js":704},2826,1393,50,35,23,166,500,{"slug":706,"title":707,"stack":708,"summary":717,"nodes":718,"edges":741,"flows":753,"decisions":764,"tradeoffs":777,"tests":780,"source":781,"stats":782},"finance-tracker","Книга учёта: личные финансы",[13,14,484,15,709,710,711,712,713,714,715,18,404,636,716],"Nuxt 4","Vue 3","TypeScript","Pinia","SVG-графики","vitest","ESLint","CSV import\u002Fexport","Учёт личных финансов: Go-бинарник с SQLite и встроенным фронтендом на Nuxt 4 (SPA). Главное — деньги целыми минорными единицами, мультивалютность с ручными курсами, расписание и импорт CSV, изоляция пользователей.",[719,722,725,727,730,733,736,739],{"id":720,"label":721,"row":24,"col":24,"span":25,"kind":26},"spa","Nuxt 4 SPA\nPinia · SVG-графики",{"id":723,"label":724,"row":24,"col":25,"span":25,"kind":26},"csv","CSV из банка\nимпорт · экспорт",{"id":125,"label":726,"row":25,"col":24,"span":34,"kind":30},"HTTP API на Go\nсессия в cookie · Origin · user_id",{"id":728,"label":729,"row":34,"col":24,"span":25,"kind":30},"imp","Импорт CSV\nпревью · пробный прогон",{"id":731,"label":732,"row":34,"col":25,"span":25,"kind":30},"rec","Расписание\ncron-правило · нагон",{"id":734,"label":735,"row":45,"col":24,"span":25,"kind":30},"ledger","Счета и операции\nминорные единицы · big.Int",{"id":737,"label":738,"row":45,"col":25,"span":25,"kind":30},"rep","Бюджеты и отчёты\nмесяц · тренды",{"id":32,"label":740,"row":54,"col":24,"span":34,"kind":35},"SQLite\nmodernc · без CGO",[742,743,744,745,746,747,748,749,750,751],{"n":25,"from":720,"to":125},{"n":34,"from":723,"to":125},{"n":45,"from":125,"to":734,"side":57},{"n":54,"from":125,"to":728},{"n":56,"from":125,"to":731},{"n":59,"from":728,"to":734},{"n":61,"from":731,"to":734},{"n":136,"from":734,"to":737},{"n":138,"from":734,"to":32},{"n":752,"from":737,"to":32},10,[754,755,756,757,758,759,760,761,762,763],"Браузер работает с API через JSON; сессия живёт в httpOnly-cookie (SameSite=Lax), в базе хранится только SHA-256 токена, а у запросов на запись проверяется Origin.","Файл CSV и экспорт (CSV с защитой от formula-injection и полный JSON) проходят через тот же API, отдельных каналов нет.","Каждый запрос привязан к user_id из сессии; обычный CRUD счетов, категорий, курсов и операций идёт прямо в слой счетов и операций, с фильтрами q, kind, account_id, category_id, tag, from, to.","POST \u002Fapi\u002Fimport\u002Fpreview определяет разделитель и кодировку и показывает сопоставление колонок; \u002Fcommit пишет строки, пропускает дубли и возвращает отчёт по ошибочным строкам (не более 20 000 строк за раз).","Cron-подобное правило из пяти полей (с точностью до дня) исполняется при открытии приложения и по кнопке \u002Fapi\u002Frecurring\u002Frun; пропущенные даты нагоняются, фонового планировщика нет.","Импортированные строки становятся обычными транзакциями: дубли пропускаются, недостающие категории создаются, переводы не импортируются.","Регулярные платежи тоже становятся обычными транзакциями и хранят recurring_id; last_date правила двигается вперёд.","Отчёты читают операции целиком в память и считают в базовой валюте по текущим курсам; наклон тренда считается МНК в целых числах.","Суммы пишутся целыми минорными единицами (amount_minor > 0), курс хранится целым ×10⁶, пересчёт идёт через big.Int; баланс счёта не хранится, а считается запросом.","Лимиты бюджета (limit_minor) и пороги предупреждения (warn_pct) лежат в таблице budgets и сравниваются с расходами месяца; отчёты читают те же таблицы и ничего не пишут.",[765,768,771,774],{"title":766,"text":767},"Деньги только целыми минорными единицами","В базе, API и браузере суммы это целые числа копеек или центов, без float. Пересчёт валют идёт через big.Int с округлением «от нуля», а округление и разбор сумм покрыты тестами.",{"title":769,"text":770},"Свои SVG-графики вместо библиотеки","Нужны три простых типа графиков, а библиотека добавила бы десятки КБ JS. Свои (около 250 строк) дают контроль над доступностью (role=\"img\", текстовая таблица), темами через CSS-переменные и строгим CSP.",{"title":772,"text":773},"SPA без SSR","Nuxt собирается как nuxt generate с ssr:false: данные сессии нельзя пререндерить. Go отдаёт встроенную web\u002Fdist и 200.html для неизвестных путей, а CSP получает хэши встроенных скриптов вместо unsafe-inline для скриптов.",{"title":775,"text":776},"Баланс считается, а не хранится","Баланс счёта = начальный остаток + доходы − расходы − исходящие переводы + входящие переводы. Хранимого поля нет, поэтому оно не может разойтись с операциями.",[778,779],"Курсы ручные и текущие, истории курсов нет: прошлое пересчитывается по сегодняшним курсам. Базовую валюту нельзя сменить, бюджет одинаков каждый месяц, платежи создаются только при открытии приложения.","Нет смены и восстановления пароля, SQLite и один процесс, отчёты читают операции целиком в память. В style-src остался unsafe-inline (стили Vue), SSR отключён.","Деньги (разбор, формат, округление, пересчёт, round-trip), бюджеты, cron-правила и нагон пропущенных дат, импорт CSV, изоляция пользователей, API, отчёты, демо-данные, TestRealDist (встроенная сборка отдаётся, CSP без unsafe-inline для скриптов); во фронтенде vitest, ESLint и typecheck; go test -race в CI.","README проекта finance-tracker: «Возможности», «Архитектура», «Схема БД», «API», «Ограничения»",{"slug":706,"kind":90,"go_lines":783,"go_test_lines":784,"tests":785,"endpoints":786,"html":787,"css":788,"js":789},3741,1371,30,45,24,1148,72,{"slug":791,"title":792,"stack":793,"summary":797,"nodes":798,"edges":820,"flows":829,"decisions":838,"tradeoffs":851,"tests":854,"source":855,"stats":856},"pixel-wall","Пиксельная стена: общий холст",[13,397,179,794,402,403,795,796,18,404],"Canvas 2D","Binary protocol","Snapshot + journal","Совместный холст 256×256 на WebSocket: свой RFC 6455 на stdlib, бинарный протокол, снимок и дельты, снапшот плюс журнал. Главное — согласованность дельт и восстановление после падения, при том что каждый пиксель стоит 4 бита.",[799,801,803,806,809,812,815,818],{"id":355,"label":800,"row":24,"col":24,"span":25,"kind":26},"Браузер\nCanvas 2D · зум · таймлапс",{"id":253,"label":802,"row":24,"col":25,"span":25,"kind":26},"Админ\nBearer ADMIN_TOKEN",{"id":804,"label":805,"row":25,"col":24,"span":34,"kind":30},"srv","HTTP + WebSocket\nOrigin · лимиты · хаб 50 мс",{"id":807,"label":808,"row":34,"col":24,"span":25,"kind":30},"wall","Ядро wall\nодин мьютекс · seq",{"id":810,"label":811,"row":34,"col":25,"span":25,"kind":30},"cool","Кулдаун и баны\nсессия · IP · rate limit",{"id":813,"label":814,"row":45,"col":24,"span":25,"kind":30},"canvas","Холст\n4 бита на пиксель",{"id":816,"label":817,"row":45,"col":25,"span":25,"kind":30},"hist","История в памяти\nстарый и новый цвет",{"id":35,"label":819,"row":54,"col":24,"span":34,"kind":35},"Диск\ncanvas.snap + journal.log",[821,822,823,824,825,826,827,828],{"n":25,"from":355,"to":804},{"n":34,"from":253,"to":804},{"n":45,"from":804,"to":807},{"n":54,"from":807,"to":810},{"n":56,"from":807,"to":813},{"n":59,"from":807,"to":816},{"n":61,"from":813,"to":35},{"n":136,"from":816,"to":35},[830,831,832,833,834,835,836,837],"Браузер открывает WS \u002Fws?sid=…, получает сжатый снимок (0x01: seq, размер, deflate-raw холста) и дальше только дельты (0x02: firstSeq и до 2000 записей); пиксель уходит в 6 байтах (0x10: x, y, цвет).","Админ-запросы идут с заголовком Authorization: Bearer; токен сравнивается за постоянное время, неверные попытки лимитируются по IP, а без ADMIN_TOKEN админ-API выключено (403).","Сервер проверяет Origin, размеры сообщений (WS до 64 байт, тело до 4 КиБ) и подписанную сессию, затем передаёт установку в wall; хаб рассылает накопленные дельты раз в 50 мс, а медленный клиент отключается и перечитывает снимок.","Перед записью wall спрашивает кулдаун: строго один пиксель за COOLDOWN_SEC на сессию и не больше IP_BURST за то же скользящее окно на IP; бан сессии или IP отвечает 403, лишнее 429.","Единый мьютекс упорядочивает запись в холст, журнал и очередь дельт; пиксель занимает 4 бита (чётный индекс в старшей тетраде байта), 256×256 = 32 КиБ.","Каждая установка добавляется в историю (до MAX_HISTORY записей) со старым и новым цветом; клиент откатывает таймлапс назад от текущего состояния, базовый снимок не нужен.","Снапшот canvas.snap пишется раз в SNAPSHOT_SEC во временный файл с crc32, fsync и rename; восстановление берёт снапшот, затем записи журнала с большим seq и обрезает недописанный хвост.","journal.log (10 байт на запись: время, x, y, старый и новый цвет, флаги очистки и бота) дописывается при каждом пикселе, fsync раз в 30 секунд; при превышении MAX_HISTORY на 25% журнал переписывается с хвостом.",[839,842,845,848],{"title":840,"text":841},"WebSocket вместо SSE и POST","Нужен двунаправленный бинарный канал: пиксель в 6 байтах и сразу ok или err, дельты бинарными. SSE это текст (base64 раздувает трафик на треть) и отдельные POST на каждое действие. Запасной путь без сокета есть: POST \u002Fapi\u002Fpixel.",{"title":843,"text":844},"Снимок со seq и дельты с firstSeq","Клиент регистрируется в хабе и под той же блокировкой получает снимок со seq. Дельта несёт firstSeq: клиент пропускает записи с seq не больше своего, а при пропуске переподключается.",{"title":846,"text":847},"В журнале хранится старый цвет","Запись «старый\u003C\u003C4 | новый» позволяет клиенту откатывать историю от текущего состояния, поэтому базовый снимок для таймлапса не нужен, а усечение журнала лишь сокращает глубину истории.",{"title":849,"text":850},"Снапшот и журнал вместо одного файла","Снапшот через временный файл и rename гарантирует целостность, журнал даёт последние изменения; битый снапшот (crc) восстанавливается из журнала, а не стыкующийся журнал начинается заново.",[852,853],"Всё в одном процессе и на одном диске; кулдауны, сессии и rate limit в памяти и сбрасываются при рестарте. Сессия это идентификатор в localStorage, поэтому защита от простых ботов, а не от атаки с множества IP.","Журнал без fsync на каждый пиксель (fsync раз в 30 с), журнал целиком в памяти, расширений WebSocket нет, клиенту нужен DecompressionStream. Браузерных тестов в репозитории нет.","Кулдаун (сессия, IP, скользящее окно, переполнение таблицы), упаковка и граничные координаты, кодеки и битые сообщения, хранилище (crc, оборванный хвост), восстановление без снапшота и из снапшота с журналом, компактация, откат истории, непрерывность дельт, API (лимиты, Origin, 401\u002F403\u002F429), WebSocket с двумя клиентами и флуд, права админа, бан, нагрузка на 300 клиентов; go test -race в CI.","README проекта pixel-wall: «Возможности», «Архитектура», «Бинарный протокол», «Формат хранения», «Ограничения»",{"slug":791,"kind":90,"go_lines":857,"go_test_lines":858,"tests":859,"endpoints":860,"html":861,"css":551,"js":862},2715,1910,77,16,106,625,{"slug":864,"title":865,"stack":866,"summary":873,"nodes":874,"edges":902,"flows":911,"decisions":920,"tradeoffs":933,"tests":936,"source":937,"stats":938},"gitpulse","GitPulse: аналитика git-репозиториев",[13,397,867,868,869,870,871,872,404,18],"CLI","git","html\u002Ftemplate","SVG","ANSI","os\u002Fexec","CLI для анализа git-репозиториев: разбор потока git log, агрегаторы метрик и три вывода из одной структуры — терминал, самодостаточный HTML и локальный сервер. Главное — не писать свой разбор .git, а доверить историю и переименования самому git.",[875,878,881,884,887,890,893,896,899],{"id":876,"label":877,"row":24,"col":24,"span":25,"kind":26},"cli","Командная строка\nчетыре команды и флаги",{"id":879,"label":880,"row":24,"col":25,"span":25,"kind":35},"repo","Репозиторий\n.git · .mailmap",{"id":882,"label":883,"row":25,"col":24,"span":34,"kind":30},"gitlog","gitlog (os\u002Fexec)\ngit log -z --numstat -M · потоковый разбор",{"id":885,"label":886,"row":34,"col":24,"span":34,"kind":30},"agg","analyze.Aggregator\nкоммит за коммитом · переименования · фильтр lock\u002Fvendor",{"id":888,"label":889,"row":45,"col":24,"span":34,"kind":30},"res","analyze.Result\nединая структура · JSON",{"id":891,"label":892,"row":54,"col":24,"span":25,"kind":30},"term","term\nANSI-таблицы · спарклайны",{"id":894,"label":895,"row":54,"col":25,"span":25,"kind":30},"report","report\nhtml\u002Ftemplate · inline SVG",{"id":897,"label":898,"row":56,"col":24,"span":25,"kind":26},"file","report.html\nодин файл или stdout",{"id":900,"label":901,"row":56,"col":25,"span":25,"kind":26},"serve","serve\n127.0.0.1 · Host-guard",[903,904,905,906,907,908,909,910],{"n":25,"from":876,"to":882},{"n":34,"from":879,"to":882},{"n":45,"from":882,"to":885},{"n":54,"from":885,"to":888},{"n":56,"from":888,"to":891},{"n":59,"from":888,"to":894},{"n":61,"from":894,"to":897},{"n":136,"from":894,"to":900},[912,913,914,915,916,917,918,919],"Команда разбирает флаги (-rev, -since, -until, -max-commits, -top, -depth, -json, -color) и проверяет репозиторий и ревизию; нет git, не репозиторий или неизвестная ревизия дают код возврата 3, неверные аргументы 2.","git читает историю и .mailmap сам; поэтому имена авторов приходят уже с учётом %aN\u002F%aE, а переименования находит git -M, без собственного разбора packfile.","gitlog запускает git log -z --numstat -M через os\u002Fexec и разбирает поток по одной записи: -z исключает проблемы с кавычками и юникодом в путях, в памяти живёт один коммит; на терминале в stderr рисуется прогресс.","Aggregator принимает коммиты от новых к старым и копит метрики; при встрече old => new запоминает переименование (с разрешением цепочек), поэтому более ранние правки old попадают на актуальное имя; lock-файлы, vendor и min-файлы пропускаются, слияния считаются отдельно.","Результат — одна структура analyze.Result (JSON при -json); term рисует из неё таблицы с цветами, столбцы и спарклайны, NO_COLOR и запись в канал или файл отключают цвет, на Windows включаются VT и UTF-8.","report через html\u002Ftemplate собирает HTML с inline SVG-графиками, CSS и небольшим JS переключателя темы; внешних запросов нет, имена и пути экранируются, тема следует за prefers-color-scheme.","report пишет один файл (-o файл, по умолчанию gitpulse-report.html) или в stdout с -o -.","serve принимает только loopback-адрес в -addr, отвергает запросы с чужим заголовком Host (защита от DNS-rebinding), ставит CSP и no-store, держит результат в кеше (по умолчанию 30 с, \u002F?refresh=1 сбрасывает) и отдаёт \u002F, \u002Fapi\u002Fstats и \u002Fhealthz.",[921,924,927,930],{"title":922,"text":923},"git через os\u002Fexec, а не разбор .git","Собственный разбор объектов и packfile с дельта-цепочками потребовал бы ещё commit-graph, .mailmap, замены ссылок, shallow- и partial-клонов и обнаружения переименований: тысячи строк с риском ошибиться в метриках. git log --numstat -M решает это надёжно, цена — зависимость от установленного git, которая проверяется явно.",{"title":925,"text":926},"Поток с -z вместо загрузки всего лога","Лог читается по одной записи, память растёт от числа различных файлов и авторов, а не коммитов; -max-commits помечает отчёт как усечённый.",{"title":928,"text":929},"Одна структура Result для всех выводов","Терминал, HTML, JSON и сервер показывают числа из одной analyze.Result, поэтому расходиться им не из чего, а тесты сверяют метрики в одном месте.",{"title":931,"text":932},"Самодостаточный HTML и loopback-сервер","В отчёте есть имена авторов, поэтому он одним файлом без внешних запросов и шрифтов, а serve слушает только 127.0.0.1 и проверяет Host.",[934,935],"Нужен установленный git; время по UTC автора; слияния не анализируются; копирование файлов не отслеживается, переименования видны лишь там, где их находит git -M.","Рост языков — оценка «добавлено минус удалено», а bus factor и риск — эвристики по числу строк; память растёт с числом файлов и авторов, в -json есть почты авторов, в HTML нет.","Синтетические репозитории через git и метрики на заранее посчитанных данных: слияния, переименования (в том числе правка по новому пути), бинарные файлы, кириллица в путях и именах, lock-файлы, пустой репозиторий, один коммит, лимит коммитов, диапазоны, плохие ревизии; разбор потока (таб в пути, битые записи), golden-тест структуры HTML, самодостаточность и экранирование отчёта, сервер (кеш, Host-guard, методы), коды возврата, JSON CLI, NO_COLOR; go test -race в CI.","README проекта gitpulse: «Команды», «Что считается», «Архитектура», «HTML-отчёт», «Тесты», «Ограничения»; код internal\u002Fgitlog, internal\u002Fanalyze, internal\u002Freport, internal\u002Fterm, internal\u002Fserve",{"slug":864,"kind":90,"go_lines":939,"go_test_lines":940,"tests":549,"endpoints":45,"html":24,"css":24,"js":24},2133,735,1792697892476]