Перейти к содержанию

ADR 0003: Разделение схем БД (public / pm / blog)

Дата: 2026-06-06 Статус: Accepted

На какие вопросы отвечает

  • Почему в одной PostgreSQL-базе живут три схемы и кто чем владеет?
  • Может ли Payment Manager писать в таблицы Serverpod (или наоборот)?
  • Как Serverpod читает баланс / историю транзакций, если они в pm.*?
  • Почему Serverpod видит блог через v_blog_post, а не напрямую blog.posts?
  • Почему миграции трёх сервисов не конфликтуют друг с другом?
  • Где «живёт» PII и почему его не видно из чужой схемы?

Контекст

OneWallet — это несколько сервисов поверх одной PostgreSQL 17:

  • Auth Center (Serverpod) — схема public.*, миграции через serverpod generate + dart bin/main.dart --apply-migrations. Здесь профили, KYC, PII.
  • Payment Manager (Drizzle) — схема pm.*, миграции через drizzle-kit migrate. Здесь intent'ы, маршруты, fee-rules, история TigerBeetle-проводок.
  • one_blog (SvelteKit + Drizzle) — схема blog.*, свои миграции. CMS «What's on».

Если бы сервисы писали в общие таблицы, любая миграция одного ломала бы другого, а PII из public.* протекал бы в платёжные и блог-таблицы. Нужна жёсткая граница владения.

Решение

Каждая схема имеет ровно одного владельца-писателя. Никто не пишет в чужую схему. Кросс-чтение разрешено только через views в public, никогда не прямым доступом к чужим таблицам.

Схема Владелец (write) Инструмент миграций
public.* Auth Center (Serverpod) serverpod
pm.* (11 таблиц) Payment Manager drizzle-kit
blog.* one_blog drizzle-kit

Serverpod читает чужие данные только через 5 views в public (источник: projects/deploy/seeds/create-views.sql):

View Источник Назначение
v_user_tb_accounts pm.tb_account_map привязка user → TB-аккаунт (USER/MERCHANT/AGENT_WALLET)
v_tx_history pm.tx_history история проводок без сырого PII (раскрывает только нужные поля)
v_blog_category blog.categories справочник категорий блога
v_blog_location blog.locations справочник локаций
v_blog_post blog.post_groupsblog.posts карточки только published постов, фильтр по expires_at

View — это явный контракт чтения: он сам решает, какие столбцы показать и в каком виде (например, v_tx_history достаёт nfcTagId, lat, lon из attributes JSONB, но не отдаёт внутренние/PII-поля). Доступ выдаётся только на чтение: GRANT USAGE ON SCHEMA pm/blog + GRANT SELECT ON ALL TABLES.

Баланс при этом Serverpod берёт не из БД, а через nginx→PM GET /accounts/{name}/balance — TigerBeetle единственный источник истины по деньгам (см. ADR 0001). Views покрывают только реляционные данные.

flowchart LR
  subgraph PG[PostgreSQL 17]
    PUB[(public.*\nServerpod)]
    PM[(pm.*\nPayment Manager)]
    BLOG[(blog.*\none_blog)]
    V[public.v_* views]
    PM -. SELECT .-> V
    BLOG -. SELECT .-> V
    V -. read-only .-> PUB
  end
  AC[Auth Center] -->|write| PUB
  AC -->|read via views| V
  PMW[Payment Manager] -->|write| PM
  OB[one_blog] -->|write| BLOG

Пример

Endpoint Serverpod показывает пользователю карточки блога. Вместо запроса к blog.posts (чужая схема) он читает public.v_blog_post через свою же ORM:

SELECT "postId", "slug", "title", "coverImageThumbUrl"
FROM public.v_blog_post
WHERE "categoryId" = $1 AND "locale" = 'th';

View уже отфильтровал черновики (status='published') и просроченные посты (expires_at > now()), и отдал стабильные текстовые UUID — Serverpod не знает деталей схемы blog.* и не сломается, если one_blog добавит туда колонку.

Последствия

  • Изоляция PII. Сырой PII живёт только в public.user_profile.encryptedPii (AES-256-GCM, см. ADR 0005). Чужие схемы и их сервисы к нему не имеют доступа, а v_tx_history намеренно не отдаёт PII-поля.
  • Независимые миграции. drizzle-kit migrate (pm, blog) и serverpod ... --apply-migrations (public) не пересекаются: каждый трогает только свою схему. Это снимает класс конфликтов «две команды поменяли одну таблицу».
  • Явный контракт чтения. Изменения внутренней структуры pm.* / blog.* не ломают читателей, пока сохранён контракт view; ломающее изменение видно как правка create-views.sql.
  • Цена. При добавлении новых полей для чтения нужно править view и выдавать GRANT'ы; views надо применять после миграций (create-views.sql идемпотентен, безопасно пере-запускать).
  • Граница доверия для записи. Любая запись в деньги идёт не в БД напрямую, а через PM POST /intents с HMAC (см. ADR 0002, ADR 0004).

Ссылки