s-ormkit 0.0.1__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. s_ormkit-0.2.1/AGENTS.md +45 -0
  2. s_ormkit-0.2.1/CHANGELOG.md +121 -0
  3. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/PKG-INFO +116 -6
  4. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/README.md +109 -4
  5. s_ormkit-0.2.1/docs/adr/0000-template.md +20 -0
  6. s_ormkit-0.2.1/docs/adr/README.md +30 -0
  7. s_ormkit-0.2.1/ormkit/__init__.py +150 -0
  8. s_ormkit-0.2.1/ormkit/__main__.py +81 -0
  9. s_ormkit-0.2.1/ormkit/async_engine.py +126 -0
  10. s_ormkit-0.2.1/ormkit/async_repository.py +129 -0
  11. s_ormkit-0.2.1/ormkit/async_unit_of_work.py +57 -0
  12. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/ormkit/engine.py +4 -10
  13. s_ormkit-0.2.1/ormkit/loading.py +169 -0
  14. s_ormkit-0.2.1/ormkit/migrations.py +271 -0
  15. s_ormkit-0.2.1/ormkit/pagination.py +131 -0
  16. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/ormkit/protocols.py +39 -0
  17. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/ormkit/repository.py +13 -2
  18. s_ormkit-0.2.1/ormkit/rls.py +146 -0
  19. s_ormkit-0.2.1/ormkit/search.py +104 -0
  20. s_ormkit-0.2.1/ormkit/url.py +94 -0
  21. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/pyproject.toml +18 -4
  22. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/tests/conftest.py +113 -66
  23. s_ormkit-0.2.1/tests/integration/test_async_infra_integration.py +42 -0
  24. s_ormkit-0.2.1/tests/integration/test_async_repository.py +94 -0
  25. s_ormkit-0.2.1/tests/integration/test_async_unit_of_work.py +65 -0
  26. s_ormkit-0.2.1/tests/integration/test_cli.py +35 -0
  27. s_ormkit-0.2.1/tests/integration/test_loading.py +156 -0
  28. s_ormkit-0.2.1/tests/integration/test_migrations.py +177 -0
  29. s_ormkit-0.2.1/tests/unit/test_async_engine.py +173 -0
  30. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/tests/unit/test_engine.py +8 -0
  31. s_ormkit-0.2.1/tests/unit/test_infra_helpers.py +143 -0
  32. s_ormkit-0.2.1/tests/unit/test_protocols.py +55 -0
  33. s_ormkit-0.2.1/tests/unit/test_public_api.py +62 -0
  34. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/tests/unit/test_schema.py +1 -1
  35. s_ormkit-0.2.1/tests/unit/test_url.py +94 -0
  36. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/uv.lock +169 -2
  37. s_ormkit-0.0.1/CHANGELOG.md +0 -36
  38. s_ormkit-0.0.1/ormkit/__init__.py +0 -56
  39. s_ormkit-0.0.1/tests/unit/test_protocols.py +0 -28
  40. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/.gitignore +0 -0
  41. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/LICENSE +0 -0
  42. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/ormkit/base.py +0 -0
  43. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/ormkit/exceptions.py +0 -0
  44. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/ormkit/unit_of_work.py +0 -0
  45. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/tests/__init__.py +0 -0
  46. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/tests/integration/__init__.py +0 -0
  47. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/tests/integration/test_foreign_keys.py +0 -0
  48. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/tests/integration/test_repository.py +0 -0
  49. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/tests/integration/test_timestamps.py +0 -0
  50. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/tests/integration/test_unit_of_work.py +0 -0
  51. {s_ormkit-0.0.1 → s_ormkit-0.2.1}/tests/unit/__init__.py +0 -0
@@ -0,0 +1,45 @@
1
+ # AGENTS.md — ormkit
2
+
3
+ > Контекст для AI-ассистентов (Claude Code, ChatGPT, Cursor и т.п.), работающих
4
+ > над этим проектом.
5
+
6
+ ## Что это
7
+
8
+ (заполнить one-line)
9
+
10
+ ## Atlas
11
+
12
+ Проект зарегистрирован в Atlas-БД (Atlas). Карточка:
13
+
14
+ ```sh
15
+ atlas project get ormkit
16
+ ```
17
+
18
+ Любые изменения метаданных (приоритет, статус, теги) — через atlas CLI:
19
+
20
+ - `atlas project update ormkit --priority P0` — поменять приоритет
21
+ - `atlas project tag add ormkit -t domain:<slug>` — добавить тег
22
+ - `atlas project move ormkit --to-type <type>` — конвертировать тип
23
+
24
+ ## Тип / Статус (на момент создания)
25
+
26
+ - type=`kit`, status=`experiment`, priority=`P2`
27
+
28
+ ## Правила работы
29
+
30
+ - Все исходные тексты, документы, код проекта — в этом репо.
31
+ - Чувствительные данные (`.env`, токены, ключи) — игнорируются `.gitignore`.
32
+ - AI-ассистенту разрешено: читать, генерировать, редактировать в этом репо.
33
+
34
+ ## Канонические команды
35
+
36
+ - `atlas project get ormkit` — карточка проекта
37
+ - `atlas task list --project ormkit` — задачи проекта (когда W7
38
+ волна будет реализована)
39
+
40
+ <!-- atlas:usage:start -->
41
+ ## Управление проектом — через Atlas
42
+
43
+ Этот проект ведётся в Atlas (личная PM-система портфеля). Для задач/проектов/эпиков/бэкапов
44
+ используй CLI `atlas` и вызывай навык `atlas` — вся логика и роутинг внутри навыка.
45
+ <!-- atlas:usage:end -->
@@ -0,0 +1,121 @@
1
+ # Changelog
2
+
3
+ Все значительные изменения этого проекта будут документироваться в этом файле.
4
+
5
+ Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ и этот проект соответствует [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.1] - 2026-08-30
9
+
10
+ ### Добавлено
11
+
12
+ - `make_async_engine(..., sqlite_pragmas=...)` — дополнительные PRAGMA на каждое
13
+ подключение sqlite (`journal_mode=WAL`, `busy_timeout`). Нужны там, где одну
14
+ базу держат несколько процессов: по умолчанию писатель блокирует читателей
15
+ целиком, и соседний процесс получает «database is locked» ровно в момент
16
+ чужой записи. Знание общее для любого потребителя sqlite — жило в каждом
17
+ приложении заново.
18
+ - `make_async_engine(..., connect_args=...)` — параметры драйвера как есть. Для
19
+ sqlite важно вместе с `busy_timeout`: `aiosqlite` ждёт очереди по своему
20
+ таймеру (5 с по умолчанию) и отказывает раньше, чем сработает PRAGMA.
21
+
22
+ Своё поведение не изменилось: `foreign_keys=True` по-прежнему включает внешние
23
+ ключи, а свои PRAGMA добавляются к нему, а не вместо.
24
+
25
+ ## [0.2.0] - 2026-08-17
26
+
27
+ Кит перестал быть «синхронным ядром с async-хвостами»: асинхронные потребители
28
+ (ChatRelay, в перспективе Skillery) садятся на него без обходных путей.
29
+
30
+ ### Added
31
+
32
+ - **Async-слой** (`ormkit.async_engine`): `make_async_engine(url|env|default, echo=,
33
+ foreign_keys=)`, `make_async_session_factory` (`async_sessionmaker`,
34
+ `expire_on_commit=False`), `init_schema_async` / `drop_schema_async` /
35
+ `dispose_engine_async`. Для sqlite `PRAGMA foreign_keys=ON` вешается событием на
36
+ `sync_engine`; `create_async_engine` остаётся ленивым (движок к недоступному
37
+ postgres создаётся без коннекта).
38
+ - **Асинхронные близнецы** с тем же API, что у синхронных: `AsyncUnitOfWork`
39
+ (`async with`, rollback при исключении, явный `commit`), `AsyncBaseRepository`
40
+ (`add / get / get_or_none / list / update / remove / count`), DIP-контракты
41
+ `AsyncRepositoryProtocol` / `AsyncUnitOfWorkProtocol`.
42
+ - **Резолв DB-URL и автоподстановка драйвера** (`ormkit.url`): `resolve_db_url`
43
+ (аргумент → env → default), `async_driver_url` (`sqlite`→`aiosqlite`,
44
+ `postgresql`→`asyncpg`, `mysql`→`aiomysql`), `sync_driver_url`
45
+ (`postgresql`→`psycopg`). Явно заданный драйвер не трогается; `postgres://`
46
+ канонизируется в `postgresql://`.
47
+ - **Миграции** (`ormkit.migrations`, extra `[migrations]`): `scaffold_alembic(...)`
48
+ генерирует в проекте-потребителе `alembic.ini` + `alembic/env.py` (async-движок,
49
+ `render_as_batch=True`, metadata по ссылке `pkg.module:attr`) + `script.py.mako`;
50
+ URL резолвится тем же способом, что и в `make_engine` (`migration_url`).
51
+ CLI: `python -m ormkit migrations init . --metadata myapp.models:Base`
52
+ (и console script `ormkit`). Без установленного alembic — понятная ошибка
53
+ `require_alembic` с подсказкой про extra.
54
+ - **Защита от N+1** (`ormkit.loading`): `LoadSpec(selectin=, joined=)` с вложенными
55
+ путями через точку + `apply_load_spec`; строгий режим на `raiseload("*")` —
56
+ `strict_loading` (один запрос), `enable_strict_loading` (сессия, с возвратом
57
+ выключателя), `make_strict_session_factory` / `make_strict_async_session_factory`
58
+ (фабрики для тестов и CI). Оба репозитория применяют `load_spec` ко всем чтениям.
59
+
60
+ ### Changed
61
+
62
+ - `make_engine` использует общий резолвер URL и подставляет синхронный драйвер
63
+ (`postgresql://` → `postgresql+psycopg://`).
64
+ - `BaseRepository` читает через внутренний `_select()` (точка расширения + место
65
+ применения `load_spec`); публичное поведение не изменилось.
66
+
67
+ ### Notes
68
+
69
+ - **Обратная совместимость сохранена**: весь синхронный API v0.1.0 на месте,
70
+ `load_spec` по умолчанию `None` (запросы такие же, как раньше).
71
+ - Alembic НЕ добавлен в обязательные зависимости: `pip install s-ormkit[migrations]`.
72
+ - Тесты постгреса не требуют живого сервера: проверяются резолв URL, ленивое
73
+ создание движка и offline-рендер миграции (`alembic upgrade head --sql`).
74
+ Реальный прогон миграций (autogenerate → upgrade → downgrade) — на sqlite+aiosqlite.
75
+
76
+ ## [0.1.0] - 2026-07-07
77
+
78
+ ### Added
79
+
80
+ - **`ormkit.pagination`** — offset-пагинация: чистые `Page[T]` / `page_to_offset`
81
+ + SQL-исполнитель `paginate_stmt(session, stmt, limit, offset, count_stmt=,
82
+ scalars=)` (limit/offset + авто-count, либо custom count для JOIN/DISTINCT).
83
+ - **`ormkit.search`** — `trigram_filter(stmt, cols, q, dialect=, threshold=)`:
84
+ fuzzy-поиск (PostgreSQL `word_similarity`/pg_trgm с exact-boost, ILIKE-fallback
85
+ для остальных диалектов); возвращает `(stmt_with_where, rank_expr)`.
86
+ - **`ormkit.rls`** — tenant Row-Level Security (defense-in-depth): `TenantContext`,
87
+ ContextVar-хелперы (`set`/`reset`/`current_tenant_context`), `rls_bypass`
88
+ контекст-менеджер, `apply_tenant_guc` (transaction-local GUC `app.rls_enforce`/
89
+ `app.rls_bypass`/`app.current_company`; no-op на не-PostgreSQL и при `ctx=None`).
90
+
91
+ Все три модуля вынесены из skills-hub (generic SQLAlchemy, 0 завязок на приложение).
92
+
93
+ ## [0.0.1] - 2026-07-04
94
+
95
+ ### Added
96
+
97
+ - **Declarative-база и миксины** (`Base`, `IntPkMixin`, `TimestampMixin`): общий
98
+ SQLAlchemy 2.0 declarative base и переиспользуемые миксины (int-PK, диалект-нейтральные
99
+ временные метки через `func.now()`)
100
+ - **Движок из DB-URL** (`make_engine`, `make_session_factory`, `init_schema`, `drop_schema`,
101
+ `dispose_engine`): резолв URL (аргумент → env `ORMKIT_DB_URL` → default), ленивое
102
+ создание движка (postgres-URL без коннекта), автоматический `PRAGMA foreign_keys=ON` для
103
+ sqlite
104
+ - **Repository-паттерн** (`BaseRepository[TModel, TDomain]`): generic CRUD
105
+ (`add / get / get_or_none / list / update / remove / count`) с маппингом ORM <-> domain
106
+ через переопределяемые хуки `to_domain` / `to_orm`
107
+ - **Транзакционная граница** (`UnitOfWork`): контекст-менеджер с rollback при исключении,
108
+ гарантированным `close` и явным `commit`
109
+ - **DIP-контракты** (`RepositoryProtocol`, `UnitOfWorkProtocol`): Protocol'ы для зависимости
110
+ прикладного слоя от абстракций, а не от `BaseRepository` / SQLAlchemy
111
+ - **Исключения** (`OrmKitError`, `NotFoundError`)
112
+ - **Тесты** (unit + integration, coverage ≥ 80%): резолв DB-URL, FK enforcement на sqlite,
113
+ round-trip CRUD, транзакции UoW, диалект-нейтральность временных меток; демо-модели
114
+ объявлены в самих тестах
115
+
116
+ ### Notes
117
+
118
+ - Первый релиз кита `s-ormkit` как generic БД/ORM-инфраструктуры для экосистемы S-kits
119
+ - 0 завязок на конкретное приложение: только чистая инфраструктура
120
+ - Зависит ТОЛЬКО от SQLAlchemy>=2.0
121
+ - Диалект-нейтрально: движок заменяется через DB-URL (sqlite → postgres → mysql) без правок кода
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: s-ormkit
3
- Version: 0.0.1
3
+ Version: 0.2.1
4
4
  Summary: Generic БД/ORM-инфраструктура на SQLAlchemy 2.0: диалект-нейтральный движок из DB-URL, Repository + UnitOfWork + DIP-протоколы. 0 завязок на конкретное приложение.
5
5
  Author: Dmitry
6
6
  License: MIT
@@ -8,10 +8,15 @@ License-File: LICENSE
8
8
  Requires-Python: >=3.11
9
9
  Requires-Dist: sqlalchemy>=2.0
10
10
  Provides-Extra: dev
11
+ Requires-Dist: aiosqlite>=0.20; extra == 'dev'
12
+ Requires-Dist: alembic>=1.13; extra == 'dev'
13
+ Requires-Dist: asyncpg>=0.29; extra == 'dev'
11
14
  Requires-Dist: psycopg[binary]>=3.1; extra == 'dev'
12
15
  Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
13
16
  Requires-Dist: pytest-cov>=6.0; extra == 'dev'
14
17
  Requires-Dist: pytest>=8; extra == 'dev'
18
+ Provides-Extra: migrations
19
+ Requires-Dist: alembic>=1.13; extra == 'migrations'
15
20
  Description-Content-Type: text/markdown
16
21
 
17
22
  # s-ormkit
@@ -19,9 +24,9 @@ Description-Content-Type: text/markdown
19
24
  Generic БД/ORM-инфраструктура для экосистемы S-kits на SQLAlchemy 2.0: диалект-нейтральный
20
25
  движок из DB-URL, Repository + UnitOfWork + DIP-протоколы.
21
26
 
22
- **Версия:** 0.0.1 (первый релиз)
27
+ **Версия:** 0.2.0
23
28
  **Лицензия:** MIT
24
- **Зависимости:** `SQLAlchemy>=2.0` (и ничего больше)
29
+ **Зависимости:** `SQLAlchemy>=2.0` (alembic опционально: `s-ormkit[migrations]`)
25
30
 
26
31
  Кит НЕ знает ни о каком приложении. Прикладная логика получает БД-слой через ORM так,
27
32
  что не зависит от sqlite/диалекта: движок конфигурируется через DB-URL и заменяется на
@@ -53,6 +58,30 @@ Generic БД/ORM-инфраструктура для экосистемы S-kits
53
58
  ### DIP-контракты (`protocols.py`)
54
59
  - `RepositoryProtocol` / `UnitOfWorkProtocol` — чтобы сервисы зависели от абстракций,
55
60
  а не от `BaseRepository` / SQLAlchemy
61
+ - `AsyncRepositoryProtocol` / `AsyncUnitOfWorkProtocol` — то же для async-слоя
62
+
63
+ ### Async-слой (`async_engine.py`, `async_repository.py`, `async_unit_of_work.py`)
64
+ - `make_async_engine` / `make_async_session_factory` — асинхронный движок и
65
+ `async_sessionmaker(expire_on_commit=False)`; для sqlite так же включается
66
+ `PRAGMA foreign_keys=ON`, `create_async_engine` так же ленив
67
+ - `init_schema_async` / `drop_schema_async` / `dispose_engine_async`
68
+ - `AsyncUnitOfWork` / `AsyncBaseRepository` — асинхронные близнецы с ТЕМ ЖЕ API
69
+
70
+ ### Резолв DB-URL (`url.py`)
71
+ - `resolve_db_url` — аргумент → переменная окружения → default (общий для sync, async
72
+ и миграций)
73
+ - `async_driver_url` / `sync_driver_url` — автоподстановка драйвера
74
+ (`postgresql://` → `postgresql+asyncpg://` или `postgresql+psycopg://`)
75
+
76
+ ### Защита от N+1 (`loading.py`)
77
+ - `LoadSpec(selectin=, joined=)` + `apply_load_spec` — декларация связей, которые
78
+ грузятся вместе с сущностью (в т.ч. вложенные пути `"posts.comments"`)
79
+ - `strict_loading` / `enable_strict_loading` / `make_strict_async_session_factory` —
80
+ строгий режим `raiseload("*")`: незадекларированная ленивая подгрузка падает
81
+
82
+ ### Миграции (`migrations.py`, extra `[migrations]`)
83
+ - `scaffold_alembic` и CLI `python -m ormkit migrations init` — генерация рабочего
84
+ alembic-окружения на async-движке кита
56
85
 
57
86
  ## Быстрый старт
58
87
 
@@ -134,17 +163,96 @@ def register_user(repo: RepositoryProtocol[UserDTO], name: str) -> UserDTO:
134
163
  # в проде — UserRepository, в тестах — in-memory фейк, реализующий тот же протокол
135
164
  ```
136
165
 
166
+ ### Асинхронное приложение
167
+
168
+ Тот же код, но на `async/await` — форма вызова другая, структура та же:
169
+
170
+ ```python
171
+ from ormkit import (
172
+ AsyncBaseRepository, AsyncUnitOfWork,
173
+ init_schema_async, make_async_engine, make_async_session_factory,
174
+ )
175
+
176
+
177
+ class UserRepository(AsyncBaseRepository[User, UserDTO]):
178
+ def to_domain(self, obj: User) -> UserDTO:
179
+ return UserDTO(id=obj.id, name=obj.name)
180
+
181
+ def to_orm(self, domain: UserDTO) -> User:
182
+ return User(name=domain.name)
183
+
184
+
185
+ # sqlite:// → sqlite+aiosqlite://, postgresql:// → postgresql+asyncpg:// (автоматически)
186
+ engine = make_async_engine("postgresql://user:pass@host/db")
187
+ await init_schema_async(engine) # в проде схему катает alembic, см. ниже
188
+ session_factory = make_async_session_factory(engine)
189
+
190
+ async with AsyncUnitOfWork(session_factory) as uow:
191
+ repo = UserRepository(uow.session, User)
192
+ saved = await repo.add(UserDTO(name="Алиса"))
193
+ await uow.commit()
194
+ ```
195
+
196
+ ### Миграции (alembic)
197
+
198
+ `create_all` годится до первого изменения схемы на живых данных — дальше нужны миграции:
199
+
200
+ ```bash
201
+ pip install "s-ormkit[migrations]"
202
+ python -m ormkit migrations init . --metadata myapp.models:Base
203
+ # дальше — штатный alembic, URL берётся из ORMKIT_DB_URL (как и у движка приложения):
204
+ alembic revision --autogenerate -m "init"
205
+ alembic upgrade head
206
+ ```
207
+
208
+ Сгенерированный `env.py` работает на async-движке кита и с `render_as_batch=True`
209
+ (без него sqlite не переживает ALTER). Ссылка на metadata (`pkg.module:attr`) —
210
+ единственное, что нужно указать.
211
+
212
+ ### Защита от N+1
213
+
214
+ Репозиторий ДЕКЛАРИРУЕТ, что грузится вместе с сущностью, а строгий режим
215
+ не даёт незаметно уехать в ленивую подгрузку:
216
+
217
+ ```python
218
+ from ormkit import LoadSpec, enable_strict_loading, make_strict_async_session_factory
219
+
220
+
221
+ class UserRepository(AsyncBaseRepository[User, UserDTO]):
222
+ load_spec = LoadSpec(selectin=("posts.comments",), joined=("profile",))
223
+ ...
224
+
225
+
226
+ # в conftest тестов / в CI: любая НЕобъявленная ленивая подгрузка → InvalidRequestError
227
+ session_factory = make_strict_async_session_factory(engine)
228
+ # либо точечно на уже открытой сессии (возвращает выключатель):
229
+ disable = enable_strict_loading(session)
230
+ ```
231
+
232
+ `selectin` — для коллекций (отдельный IN-запрос), `joined` — для «многие-к-одному»
233
+ (один JOIN). N+1 падает на этапе тестов, а не в проде.
234
+
137
235
  ## Архитектура
138
236
 
139
237
  ```
140
238
  ormkit/
141
239
  ├── __init__.py # Public API + __version__
240
+ ├── __main__.py # CLI: python -m ormkit migrations init
142
241
  ├── exceptions.py # OrmKitError, NotFoundError
143
242
  ├── base.py # Base, IntPkMixin, TimestampMixin
243
+ ├── url.py # resolve_db_url, async_driver_url, sync_driver_url
144
244
  ├── engine.py # make_engine, make_session_factory, init/drop_schema, dispose
245
+ ├── async_engine.py # make_async_engine, make_async_session_factory, *_async
145
246
  ├── repository.py # BaseRepository[TModel, TDomain]
247
+ ├── async_repository.py # AsyncBaseRepository[TModel, TDomain]
146
248
  ├── unit_of_work.py # UnitOfWork
147
- └── protocols.py # RepositoryProtocol, UnitOfWorkProtocol
249
+ ├── async_unit_of_work.py# AsyncUnitOfWork
250
+ ├── loading.py # LoadSpec, apply_load_spec, строгий режим (raiseload)
251
+ ├── migrations.py # scaffold_alembic, migration_url, require_alembic
252
+ ├── pagination.py # Page, page_to_offset, paginate_stmt
253
+ ├── search.py # trigram_filter
254
+ ├── rls.py # TenantContext, apply_tenant_guc, rls_bypass
255
+ └── protocols.py # RepositoryProtocol, UnitOfWorkProtocol (+ async)
148
256
  ```
149
257
 
150
258
  ## Тестирование
@@ -167,7 +275,9 @@ uv run ruff check . # чисто
167
275
  - **Временные метки.** `TimestampMixin` использует `func.now()`, работающий и в sqlite,
168
276
  и в postgres/mysql.
169
277
  - **Ленивое создание.** `make_engine("postgresql://...")` не коннектится — движок создаётся
170
- без живого сервера.
278
+ без живого сервера. То же верно для `make_async_engine`.
279
+ - **Драйвер подставляется сам.** `postgresql://` становится `postgresql+asyncpg://` в
280
+ async-контексте и `postgresql+psycopg://` в синхронном; явно заданный драйвер не трогается.
171
281
 
172
282
  ## Лицензия
173
283
 
@@ -3,9 +3,9 @@
3
3
  Generic БД/ORM-инфраструктура для экосистемы S-kits на SQLAlchemy 2.0: диалект-нейтральный
4
4
  движок из DB-URL, Repository + UnitOfWork + DIP-протоколы.
5
5
 
6
- **Версия:** 0.0.1 (первый релиз)
6
+ **Версия:** 0.2.0
7
7
  **Лицензия:** MIT
8
- **Зависимости:** `SQLAlchemy>=2.0` (и ничего больше)
8
+ **Зависимости:** `SQLAlchemy>=2.0` (alembic опционально: `s-ormkit[migrations]`)
9
9
 
10
10
  Кит НЕ знает ни о каком приложении. Прикладная логика получает БД-слой через ORM так,
11
11
  что не зависит от sqlite/диалекта: движок конфигурируется через DB-URL и заменяется на
@@ -37,6 +37,30 @@ Generic БД/ORM-инфраструктура для экосистемы S-kits
37
37
  ### DIP-контракты (`protocols.py`)
38
38
  - `RepositoryProtocol` / `UnitOfWorkProtocol` — чтобы сервисы зависели от абстракций,
39
39
  а не от `BaseRepository` / SQLAlchemy
40
+ - `AsyncRepositoryProtocol` / `AsyncUnitOfWorkProtocol` — то же для async-слоя
41
+
42
+ ### Async-слой (`async_engine.py`, `async_repository.py`, `async_unit_of_work.py`)
43
+ - `make_async_engine` / `make_async_session_factory` — асинхронный движок и
44
+ `async_sessionmaker(expire_on_commit=False)`; для sqlite так же включается
45
+ `PRAGMA foreign_keys=ON`, `create_async_engine` так же ленив
46
+ - `init_schema_async` / `drop_schema_async` / `dispose_engine_async`
47
+ - `AsyncUnitOfWork` / `AsyncBaseRepository` — асинхронные близнецы с ТЕМ ЖЕ API
48
+
49
+ ### Резолв DB-URL (`url.py`)
50
+ - `resolve_db_url` — аргумент → переменная окружения → default (общий для sync, async
51
+ и миграций)
52
+ - `async_driver_url` / `sync_driver_url` — автоподстановка драйвера
53
+ (`postgresql://` → `postgresql+asyncpg://` или `postgresql+psycopg://`)
54
+
55
+ ### Защита от N+1 (`loading.py`)
56
+ - `LoadSpec(selectin=, joined=)` + `apply_load_spec` — декларация связей, которые
57
+ грузятся вместе с сущностью (в т.ч. вложенные пути `"posts.comments"`)
58
+ - `strict_loading` / `enable_strict_loading` / `make_strict_async_session_factory` —
59
+ строгий режим `raiseload("*")`: незадекларированная ленивая подгрузка падает
60
+
61
+ ### Миграции (`migrations.py`, extra `[migrations]`)
62
+ - `scaffold_alembic` и CLI `python -m ormkit migrations init` — генерация рабочего
63
+ alembic-окружения на async-движке кита
40
64
 
41
65
  ## Быстрый старт
42
66
 
@@ -118,17 +142,96 @@ def register_user(repo: RepositoryProtocol[UserDTO], name: str) -> UserDTO:
118
142
  # в проде — UserRepository, в тестах — in-memory фейк, реализующий тот же протокол
119
143
  ```
120
144
 
145
+ ### Асинхронное приложение
146
+
147
+ Тот же код, но на `async/await` — форма вызова другая, структура та же:
148
+
149
+ ```python
150
+ from ormkit import (
151
+ AsyncBaseRepository, AsyncUnitOfWork,
152
+ init_schema_async, make_async_engine, make_async_session_factory,
153
+ )
154
+
155
+
156
+ class UserRepository(AsyncBaseRepository[User, UserDTO]):
157
+ def to_domain(self, obj: User) -> UserDTO:
158
+ return UserDTO(id=obj.id, name=obj.name)
159
+
160
+ def to_orm(self, domain: UserDTO) -> User:
161
+ return User(name=domain.name)
162
+
163
+
164
+ # sqlite:// → sqlite+aiosqlite://, postgresql:// → postgresql+asyncpg:// (автоматически)
165
+ engine = make_async_engine("postgresql://user:pass@host/db")
166
+ await init_schema_async(engine) # в проде схему катает alembic, см. ниже
167
+ session_factory = make_async_session_factory(engine)
168
+
169
+ async with AsyncUnitOfWork(session_factory) as uow:
170
+ repo = UserRepository(uow.session, User)
171
+ saved = await repo.add(UserDTO(name="Алиса"))
172
+ await uow.commit()
173
+ ```
174
+
175
+ ### Миграции (alembic)
176
+
177
+ `create_all` годится до первого изменения схемы на живых данных — дальше нужны миграции:
178
+
179
+ ```bash
180
+ pip install "s-ormkit[migrations]"
181
+ python -m ormkit migrations init . --metadata myapp.models:Base
182
+ # дальше — штатный alembic, URL берётся из ORMKIT_DB_URL (как и у движка приложения):
183
+ alembic revision --autogenerate -m "init"
184
+ alembic upgrade head
185
+ ```
186
+
187
+ Сгенерированный `env.py` работает на async-движке кита и с `render_as_batch=True`
188
+ (без него sqlite не переживает ALTER). Ссылка на metadata (`pkg.module:attr`) —
189
+ единственное, что нужно указать.
190
+
191
+ ### Защита от N+1
192
+
193
+ Репозиторий ДЕКЛАРИРУЕТ, что грузится вместе с сущностью, а строгий режим
194
+ не даёт незаметно уехать в ленивую подгрузку:
195
+
196
+ ```python
197
+ from ormkit import LoadSpec, enable_strict_loading, make_strict_async_session_factory
198
+
199
+
200
+ class UserRepository(AsyncBaseRepository[User, UserDTO]):
201
+ load_spec = LoadSpec(selectin=("posts.comments",), joined=("profile",))
202
+ ...
203
+
204
+
205
+ # в conftest тестов / в CI: любая НЕобъявленная ленивая подгрузка → InvalidRequestError
206
+ session_factory = make_strict_async_session_factory(engine)
207
+ # либо точечно на уже открытой сессии (возвращает выключатель):
208
+ disable = enable_strict_loading(session)
209
+ ```
210
+
211
+ `selectin` — для коллекций (отдельный IN-запрос), `joined` — для «многие-к-одному»
212
+ (один JOIN). N+1 падает на этапе тестов, а не в проде.
213
+
121
214
  ## Архитектура
122
215
 
123
216
  ```
124
217
  ormkit/
125
218
  ├── __init__.py # Public API + __version__
219
+ ├── __main__.py # CLI: python -m ormkit migrations init
126
220
  ├── exceptions.py # OrmKitError, NotFoundError
127
221
  ├── base.py # Base, IntPkMixin, TimestampMixin
222
+ ├── url.py # resolve_db_url, async_driver_url, sync_driver_url
128
223
  ├── engine.py # make_engine, make_session_factory, init/drop_schema, dispose
224
+ ├── async_engine.py # make_async_engine, make_async_session_factory, *_async
129
225
  ├── repository.py # BaseRepository[TModel, TDomain]
226
+ ├── async_repository.py # AsyncBaseRepository[TModel, TDomain]
130
227
  ├── unit_of_work.py # UnitOfWork
131
- └── protocols.py # RepositoryProtocol, UnitOfWorkProtocol
228
+ ├── async_unit_of_work.py# AsyncUnitOfWork
229
+ ├── loading.py # LoadSpec, apply_load_spec, строгий режим (raiseload)
230
+ ├── migrations.py # scaffold_alembic, migration_url, require_alembic
231
+ ├── pagination.py # Page, page_to_offset, paginate_stmt
232
+ ├── search.py # trigram_filter
233
+ ├── rls.py # TenantContext, apply_tenant_guc, rls_bypass
234
+ └── protocols.py # RepositoryProtocol, UnitOfWorkProtocol (+ async)
132
235
  ```
133
236
 
134
237
  ## Тестирование
@@ -151,7 +254,9 @@ uv run ruff check . # чисто
151
254
  - **Временные метки.** `TimestampMixin` использует `func.now()`, работающий и в sqlite,
152
255
  и в postgres/mysql.
153
256
  - **Ленивое создание.** `make_engine("postgresql://...")` не коннектится — движок создаётся
154
- без живого сервера.
257
+ без живого сервера. То же верно для `make_async_engine`.
258
+ - **Драйвер подставляется сам.** `postgresql://` становится `postgresql+asyncpg://` в
259
+ async-контексте и `postgresql+psycopg://` в синхронном; явно заданный драйвер не трогается.
155
260
 
156
261
  ## Лицензия
157
262
 
@@ -0,0 +1,20 @@
1
+ # NNNN. Краткий заголовок решения
2
+
3
+ - **Статус:** proposed | accepted | superseded (→ ADR-NNNN) | deprecated
4
+ - **Дата:** ГГГГ-ММ-ДД
5
+
6
+ ## Контекст
7
+
8
+ Какую ситуацию/проблему решаем. Что вынуждает принять решение сейчас: ограничения, требования, силы, которые тянут в разные стороны. Достаточно, чтобы понять «почему» без внешнего контекста.
9
+
10
+ ## Решение
11
+
12
+ Что именно решили — одной-двумя фразами, в настоящем времени: «Делаем X». Конкретно и проверяемо.
13
+
14
+ ## Последствия
15
+
16
+ Что становится проще и что — сложнее/дороже после принятия. Плюсы, минусы, новые обязательства и ограничения, которые теперь придётся держать.
17
+
18
+ ## Альтернативы
19
+
20
+ Какие варианты рассматривали и **почему отклонили** (в этом ценность записи). Кратко: вариант → причина отказа / отложено до условия.
@@ -0,0 +1,30 @@
1
+ # ADR — журнал архитектурных решений
2
+
3
+ **Что это.** ADR (Architecture Decision Record) — короткий документ на **одно** архитектурное решение: контекст → что решили → почему → последствия. Лежат здесь, в `docs/adr/`, со **сквозной нумерацией** (`0001-…`, `0002-…`). Шаблон — [`0000-template.md`](0000-template.md).
4
+
5
+ **Зачем.** «Почему» проекта нельзя держать в комментариях к задачам и переписке — оно теряется. ADR — дистиллят решения: агент читает его **перед** правкой и не «чинит» то, что было выбрано сознательно. Дёшев по токенам: одно решение прочитать проще, чем реконструировать его из истории коммитов.
6
+
7
+ ## Когда заводить ADR — когда сошлись 3 условия
8
+
9
+ 1. решение **дорого откатить** (влияет на структуру, данные, контракты);
10
+ 2. без контекста оно **выглядит странно** (кто-то потом захочет «починить»);
11
+ 3. были **реальные альтернативы** и выбор — осознанный трейдофф.
12
+
13
+ Если хотя бы одно условие не выполнено — ADR не нужен, хватит `comment` на задаче в Atlas.
14
+
15
+ ## Правила ведения
16
+
17
+ - **Нумерация сквозная**, файл — `NNNN-краткий-слаг.md` (`0001-operating-model-foundamentals.md`).
18
+ - **Не редактировать задним числом.** Принятое решение — исторический факт. Если решение отменено новым — старому ставим `Статус: superseded` (со ссылкой на новое ADR), а не переписываем его.
19
+ - **«Схлопывание».** Когда цепочка `superseded` разрослась, допустимо свести её к одному ADR с текущим состоянием — ради экономии контекста агента. Прежние помечаем как схлопнутые.
20
+ - **Статусы:** `proposed` → `accepted` → (`superseded` / `deprecated`).
21
+
22
+ ## Что НЕ идёт в ADR
23
+
24
+ - **состояние** (ветка/worktree, стадия) — это тег/поле задачи в Atlas;
25
+ - **ход работы / handoff** — `comment` на задаче в Atlas;
26
+ - **черновой план реализации** — scratchpad, в `.gitignore`, в репо не коммитится.
27
+
28
+ Полная модель фиксации артефактов (4 слоя) — в `ai-ecosystem-core/AGENT-OPERATING-MODEL.md`, раздел «Модель фиксации артефактов».
29
+
30
+ **Методология:** практика ADR (Michael Nygard), компактный формат Matt Pocock (`github.com/mattpocock/skills`, grill-with-docs), примеры в NATS / AG2.