Repository navigation
Database
Система использует SQLite в качестве основной СУБД через async SQLAlchemy + aiosqlite. Три независимых файла БД:
| База данных | Путь | Назначение |
|---|---|---|
| Основная | data/secretary.db |
Пользователи, сессии, чаты, боты, виджеты, CRM, канбан и т.д. |
| Telegram Sales |
data/sales.db (single) / data/sales_{bot_id}.db (multi) |
Воронка продаж Telegram-бота (пользователи, события, discovery, платежи) |
| WhatsApp Sales |
data/wa_sales.db (single) / data/wa_sales_{instance_id}.db (multi) |
Воронка продаж WhatsApp-бота (пользователи, события, discovery) |
Миграция legacy-путей: До v1.1 Telegram sales DB по умолчанию создавалась в корне проекта (
sales.db). Теперь все БД хранятся вdata/. При старте бота старый файл автоматически мигрирует вdata/sales.dbвместе с WAL/SHM сайдкарами.
При подключении к каждой базе данных автоматически устанавливаются оптимальные PRAGMA:
| PRAGMA | Значение | Назначение |
|---|---|---|
journal_mode |
WAL |
Конкурентное чтение во время записи (вместо полной блокировки) |
foreign_keys |
ON |
Включение проверки внешних ключей и ON DELETE CASCADE
|
busy_timeout |
5000 |
5 секунд ожидания при блокировке (вместо мгновенной ошибки) |
synchronous |
NORMAL |
Оптимальный баланс скорости и надёжности для WAL-режима |
PRAGMA устанавливаются через event listener на engine.sync_engine:
# db/database.py
@event.listens_for(engine.sync_engine, "connect")
def _set_sqlite_pragmas(dbapi_conn, connection_record):
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.close()С StaticPool (единственное постоянное соединение) listener срабатывает ровно 1 раз.
PRAGMA устанавливаются напрямую в SalesDatabase.init():
# telegram_bot/sales/database.py, whatsapp_bot/sales/database.py
await self._db.execute("PRAGMA journal_mode=WAL")
await self._db.execute("PRAGMA foreign_keys=ON")
await self._db.execute("PRAGMA busy_timeout=5000")
await self._db.execute("PRAGMA synchronous=NORMAL")Write-Ahead Logging (WAL) — режим журналирования, который позволяет читателям не блокироваться при записи. Особенности:
- При WAL появляются дополнительные файлы:
secretary.db-walиsecretary.db-shm - При корректном завершении все три БД выполняют
PRAGMA wal_checkpoint(TRUNCATE):- Основная:
close_db()вdb/database.py - Sales (Telegram/WhatsApp):
SalesDatabase.close()в соответствующихsales/database.py
- Основная:
-
Бэкапы безопасны:
BackupServiceавтоматически вызывает WAL checkpoint перед копированием каждого.db-файла и включает-wal/-shmсайдкары как safety net (см. Backup)
Эндпоинт GET /health возвращает информацию о БД, включая PRAGMA-конфигурацию:
{
"database": {
"status": "ok",
"path": "/app/data/secretary.db",
"size_mb": 2.45,
"journal_mode": "wal",
"foreign_keys_enabled": true
}
}Если journal_mode не "wal" или foreign_keys_enabled не true — это признак проблемы с инициализацией.
Основная БД использует паттерн Unit of Work: репозитории только делают flush() (отправляют SQL в БД без коммита), а коммит выполняет вызывающий код — менеджер (db/integration.py), роутер или сервис.
┌──────────────────────────────────────────┐
│ Caller (manager / router / service) │
│ async with AsyncSessionLocal() as s: │
│ repo = SomeRepo(s) │
│ await repo.create(entity) # flush │
│ await repo.update(other) # flush │
│ await session.commit() # ← ✅ │
└──────────────────────────────────────────┘
- Атомарность: несколько операций коммитятся как одна транзакция — либо все, либо ни одна
- Консистентность: данные не попадают в БД частично при ошибке между операциями
- Контроль: вызывающий код решает, когда фиксировать изменения
| Слой | flush() |
commit() |
rollback() |
|---|---|---|---|
Репозитории (db/repositories/) |
Да | Нет | Нет |
BaseRepository (create, update, delete) |
Да (автоматически) | Нет | Нет |
Менеджеры (db/integration.py) |
Нет | Да (после всех операций) | Нет (обрабатывается session context) |
Роутеры (app/routers/) |
Нет | Да (write-блоки с AsyncSessionLocal) |
Нет |
get_async_session() (FastAPI DI) |
Нет | Да (автоматически при успехе) | Да (при исключении) |
get_session_context() |
Нет | Да (автоматически при успехе) | Да (при исключении) |
# db/repositories/user.py — только flush
async def create(self, entity):
self.session.add(entity)
await self.session.flush() # SQL INSERT, но не зафиксирован
return entity
# db/integration.py — коммит в менеджере
async def create_user(self, ...):
async with AsyncSessionLocal() as session:
repo = UserRepository(session)
user = await repo.create(User(...))
await session.commit() # ← фиксация транзакции
return user.to_dict()# ❌ НЕЛЬЗЯ: коммит в репозитории
async def create(self, entity):
self.session.add(entity)
await self.session.commit() # нарушает Unit of Work
# ❌ НЕЛЬЗЯ: забыть коммит в роутере
async with AsyncSessionLocal() as session:
repo = SomeRepo(session)
await repo.create(entity)
# нет commit → данные потеряются!Две системы миграций:
| Система | Использование |
|---|---|
| Alembic (предпочтительно) |
alembic upgrade head — новые миграции |
| Legacy скрипты |
python scripts/migrate_*.py — старые миграции |
Новые таблицы создаются автоматически через Base.metadata.create_all при старте. Изменения схемы существующих таблиц требуют миграционных скриптов.
# Применить все миграции
alembic upgrade head
# Создать новую миграцию из изменений моделей
alembic revision --autogenerate -m "description"
# Посмотреть историю миграций
alembic history# Внутри Docker
docker exec ai-secretary python -c "
import asyncio
from db.database import AsyncSessionLocal, init_db
from sqlalchemy import text
async def check():
await init_db()
async with AsyncSessionLocal() as s:
for p in ['journal_mode', 'foreign_keys', 'busy_timeout', 'synchronous']:
r = (await s.execute(text(f'PRAGMA {p}'))).scalar()
print(f'{p} = {r}')
asyncio.run(check())
"Ожидаемый вывод:
journal_mode = wal
foreign_keys = 1
busy_timeout = 5000
synchronous = 1
sqlite3 data/secretary.db "PRAGMA integrity_check;"
# Ответ: oksqlite3 data/secretary.db "PRAGMA foreign_key_check;"
# Пустой вывод = нет нарушений FK28 тестов в tests/unit/:
pytest tests/unit/ -v -o asyncio_mode=auto| Тест | Что проверяет |
|---|---|
test_repo_does_not_commit |
Repo flush без commit → rollback теряет данные |
test_repo_flush_visible_in_session |
Flush делает данные видимыми в пределах сессии |
test_commit_persists_data |
Commit сохраняет данные после закрытия сессии |
test_multi_op_atomicity |
Несколько операций коммитятся/откатываются атомарно |
| Тест | Что проверяет |
|---|---|
test_main_engine_pragmas |
Все 4 PRAGMA значения после init |
test_pragmas_persist_across_sessions |
PRAGMA сохраняются между сессиями (StaticPool) |
test_foreign_keys_enforced |
INSERT с невалидным FK вызывает IntegrityError |
test_wal_checkpoint_on_close |
WAL checkpoint очищает WAL-файл |
test_event_listener_fires_once_with_static_pool |
Listener вызывается ровно 1 раз |
test_telegram_sales_db_pragmas |
PRAGMA в Telegram sales DB |
test_whatsapp_sales_db_pragmas |
PRAGMA в WhatsApp sales DB |
test_sales_db_fk_enforced |
FK enforcement в sales DB |
| Тест | Что проверяет |
|---|---|
test_checkpoint_wal_clears_wal_file |
WAL checkpoint обнуляет WAL-файл |
test_checkpoint_wal_nonexistent_db |
Checkpoint на несуществующей БД не падает |
test_backup_includes_sales_dbs |
ZIP содержит main DB + все sales DBs |
test_backup_includes_wal_sidecars |
ZIP содержит непустые WAL/SHM сайдкары |
test_backup_skips_empty_sidecars |
Пустые сайдкары не включаются |
test_manifest_version_1_1 |
Manifest version = 1.1 |
test_restore_cleans_stale_wal |
Restore удаляет stale WAL/SHM |
test_restore_handles_sales_dbs |
Restore распаковывает sales DBs |
test_restore_creates_bak_files |
Restore создаёт .bak копии |
test_get_system_info_includes_sales_dbs |
system_info содержит sales DBs |
| Тест | Что проверяет |
|---|---|
test_telegram_sales_close_checkpoints |
close() делает WAL checkpoint |
test_whatsapp_sales_close_checkpoints |
close() делает WAL checkpoint |
test_telegram_sales_default_path |
Default = data/sales.db
|
test_whatsapp_sales_default_path |
Default = data/wa_sales.db
|
test_telegram_legacy_migration |
sales.db мигрирует в data/sales.db
|
test_telegram_close_idempotent |
Двойной close() не падает |