хранилище переехало с PocketBase на SQLite со своим каталогом файлов

- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
av
2026-08-23 08:06:04 +03:00
parent 1edf8cb225
commit c9b7765646
118 changed files with 11668 additions and 6679 deletions
+250 -49
View File
@@ -1,50 +1,119 @@
# archive Specification
## Purpose
TBD - created by archiving change app-json-contract. Update Purpose after archive.
Что приложение спрашивает у сервиса и что получает в ответ: собственное адресное
пространство под корнем `/app/`, единая форма отказа, объявленные пределы,
страница своих записей, карточка записи, текст названного вида и файл записи
названной копии.
Приём записи нормирует `intake`, узнавание пришедшего — `access`, раздачу самого
приложения — `webapp`, хранение записи и её файлов — `storage`.
Сознательно не описаны: правка записи и её удаление — их приносят отдельные
задачи, и до них у приложения нет ни одного адреса, меняющего чужую строку.
## Requirements
### Requirement: Адреса приложения живут своим пространством
Сервис SHALL вешать собственные адреса приложения под корнем `/app/` и MUST не
занимать имён в пространстве `/api/`: последнее принадлежит хранилищу, оно
вешает туда собственные наборы адресов, и поменять этот префикс нельзя — он
литерал библиотеки, а не настройка.
заводить второго адресного пространства рядом. Пространство `/api/`, прежде
принадлежавшее встроенному хранилищу, и адрес панели `/_/` перестают
существовать: сервис их не занимает и отвечает на них тем же, чем отвечает
всякий неизвестный путь, — норму держит capability `webapp`.
Свободных имён в чужом пространстве сегодня хватает, но соседство остаётся:
обновление библиотеки вправе занять новое имя рядом с нашим, и разойдутся они
молча — тем же адресом начнёт отвечать не тот обработчик.
Корень приложения остаётся прежним, и это решение подтверждается, а не
принимается заново: формы запросов и ответов приложения смена хранилища не
трогает ни одним полем.
Цена переезда называется здесь же. Правило неизвестного пути, по которому
приложение отдаётся вместо отказа, MUST перечислять **все** корни сервиса, а не
один: путь внутри любого корня в приложение не проваливается никогда. Ограничитель
частоты хранилища настроен на чужой корень и наших адресов больше не покрывает,
поэтому сервис MUST заводить своё правило под корень приложения.
Соседство, ради которого корень был выбран, кончилось вместе с соседом: чужого
обновления, вправе занять новое имя рядом с нашим, больше нет.
Цена этого названа здесь же: ограничитель у хранилища один на всю его поверхность
и выключен умолчанием, поэтому включение нашего правила вводит в действие и его
собственные — на входе, на заведении записей и на его адресах. Принимается
сознательно: без включения наше правило не значит ничего.
Ограничитель частоты под корнем приложения MUST быть своим: он считает бюджет по
адресу спрашивающего и MUST не зависеть от настроек чужой поверхности. Прежде
включение нашего правила вводило в действие и чужие правила на чужих адресах;
платить за это больше нечем — чужих адресов нет.
Пространство `/api/settings` принадлежит хранилищу и остаётся ему: настройки
человека живут под корнем приложения.
**Адрес спрашивающего ограничитель MUST брать из заголовка пересылки — и только
тогда, когда соединение пришло с адреса из объявленного перечня доверенных.** Во
всяком другом случае адресом MUST считаться адрес пира, а пришедший заголовок
MUST не влиять на ключ бюджета ничем.
**Цепочку пересылки ограничитель MUST читать справа налево, отбрасывая адреса из
перечня доверенных, и брать первый недоверенный.** Читаются при этом **все**
строки заголовка, а не первая: цепочка законно приходит несколькими строками.
Значение, оставшееся слева, ключа бюджета MUST не задавать: прокси заголовок
дописывает, а не заменяет, поэтому слева стоит то, что прислал спрашивающий, — и
ключ, взятый оттуда, меняется у него на каждом запросе, то есть бюджет
обходится с первого. Цепочка, где недоверенного адреса не нашлось вовсе, MUST
падать обратно на адрес пира.
Обе половины правила закрывают свою поломку. Сервис стоит за обратным прокси, и
пир у него один на всех: бюджет, посчитанный по пиру, становится общим на весь
сервис, и один спрашивающий исчерпывает его остальным. Обратная ошибка — верить
заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он
ограничивает: значением пересылаемого заголовка распоряжается тот, кто шлёт
запрос, и меняет он его на каждом запросе.
**Узнавание и ограничитель берут адрес разными способами, и это намеренно.**
Узнаванию нужен адрес самого соединения: им оно решает, верить ли заголовку
вообще, и взятый из пересылаемого заголовка он сделал бы барьер подделываемым той
же строкой, которой обходится, — норму держит capability `access`. Ограничителю
нужен адрес того, кого он ограничивает, а тот за прокси в адресе соединения не
виден вовсе. Вопросы разные — «кому верить» и «кого считать», — и один ответ на
оба ломает либо барьер, либо бюджет.
#### Scenario: Адрес приложения отвечает под своим корнем
- **GIVEN** человек вошёл и предъявил сессию
- **GIVEN** человек узнан
- **WHEN** он спрашивает список своих записей под корнем приложения
- **THEN** ответ приходит от сервиса, а не от хранилища
- **THEN** ответ приходит от сервиса
#### Scenario: Прежние адреса приложения не отвечают
#### Scenario: Пространства хранилища не существует
- **GIVEN** заведена запись
- **WHEN** её спрашивают прежними адресами в чужом пространстве
- **THEN** ответ имеет код `404`
- **GIVEN** сервис поднялся
- **WHEN** запрос приходит на путь под прежним корнем хранилища
- **THEN** ответ тот же, что и на всякий неизвестный путь вне корней сервиса
#### Scenario: Адреса панели не существует
- **GIVEN** сервис поднялся
- **WHEN** запрос приходит на прежний адрес панели — и записанный знаком, и
записанный его кодом
- **THEN** ответ тот же, что и на всякий неизвестный путь вне корней сервиса
#### Scenario: Ограничитель частоты покрывает адреса приложения
- **WHEN** сервис поднялся
- **THEN** настройки ограничителя несут правило, чей адрес начинается корнем
приложения
- **GIVEN** сервис поднялся
- **WHEN** запросы с одного адреса идут чаще бюджета под корнем приложения
- **THEN** лишние получают отказ ограничителя
#### Scenario: Два клиентских адреса через один прокси расходуют разные бюджеты
- **GIVEN** запросы идут с доверенного адреса, и адрес пира у них один
- **WHEN** два разных клиентских адреса шлют запросы под корнем приложения
- **THEN** бюджет каждого считается отдельно
- **AND** исчерпание бюджета одним не отказывает другому
#### Scenario: Заголовок с недоверенного адреса на ключ бюджета не влияет
- **GIVEN** запросы приходят с адреса вне перечня доверенных
- **WHEN** они несут заголовок пересылки с разными значениями адреса
- **THEN** бюджет у них общий и считается по адресу пира
#### Scenario: Значение, приписанное спрашивающим, ключа бюджета не задаёт
- **GIVEN** запросы приходят с доверенного адреса
- **WHEN** они несут цепочку пересылки, где слева стоит меняющееся значение
спрашивающего, а справа — адрес, приписанный прокси
- **THEN** бюджет считается по правому значению
- **AND** запросы чаще бюджета получают отказ ограничителя
#### Scenario: Цепочка читается всеми строками заголовка
- **GIVEN** запросы приходят с доверенного адреса
- **WHEN** цепочка пересылки приходит несколькими строками заголовка
- **THEN** ключ бюджета берётся из последней строки, а не из первой
### Requirement: Отказ называет причину, а не место
@@ -54,21 +123,24 @@ TBD - created by archiving change app-json-contract. Update Purpose after archiv
- пришедший не узнан — `401`, и он MUST наступать **до всякого чтения записи**,
одинаково для заведённой записи и для неизвестного идентификатора: иначе по
разнице кодов перебирается список заведённых записей;
- узнанный предъявитель без учётной записи пользователя — `403`;
- неизвестный идентификатор — `404`, и **тем же кодом с тем же телом** MUST
отвечать чужая и ничья запись;
- негодный ввод — `400`: нечитаемая запись, неизвестное значение параметра,
негодный размер страницы;
негодный размер страницы, негодный диапазон в запросе файла;
- запись сверх потолка размера — `413`, и тело MUST нести предел числом;
- состояние, в котором действие недоступно, — `409`: текста запрошенного вида у
записи ещё нет;
- отказ хранилища и всякая неназванная причина — `500`.
- состояние, в котором действие недоступно, — `409`: текста или копии файла
запрошенного вида у записи ещё нет;
- отказ базы и всякая неназванная причина — `500`.
Ветвь «узнанный предъявитель без учётной записи пользователя» из перечня ушла
вместе со своим единственным случаем: им был владелец панели, предъявивший
собственный токен хранилища. Ни панели, ни токенов у сервиса не осталось, а
узнавание по заголовку учётную запись заводит само, и предъявителя без неё не
бывает. Ветвь, у которой нет достижимого случая, не проверяется ничем и остаётся
в коде мёртвой.
Отображение доменной ошибки в код и сообщение MUST жить **одним местом** на все
адреса, и у него MUST быть определённая ветвь по умолчанию. Сегодня такого места
нет вовсе, и каждый обработчик решает сам: опрос отвечает «записи нет» на упавшую
базу, а приём — «внутренняя ошибка» на негодный файл. Человек читает первое как
«моя запись пропала», а второе не говорит ему ничего.
адреса, и у него MUST быть определённая ветвь по умолчанию.
Тело отказа MUST быть одной формы на всех адресах приложения и MUST нести **два**
поля: машиночитаемый код отказа из закрытого перечня и сообщение, пригодное
@@ -82,8 +154,8 @@ TBD - created by archiving change app-json-contract. Update Purpose after archiv
выбранные кодом они стали бы контрактом молча:
- поля тела: `error_code` и `message`;
- перечень `error_code`: `unauthorized`, `forbidden`, `not_found`,
`bad_request`, `too_large`, `too_many_requests`, `not_ready`, `internal`.
- перечень `error_code`: `unauthorized`, `not_found`, `bad_request`,
`too_large`, `too_many_requests`, `not_ready`, `internal`.
Часть отказов рождается **не в обработчике** — предел тела, ограничитель частоты,
неизвестный путь под корнем приложения, — и до отображения доменной ошибки не
@@ -97,13 +169,13 @@ TBD - created by archiving change app-json-contract. Update Purpose after archiv
умолчанию отдаст `internal` на обычный конфликт, и владелец сервиса увидит в
журнале аварию там, где её нет.
Сырой текст ошибки MUST в тело не попадать — ни `err.Error()`, ни детали
устройства: имена внешних сервисов, пути на диске, ключи файлов. Полная ошибка
Сырой текст ошибки MUST в тело не попадать — ни текст отказа драйвера, ни детали
устройства: имена внешних сервисов, пути на диске, имена файлов. Полная ошибка
остаётся в журнале владельца сервиса.
#### Scenario: Сбой хранилища виден как сбой
#### Scenario: Сбой базы виден как сбой
- **GIVEN** хранилище отвечает отказом драйвера на чтение записи
- **GIVEN** база отвечает отказом драйвера на чтение записи
- **WHEN** владелец спрашивает свою запись
- **THEN** ответ имеет код `500`
- **AND** тела записи в ответе нет
@@ -115,16 +187,10 @@ TBD - created by archiving change app-json-contract. Update Purpose after archiv
- **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку
- **AND** причина отказа в тело ответа не попадает
#### Scenario: Отказ по пустому владельцу
- **GIVEN** предъявитель узнан, но учётной записи пользователя у него нет
- **WHEN** он шлёт запись приёмом
- **THEN** ответ имеет код `403`
#### Scenario: Форма тела одна на всех ветвях отказа
- **WHEN** сервис отказывает по ненайденной записи, по негодному вводу, по
отсутствию учётной записи и по сбою хранилища
недоступному состоянию и по сбою базы
- **THEN** тело каждого ответа несёт код отказа и сообщение одними и теми же
полями
- **AND** код отказа принадлежит закрытому перечню
@@ -468,3 +534,138 @@ MUST браться колонками самой записи, а не стро
называет вида вовсе
- **THEN** ответ имеет код `400`
### Requirement: Файл записи отдаётся адресом приложения
Сервис SHALL отдавать файл записи адресом под корнем приложения — `GET
/app/audiorecords/{id}/file` — и MUST отдавать **копию, названную
спрашивающим**. Отдача «какой-нибудь» копии сделала бы ответ функцией того, что
успел записать конвейер, а не состояния записи.
**Копию называет параметр запроса `copy`.** Имя параметра нормативно наравне со
значениями: разбирает его каждый экран, и выбранное кодом оно стало бы публичным
контрактом молча.
**Перечень значений закрыт, и каждое называет ровно одну хранимую вещь:**
- `original` — файл, принятый от отправителя;
- `normalized` — копия, приведённая к рабочему формату.
Копию, которой у записи ещё нет, сервис MUST отдавать отказом состояния — тем же
кодом, каким отвечает ненаписанный текст: `409`. Пустой ответ читался бы как
пустой файл, а `404` слился бы с ответом на чужую и неизвестную запись, и человек
увидел бы «не найдено» на своей записи, загруженной минуту назад.
Копия, которой сервис не знает, и незаданная копия MUST давать отказ по негодному
вводу — но только у **своей** записи.
Порядок проверок MUST быть один: владение записью судится **до** разбора значения
копии. Неизвестное либо незаданное значение копии у чужой и у несуществующей
записи MUST давать тот же ответ, что и неизвестный идентификатор, — и кодом, и
телом. Неотличимость чужой записи от несуществующей главнее формы ответа на
негодный ввод: разбор параметра, выполненный раньше, отвечал бы одинаково на
чужую и на неизвестную только случайно, а стоило бы ответам разойтись — по этой
разнице перебирался бы список заведённых записей одним негодным параметром.
Ответ MUST нести длину файла и тип содержимого и MUST допускать выдачу по частям:
запись расчётного потолка — шесть часов, и проигрыватель в браузере перематывает
её запросом диапазона, а не повторной загрузкой целиком.
**Негодный диапазон MUST приводиться к обычному отказу сервиса** — телом той же
формы и кодом из закрытого перечня, — а не отвечать кодом `416` и телом
библиотеки. Негодных диапазонов два вида, и оба ведут себя одинаково:
неудовлетворимый (начало за концом файла) и множественный (в запросе назван
больше чем один диапазон). Второй сервис не отдаёт намеренно: ответ из нескольких
частей — это отдельный тип содержимого со своими границами, а просит его один
только самодельный запрос, потому что проигрыватель в браузере шлёт один
диапазон.
Причина у требования общая с прочими отказами, рождающимися не в обработчике:
форма тела на адресах приложения одна, и код отказа принадлежит закрытому
перечню. Ответ `416` с телом библиотеки приходит без полей `error_code` и
`message`, и приложение разбирает его отдельной веткой — единственной такой на
все адреса.
Файл чужой записи MUST быть недоступен наравне с её карточкой и отвечать тем же,
чем неизвестный идентификатор. Кто владелец файла и почему право пройти по адресу
даёт узнавание, а не выданное значение, нормирует capability `storage`.
Имя файла на диске MUST в ответ не попадать: имя, предлагаемое браузеру при
сохранении, строится из имени, данного отправителем, и лежит оно колонкой записи.
**Перечня доступных копий карточка записи не объявляет** — до задачи об экране
прослушивания его в ответе MUST не быть, и об отсутствующей копии спрашивающий
узнаёт отказом состояния на самом обращении за файлом.
Довод, которым перечень доступных видов текста объявляется карточкой всегда, к
копиям файла не относится, и это разные случаи. Видов текста несколько, шаг
завершения пишет их несколькими операциями, поэтому состояние «сплошной текст
есть, реплик ещё нет» достижимо, а из состояния записи не выводится: приложение
обязано узнать перечень, иначе пойдёт за текстом, которого нет. Копий же две, и
каждая выводится из рубежа записи, который карточка несёт и так: принятая копия
есть у всякой заведённой записи, приведённая — у всякой, прошедшей приведение.
Второе поле повторяло бы рубеж и разошлось бы с ним молча.
Перечень появится тогда, когда у него появится потребитель: экран прослушивания
приносит задача `play-recording-in-app`. Объявлять его раньше — закреплять
контракт, которого никто не разбирает.
#### Scenario: Владелец забирает принятую копию
- **GIVEN** запись принята
- **WHEN** владелец просит её файл копией `original`
- **THEN** ответ несёт содержимое принятого файла и его длину
#### Scenario: Приведённой копии ещё нет
- **GIVEN** запись принята и не дошла до приведения
- **WHEN** владелец просит её файл копией `normalized`
- **THEN** ответ имеет код `409`
- **AND** он отличается от ответа на неизвестный идентификатор
#### Scenario: Копия неизвестна или не названа
- **WHEN** владелец просит файл копией, которой сервис не знает, либо не
называет копии вовсе
- **THEN** ответ имеет код `400`
#### Scenario: Чужой файл неотличим от неизвестной записи
- **GIVEN** запись принята одним узнанным
- **WHEN** её файл просит другой узнанный
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
#### Scenario: Негодная копия у чужой записи неотличима от неизвестной записи
- **GIVEN** запись принята одним узнанным
- **WHEN** другой узнанный просит её файл копией, которой сервис не знает
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
- **AND** он не отличается от ответа на ту же просьбу к несуществующей записи
#### Scenario: Перечня копий в карточке нет
- **GIVEN** запись принята и приведена к рабочему формату
- **WHEN** владелец спрашивает её карточку
- **THEN** поля с перечнем доступных копий файла в ответе нет
#### Scenario: Проигрыватель просит кусок записи
- **GIVEN** запись принята
- **WHEN** владелец просит её файл с указанием диапазона
- **THEN** ответ несёт запрошенный кусок, а не файл целиком
#### Scenario: Неудовлетворимый диапазон отвечает обычным отказом
- **GIVEN** запись принята, и её файл короче запрошенного начала
- **WHEN** владелец шлёт настоящий HTTP-запрос за файлом с заголовком `Range:
bytes=99999999-`
- **THEN** ответ имеет код `400`, а не `416`
- **AND** тело несёт поля `error_code` и `message`
#### Scenario: Двух диапазонов в одном запросе сервис не отдаёт
- **GIVEN** запись принята
- **WHEN** владелец шлёт настоящий HTTP-запрос за файлом с заголовком,
называющим два диапазона
- **THEN** ответ имеет код `400`, а не `416`
- **AND** тело несёт поля `error_code` и `message`
- **AND** ответа из нескольких частей сервис не отдаёт