Мультитенантность в 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 — ручной:
Безопасно, потому что 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). Все шесть зелёные локально:
- Схема: таблица
tenantsсоздаётся с корректными NOT NULL колонками и первичным ключом. 2 + 3. Seed идемпотентен: 2own_companies→ 2tenantsсkind='personal'; повторный запуск ничего не создаёт,slugуникален. findTenantBySlug('golovin')возвращает корректный объект сdisplayName,kind='personal',status='active'.listTenants({ status: 'archived' })возвращает[]в чистой базе.- Каждый
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)¶
- npm-пакет ULID. Выбран
ulid^2.3.0(в production подтянут2.4.0).monotonicFactory()в seed даёт стабильный порядок при быстрых подряд вызовах. Альтернативаulidxотклонена как менее распространённая. - Формат миграции. Не drizzle migration file, а inline
CREATE TABLE IF NOT EXISTSвserver/storage.ts— проектная идиома alatyr-service (все более 30 существующих таблиц так же). Drizzle-модель вshared/schema.ts— только для TS-типов и ORM-запросов. - Repository → feature-модуль. Вместо
TenantsRepositoryclass — отдельный модульserver/tenants.tsс экспортами-функциями (соответствует паттерну остальных модулей:moedelo.ts,object-assistant.ts). - Seed в deploy-pipeline.
npm run db:seed:tenantsвстроен в.github/workflows/deploy.ymlсразу послеdb:seed:own-companies— выполняется на каждом деплое, идемпотентно.
Открытые вопросы для будущих фаз:
kind='system'для cron/фоновых задач — если понадобится, расширять enum отдельнымMT-пунктом.
Ссылки¶
- Эпик MT — в CMS-разделе Задачи (фильтр:
project=alatyr-service,aliasначинается с !MT-!), архив —_archive/todo/TODO.md - migration-sqlite-to-postgres.md — предпосылка CRM-17
- my-companies.md — реквизиты обоих ИП, источник seed
- crm-objects-spec.md — базовая CRM-схема