Files
iu 8638d1bbfc chore(openspec): archive completed changes and sync main specs
- archive local-fs-storage-backend, sqlite-metadata-backend,
  storage-orphan-reconciliation, metadata-managed-jwt-secret, default-local-backends
- sync specs: storage-backends, metadata-backends, storage-reconciliation,
  app-secrets; update photos/share-links
- defaults reflected in specs: SQLite and local FS
2026-10-11 10:19:44 +03:00

121 lines
9.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design
## Context
См. `proposal.md` — Why. Текущее состояние, определяющее подход:
- Каждый домен имеет конкретный `Store` с полем `*pgxpool.Pool` и SQL диалекта Postgres.
Всего ~72 метода: `auth(9)`, `albums(7)`, `members(8)`, `photos(20)`,
`photos/objects(15)`, `social(6)`, `notifications(3)`, `sharelinks(4)`.
- Postgres-специфика: `FOR UPDATE SKIP LOCKED`, `ANY($1)`, `make_interval`/`power`/`least`,
`now()`, `timestamptz`, `jsonb`/`::jsonb`, `ON CONFLICT`, `RETURNING`.
- Кросс-доменные зависимости уже выражены интерфейсами (`AlbumAccess`, `PhotoLookup`,
`Notifier`, `MemberLookup`, `AlbumReader`); свои store сервисы берут конкретными.
- Сборка `CGO_ENABLED=0`; model: один экземпляр приложения на инсталляцию.
- Ограничение: SQLite допускает один писатель, много читателей (WAL); один файл на
один экземпляр приложения.
## Goals / Non-Goals
**Goals:**
- Ввести интерфейсы репозиториев по доменам и вторую реализацию на SQLite с паритетом.
- Сохранить текущее поведение PostgreSQL по умолчанию и не менять сервисную логику.
- Обеспечить атомарность объектов, GC и очереди вариантов на обоих бэкендах.
- Гарантировать паритет общим контрактным набором тестов.
**Non-Goals:**
- Горизонтальное масштабирование и несколько экземпляров на один файл SQLite.
- Миграция данных между PostgreSQL и SQLite.
- Изменение наблюдаемого поведения API/спек (см. `proposal.md`, изменений требований нет).
- Изменение слоя хранения (см. `local-fs-storage-backend`).
## Decisions
### D1. Параллельные реализации, а не слой диалекта
Выбрано (по решению пользователя): Postgres-store не трогаем, пишем отдельные
SQLite-store. Интерфейс `Repository` становится швом между доменом и хранилищем.
- Альтернатива: единая логика + `Dialect` — отвергнута: правки во всех существующих
store и риск регрессии Postgres ради экономии дублирования.
- Цена: каждая будущая фича реализуется дважды; снижается контрактными тестами (D9).
### D2. Именование и размещение
Интерфейс домена — `Repository`; существующий pgx-тип остаётся `Store` (`NewStore(pool)`),
SQLite-тип — `SQLiteStore` и живёт в том же пакете (`photos/sqlite_store.go` и т.п.).
- В том же пакете — доступ к `models` и sentinel-ошибкам без циклов импорта и без
перемещения существующего кода.
- Сервисы типизируются интерфейсом (`albums.Repository` и т.д.); кросс-доменные
интерфейсы удовлетворяются любой реализацией.
### D3. Драйвер SQLite — `modernc.org/sqlite`
Чистый Go, совместим с `CGO_ENABLED=0`.
- Альтернатива `mattn/go-sqlite3` (CGO) — отвергнута, ломает модель сборки.
### D4. Роль `$1`-плейсхолдеров
Плейсхолдеры остаются `$1`: и pgx, и `modernc` понимают именованные параметры.
Поскольку реализации раздельные, каждая пишет свой SQL; отдельный слой диалекта не нужен.
### D5. Отдельный набор миграций для SQLite
`migrations/sqlite/` рядом с Postgres-набором, драйвер `golang-migrate/database/sqlite`.
Отображение типов: `timestamptz`→TEXT, `jsonb`→TEXT, `bigint`→INTEGER,
`double precision`→REAL, `now()`→`strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
`REFERENCES ... ON DELETE CASCADE` и `CHECK` сохраняются.
- Альтернатива: переносимый единый набор — отвергнута, тянет переписывание обоих.
### D6. Настройка соединения и конкурентность
`journal_mode=WAL`, `busy_timeout=5000`, `foreign_keys=ON` через DSN-параметры
`_pragma`. Писатель один; читатели — параллельно.
- `ANY($1)` переписывается в `IN (?, ...)` с построением списка в Go; батчи малы.
- `FOR UPDATE`/`SKIP LOCKED` заменяются транзакцией с немедленной блокировкой записи
(`BEGIN IMMEDIATE`); порядок «проверить состояние — вставить/заявку» сохраняется.
### D7. Атомарность жизненного цикла объектов
`BindObject` и claim-методы (`ClaimGarbage`, `ClaimStalePending`, `ClaimVariantJobs`)
выполняются в `BEGIN IMMEDIATE`-транзакции, что сериализует запись и воспроизводит
инварианты Postgres-версии (нельзя привязать объект, уходящий в GC; нельзя удалить
используемый; одинаковая роль без дублей).
### D8. Backoff и `now()`
`FailVariantJob` считает задержку в Go (`min(300, 10*2^attempts)`) вместо
`make_interval/power/least`. Время пишется/читается через хелперы преобразования
`time.Time` <-> RFC3339 TEXT; запросы сравнения используют `datetime('now')`/значения Go,
а не неявное приведение драйвера.
### D9. Контрактные тесты на интерфейс
Один набор тестов на каждый `Repository`, прогоняемый на обоих бэкендах: Postgres под
`TEST_DATABASE_URL` (пропуск без него), SQLite на временном файле (всегда). Это механизм
удержания паритета при параллельных реализациях.
### D10. Фабрика и сборка зависимостей
`store.NewPostgres(pool)` и `store.NewSQLite(db)` возвращают бандл репозиториев;
`app.New` принимает бандл и `storage.Backend`. Выбор бэкенда — на старте по
`DB_BACKEND=postgres|sqlite` (дефолт `postgres`), путь — `SQLITE_PATH`.
- Альтернатива: ветвление внутри `app.New` по типу пула — отвергнута, размазывает выбор.
### D11. Маппинг ошибок и `RETURNING`
`schema`-уровневые sentinel-ошибки домена сохраняются; каждая реализация сама
отображает `pgx.ErrNoRows`/`sql.ErrNoRows` в sentinel. `RETURNING` используется и в
SQLite (>=3.35), поэтому вставки с возвратом `created_at` переносятся один-в-один.
## Risks / Trade-offs
- [Дрейф параллельных реализаций] → общий контрактный набор тестов на интерфейс (D9).
- [Пропускная способность записи SQLite под многими пользователями] → WAL,
`busy_timeout`, один писатель; документируем как режим малой инсталляции.
- [Потеря валидации `jsonb`] → хранение как TEXT, проверка JSON при необходимости в Go.
- [Ошибки преобразования времени/JSON между бэкендами] → хелперы + контрактные тесты.
- [Расхождение миграций] → симметричные наборы, прогон `up` на обоих в тестах.
- [Лимит параметров SQLite в `IN (...)`] → малые батчи (`WORKER_BATCH`), построение
списка в Go.
## Migration Plan
1. Ввести интерфейсы, не меняя Postgres-реализации и дефолт `postgres`.
2. Добавить SQLite-реализации, миграции и фабрику; включение — `DB_BACKEND=sqlite`.
3. Существующие развёртывания не меняются; откат — вернуть `DB_BACKEND=postgres`.
4. Перенос данных между бэкендами не автоматизируется и вне рамок изменения.
## Open Questions
- Нужен ли SQLite-специфичный поиск меток (например, FTS) или достаточно текущих
запросов с точным совпадением/префиксом.