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

Мультитенантность в alatyr-service — архитектурный якорь (эпик MT)

Автор: сессия Perplexity Computer, 27.08.2026 Статус: v0.2 — MT-01 закрыт в production (alatyr-service PR #99, 27.08.2026, commit 323a675) Владелец: Роман Головин Связано с задачами: MT-01 (в production), MT-02..MT-17 (последующие фазы), CRM-17 (SQLite → Postgres — предпосылка MT), RAG-04 (двухмерная изоляция знаний)

Контекст и мотивация

alatyr-service изначально писался под одного владельца (Роман Головин, два ИП). Сейчас, чтобы платформа могла:

  • продаваться корпоративным заказчикам с изолированным контуром,
  • поставляться в in-house/on-premise (MT-16) без переписывания,
  • проходить требования ФСТЭК/КИИ по разграничению доступа,
  • давать подрядчикам-исполнителям управляемую шару к чужим объектам (MT-17),

нужен единый архитектурный якорь — сущность tenant, к которой привязаны все прикладные данные и ИИ-контекст. Эпик MT в TODO.md фиксирует переход в разработке "multi-tenant by default": любая новая таблица, API-хендлер, RAG-коллекция и UI-контекст обязаны нести tenant_id уже сейчас, даже когда рабочий тенант один.

MT-01 — минимальный неразрушающий шаг, вводящий сущность tenants в схему БД. Данные существующего own_companies не трогаются: связь между own_companies и tenants (own_companies.tenant_id) добавляется отдельной миграцией в MT-02. Такой подход даёт откатабельность и позволяет прикладному коду ещё некоторое время оставаться tenant-unaware.


Схема таблицы tenants (MT-01)

SQL (SQLite → совместимо с Postgres после CRM-17)

CREATE TABLE IF NOT EXISTS tenants (
  id            TEXT PRIMARY KEY,             -- ULID (26 символов, Crockford Base32)
  slug          TEXT NOT NULL UNIQUE,         -- человекочитаемый, стабильный для URL/логов
  display_name  TEXT NOT NULL,                -- как показываем в UI и документах
  kind          TEXT NOT NULL
                CHECK(kind IN ('personal','company','partner','customer')),
  status        TEXT NOT NULL DEFAULT 'active'
                CHECK(status IN ('active','archived')),
  created_at    INTEGER NOT NULL,             -- Unix epoch seconds, e.g. 1756297200
  CHECK (length(id) = 26)
);

CREATE INDEX IF NOT EXISTS idx_tenants_status ON tenants(status);

Обоснование полей

Поле Почему такое
id — ULID Сортируется по времени, компактнее UUID (26 vs 36 символов), безопасно генерируется на стороне приложения без коллизий; переносится в Postgres как TEXT (позже можно uuid, если понадобится).
slug — UNIQUE Стабильный идентификатор для путей в файловом хранилище (/{tenant_slug}/{object_id}/... в MT-05), логов, отладки и агентских запросов. В отличие от id — человеко-читаемый.
display_name Отделяет UI-название от slug: slug менять нельзя (ломает пути и ссылки), display_name — можно.
kind — enum из 4 значений personal — тенанты владельца платформы (два ИП Романа); company — корпоративные заказчики платформы; partner — подрядчики/исполнители (для MT-17); customer — конечные клиенты, получившие ограниченный доступ (для CRM-35..47, "клиентский кабинет").
status — только active / archived Явное решение владельца: без soft-delete через deleted_at. Аудит закрывается platform_audit_log в MT-06.
created_at — INTEGER unix epoch seconds Единообразно с остальными таблицами alatyr-service (auth_tokens, users, own_companies все хранят created_at как INTEGER). SQLite не имеет native timestamptz; при переходе на Postgres (CRM-17) тип маппится на bigint или конвертится в timestamptz отдельной миграцией.

Что не входит в MT-01

  • Колонка inn — перенесена в MT-02, где она появится вместе с own_companies.tenant_id. Причина: пока связь с own_companies не установлена, inn дублировал бы уже существующий own_companies.inn.
  • Колонки updated_at, metadata, deleted_at, owner_user_id, plan_code — вводятся точечно в последующих MT-02..MT-07, а не на всякий случай.
  • Foreign keys на own_companies — тоже MT-02 (тогда меняется owning-сторона: own_companies.tenant_id → tenants.id).
  • Any UI, CRUD API, admin-панель — MT-01 меняет только схему и агентский read-доступ.

Data migration

Источник seed

own_companies — существующая таблица с 1..N записями своих юрлиц (сейчас 2 ИП, см. my-companies.md). Она уже имеет колонку code (например, golovin), которая на MT-01 идеально маппится на tenants.slug.

Правила seed 1:1

Для каждой строки own_companies:

Фактическая реализация в script/seed-tenants.ts (вызывается npm run db:seed:tenants на каждом деплое после db:seed:own-companies):

import { monotonicFactory } from "ulid";
const ulid = monotonicFactory();
const now = Math.floor(Date.now() / 1000);

db.transaction((tx) => {
  for (const company of tx.select().from(ownCompanies).all()) {
    // Идемпотентность: skip если tenant с таким slug уже существует.
    const [existing] = tx.select({ id: tenants.id })
      .from(tenants).where(eq(tenants.slug, company.code)).all();
    if (existing) continue;

    tx.insert(tenants).values({
      id: ulid(),
      slug: company.code,               // 'golovin', 'korobochka'
      displayName: company.fullName,
      kind: 'personal',                 // все существующие own_companies — personal tenants владельца
      status: 'active',
      createdAt: now,
    }).run();
  }
});

Идемпотентность

Миграция должна быть безопасна при повторном запуске (например, если dev поднимает БД из dump, где tenants уже частично засижена). Правило: перед INSERT проверять SELECT id FROM tenants WHERE slug = ?; если найден — skip.

В production drizzle-kit не выполнит миграцию повторно, но защита нужна для локальной разработки и восстановления из бэкапа.

Down-миграция

alatyr-service использует идиому CREATE TABLE IF NOT EXISTS в server/storage.ts вместо drizzle migration files, поэтому формальной down-миграции нет. Откат MT-01 — ручной:

DROP INDEX IF EXISTS idx_tenants_status;
DROP TABLE IF EXISTS tenants;

Безопасно, потому что MT-01 не создаёт ссылок из других таблиц. С MT-02 откат станет разрушительным — это отдельная граница осторожности.


Repository и агентский доступ

Скоуп на MT-01

Фактическая реализация — не класс-repository, а feature-модуль server/tenants.ts (как moedelo.ts, object-assistant.ts и др.):

// server/tenants.ts
export function findTenantById(id: string): Tenant | null;
export function findTenantBySlug(slug: string): Tenant | null;
export function listTenants(opts?: { status?: TenantStatus }): Tenant[];
export function isValidUlid(value: string): boolean;
export const ULID_REGEX: RegExp;
  • Только read. Никаких create / update / archive в MT-01 — они появятся в MT-04 (memberships) и MT-09 (onboarding).
  • Никаких HTTP endpoints. В server/routes.ts изменения не вносятся.
  • Агентский read-доступ. Внутренние сервисы (RAG bridge, planned MT-03 middleware) могут читать список активных тенантов из репозитория. Прикладной код CRM продолжает работать как раньше — без tenant filter.

Тесты (обязательные для MT-01)

Реализованы в script/smoke-tenants.ts (npm run test:tenants). Все шесть зелёные локально:

  1. Схема: таблица tenants создаётся с корректными NOT NULL колонками и первичным ключом. 2 + 3. Seed идемпотентен: 2 own_companies → 2 tenants с kind='personal'; повторный запуск ничего не создаёт, slug уникален.
  2. findTenantBySlug('golovin') возвращает корректный объект с displayName, kind='personal', status='active'.
  3. listTenants({ status: 'archived' }) возвращает [] в чистой базе.
  4. Каждый id проходит ULID регулярку /^[0-9A-HJKMNP-TV-Z]{26}$/; findTenantById по нему возвращает ту же строку.

Эволюция схемы: от MT-01 к MT-06

Шаг Что добавляется
MT-01 (сейчас) Таблица tenants, seed из own_companies, read-only repository.
MT-02 tenant_id во все hot-path таблицы CRM (objects, contracts, requests, estimates, invoices, acts, documents, crm_clients, assignments, materials_movements, object_contracts). Backfill из own_companies.owner_tenant_id. Индексы (tenant_id, ...). Проверка EXPLAIN. Добавление own_companies.tenant_id + tenants.inn (либо через join).
MT-03 Request-context middleware: каждый HTTP-запрос получает tenant_id из сессии; запрет прямых db.select().from(objects) без tenant filter (проверка в тестах и линтере). Служебные задачи явно указывают tenant или system.
MT-04 tenant_memberships(user_id, tenant_id, role, status). Пользователь может быть в нескольких тенантах с разными ролями.
MT-05 Изоляция файлового хранилища в alatyr-storage-api: tenant_id в токене, путь /{tenant_slug}/{object_id}/..., 403 при cross-tenant чтении.
MT-06 platform_audit_log: любой tenant-cross запрос (админ платформы читает данные другого tenant) фиксируется с обоснованием.

Все таблицы, добавляемые начиная с MT-02, обязаны иметь tenant_id с первой миграции — правило "multi-tenant by default" из TODO.md.


Совместимость с CRM-17 (SQLite → Postgres)

MT-01 использует только portable SQL:

  • TEXT PRIMARY KEY — в Postgres станет TEXT или, при желании, uuid (тогда ULID пишем как base32 в text-поле, конвертация не нужна).
  • CHECK(kind IN (...)) — работает в обоих движках; в Postgres можно позже заменить на ENUM, но не обязательно.
  • CREATE INDEX — стандартный.

Миграция MT-01 перенесётся в Postgres через CRM-17 (pg_dump/restore + переприменение drizzle-миграций) без ручных правок.


Совместимость с RAG-04 (двухмерная изоляция знаний)

RAG-04 требует, чтобы каждый вектор в Qdrant/Open WebUI Knowledge имел в payload tenant_id и object_id. MT-01 даёт стабильный источник tenant_id (ULID) и tenant_slug для человекочитаемых имён коллекций (если будет выбрана стратегия "collection-per-tenant" вместо payload filter).

Решение "collection-per-tenant vs payload filter" — не входит в MT-01, оно принимается в RAG-04 после того, как tenants уже существует.


Совместимость с MT-05 (файловое хранилище)

alatyr-storage-api в MT-05 будет строить путь как /{tenant_slug}/{object_id}/.... Требование к tenants.slug: не меняется во времени, только буквы/цифры/дефис в нижнем регистре, длина 2..40 символов. Валидация slug добавляется в момент, когда появится write-путь (MT-04 / MT-09); в MT-01 seed-значения из own_companies.code уже удовлетворяют этому требованию.


Закрытые решения (v0.2, по итогам PR #99)

  1. npm-пакет ULID. Выбран ulid ^2.3.0 (в production подтянут 2.4.0). monotonicFactory() в seed даёт стабильный порядок при быстрых подряд вызовах. Альтернатива ulidx отклонена как менее распространённая.
  2. Формат миграции. Не drizzle migration file, а inline CREATE TABLE IF NOT EXISTS в server/storage.ts — проектная идиома alatyr-service (все более 30 существующих таблиц так же). Drizzle-модель в shared/schema.ts — только для TS-типов и ORM-запросов.
  3. Repository → feature-модуль. Вместо TenantsRepository class — отдельный модуль server/tenants.ts с экспортами-функциями (соответствует паттерну остальных модулей: moedelo.ts, object-assistant.ts).
  4. Seed в deploy-pipeline. npm run db:seed:tenants встроен в .github/workflows/deploy.yml сразу после db:seed:own-companies — выполняется на каждом деплое, идемпотентно.

Открытые вопросы для будущих фаз:

  • kind='system' для cron/фоновых задач — если понадобится, расширять enum отдельным MT-пунктом.

Ссылки