xtr-orm 2.0.0__tar.gz → 3.0.0__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 (65) hide show
  1. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/PKG-INFO +14 -10
  2. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/README.md +8 -4
  3. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/pyproject.toml +14 -11
  4. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/pyproject.toml.orig +15 -11
  5. xtr_orm-3.0.0/src/xtr_orm/.agents/skills/xtr-orm/SKILL.md +282 -0
  6. xtr_orm-3.0.0/src/xtr_orm/.agents/skills/xtr-orm/references/commands.md +67 -0
  7. xtr_orm-3.0.0/src/xtr_orm/.agents/skills/xtr-orm/references/connections.md +97 -0
  8. xtr_orm-3.0.0/src/xtr_orm/.agents/skills/xtr-orm/references/messenger.md +95 -0
  9. xtr_orm-3.0.0/src/xtr_orm/.agents/skills/xtr-orm/references/migrations.md +71 -0
  10. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/bundle/orm_bundle.py +35 -26
  11. xtr_orm-3.0.0/src/xtr_orm/recipe/__init__.py +15 -0
  12. xtr_orm-3.0.0/src/xtr_orm/recipe/files/config/orm.py.tmpl +24 -0
  13. xtr_orm-3.0.0/src/xtr_orm/recipe/manifest.toml +32 -0
  14. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/LICENSE +0 -0
  15. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/__init__.py +0 -0
  16. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/bundle/__init__.py +0 -0
  17. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/bundle/connection_config.py +0 -0
  18. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/bundle/orm_config.py +0 -0
  19. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/__init__.py +0 -0
  20. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/connection_command.py +0 -0
  21. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/connections.py +0 -0
  22. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/database_create_command.py +0 -0
  23. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/database_drop_command.py +0 -0
  24. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_current_command.py +0 -0
  25. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_diff_command.py +0 -0
  26. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_dump_schema_command.py +0 -0
  27. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_execute_command.py +0 -0
  28. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_generate_command.py +0 -0
  29. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_latest_command.py +0 -0
  30. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_list_command.py +0 -0
  31. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_migrate_command.py +0 -0
  32. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_rollup_command.py +0 -0
  33. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_status_command.py +0 -0
  34. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_up_to_date_command.py +0 -0
  35. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_version_command.py +0 -0
  36. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/run_sql_command.py +0 -0
  37. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/connection_registry.py +0 -0
  38. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/database/__init__.py +0 -0
  39. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/database/database_manager.py +0 -0
  40. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/__init__.py +0 -0
  41. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/invalid_argument_error.py +0 -0
  42. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/migration_error.py +0 -0
  43. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/orm_error.py +0 -0
  44. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/session_unavailable_error.py +0 -0
  45. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/unknown_connection_error.py +0 -0
  46. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/unsupported_database_error.py +0 -0
  47. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/messenger/__init__.py +0 -0
  48. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/messenger/close_connection_middleware.py +0 -0
  49. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/messenger/open_transaction_logger_middleware.py +0 -0
  50. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/messenger/transaction_middleware.py +0 -0
  51. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/__init__.py +0 -0
  52. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/_environment.py +0 -0
  53. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/_history.py +0 -0
  54. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/_revisions.py +0 -0
  55. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/_schema.py +0 -0
  56. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/available_migration.py +0 -0
  57. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/direction.py +0 -0
  58. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/executed_migration.py +0 -0
  59. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/execution_result.py +0 -0
  60. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/migration_plan.py +0 -0
  61. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/migration_status.py +0 -0
  62. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/migrations_config.py +0 -0
  63. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/migrator.py +0 -0
  64. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/template/script.py.mako +0 -0
  65. {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xtr-orm
3
- Version: 2.0.0
3
+ Version: 3.0.0
4
4
  Summary: Databases for async applications: engines, sessions and versioned schema migrations, configured once per connection.
5
5
  Keywords: orm,database,migrations,sqlalchemy,alembic,asyncio
6
6
  Author: Xterr
@@ -20,11 +20,11 @@ Requires-Dist: advanced-alchemy>=1.11
20
20
  Requires-Dist: alembic>=1.20
21
21
  Requires-Dist: sqlalchemy[asyncio]>=2.0.41
22
22
  Requires-Dist: typing-extensions>=4.4
23
- Requires-Dist: xtr-logging-contracts>=2.0,<3
24
- Requires-Dist: xtr-console>=2.0,<3 ; extra == 'console'
25
- Requires-Dist: xtr-dependency-injection>=2.0,<3 ; extra == 'di'
26
- Requires-Dist: xtr-service-contracts>=2.0,<3 ; extra == 'di'
27
- Requires-Dist: xtr-messenger>=2.0,<3 ; extra == 'messenger'
23
+ Requires-Dist: xtr-logging-contracts>=3.0,<4
24
+ Requires-Dist: xtr-console>=3.0,<4 ; extra == 'console'
25
+ Requires-Dist: xtr-dependency-injection>=3.0,<4 ; extra == 'di'
26
+ Requires-Dist: xtr-service-contracts>=3.0,<4 ; extra == 'di'
27
+ Requires-Dist: xtr-messenger>=3.0,<4 ; extra == 'messenger'
28
28
  Requires-Dist: asyncmy>=0.2.9 ; extra == 'mysql'
29
29
  Requires-Dist: asyncpg>=0.29 ; extra == 'postgres'
30
30
  Requires-Dist: aiosqlite>=0.20 ; extra == 'sqlite'
@@ -202,15 +202,19 @@ removing it undoes.
202
202
  - **Install** — `uv add "xtr-orm[di,console,postgres]"`; `mysql` or `sqlite` instead of
203
203
  `postgres` for those databases; drop `console` to go without the commands; add `messenger`
204
204
  for the [message bus middleware](#message-bus-middleware).
205
+ - **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate*, *Configure* and
206
+ *Environment* steps below: it lists `OrmBundle`, writes a starting `<app>/config/orm.py` with the
207
+ `default` connection, and `DATABASE_URL` in `.env`. It prints the middleware step, which a recipe
208
+ cannot make for you.
205
209
  - **Activate** — `OrmBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported from
206
210
  `xtr_orm.bundle`.
207
211
  - **Brings along** — the logging and console bundles, when those packages are installed.
208
212
  With the messenger bundle active too, the [middleware](#message-bus-middleware) are
209
213
  registered for a bus to list.
210
- - **Configure** — optional: with no configuration there is one connection, `default`, reading
211
- `DATABASE_URL`, with its revisions in `migrations` in the project directory and diffs
212
- against the models advanced-alchemy registers. Connections, replicas and table definitions go
213
- in `<app>/config/orm.py`, a `@configure` function returning `OrmConfig` — see
214
+ - **Configure** — the recipe writes a starting `<app>/config/orm.py` with the `default`
215
+ connection reading `DATABASE_URL`, its revisions in `migrations` in the project directory and
216
+ diffs against the models advanced-alchemy registers. Add connections, replicas and table
217
+ definitions to that `@configure` function returning `OrmConfig` — see
214
218
  [Kernel / bundle](#kernel--bundle).
215
219
  - **Environment** — `DATABASE_URL`, read when a connection is first used — engine, session,
216
220
  migrator or command — not when the application boots. Its query may carry options; see
@@ -163,15 +163,19 @@ removing it undoes.
163
163
  - **Install** — `uv add "xtr-orm[di,console,postgres]"`; `mysql` or `sqlite` instead of
164
164
  `postgres` for those databases; drop `console` to go without the commands; add `messenger`
165
165
  for the [message bus middleware](#message-bus-middleware).
166
+ - **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate*, *Configure* and
167
+ *Environment* steps below: it lists `OrmBundle`, writes a starting `<app>/config/orm.py` with the
168
+ `default` connection, and `DATABASE_URL` in `.env`. It prints the middleware step, which a recipe
169
+ cannot make for you.
166
170
  - **Activate** — `OrmBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported from
167
171
  `xtr_orm.bundle`.
168
172
  - **Brings along** — the logging and console bundles, when those packages are installed.
169
173
  With the messenger bundle active too, the [middleware](#message-bus-middleware) are
170
174
  registered for a bus to list.
171
- - **Configure** — optional: with no configuration there is one connection, `default`, reading
172
- `DATABASE_URL`, with its revisions in `migrations` in the project directory and diffs
173
- against the models advanced-alchemy registers. Connections, replicas and table definitions go
174
- in `<app>/config/orm.py`, a `@configure` function returning `OrmConfig` — see
175
+ - **Configure** — the recipe writes a starting `<app>/config/orm.py` with the `default`
176
+ connection reading `DATABASE_URL`, its revisions in `migrations` in the project directory and
177
+ diffs against the models advanced-alchemy registers. Add connections, replicas and table
178
+ definitions to that `@configure` function returning `OrmConfig` — see
175
179
  [Kernel / bundle](#kernel--bundle).
176
180
  - **Environment** — `DATABASE_URL`, read when a connection is first used — engine, session,
177
181
  migrator or command — not when the application boots. Its query may carry options; see
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xtr-orm"
3
- version = "2.0.0"
3
+ version = "3.0.0"
4
4
  description = "Databases for async applications: engines, sessions and versioned schema migrations, configured once per connection."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -30,7 +30,7 @@ dependencies = [
30
30
  "alembic>=1.20",
31
31
  "sqlalchemy[asyncio]>=2.0.41",
32
32
  "typing-extensions>=4.4",
33
- "xtr-logging-contracts>=2.0,<3",
33
+ "xtr-logging-contracts>=3.0,<4",
34
34
  ]
35
35
 
36
36
  [[project.authors]]
@@ -42,15 +42,18 @@ postgres = ["asyncpg>=0.29"]
42
42
  sqlite = ["aiosqlite>=0.20"]
43
43
  mysql = ["asyncmy>=0.2.9"]
44
44
  di = [
45
- "xtr-dependency-injection>=2.0,<3",
46
- "xtr-service-contracts>=2.0,<3",
45
+ "xtr-dependency-injection>=3.0,<4",
46
+ "xtr-service-contracts>=3.0,<4",
47
47
  ]
48
- console = ["xtr-console>=2.0,<3"]
49
- messenger = ["xtr-messenger>=2.0,<3"]
48
+ console = ["xtr-console>=3.0,<4"]
49
+ messenger = ["xtr-messenger>=3.0,<4"]
50
50
 
51
51
  [project.entry-points."xtr_dependency_injection.bundles"]
52
52
  orm = "xtr_orm.bundle:OrmBundle"
53
53
 
54
+ [project.entry-points.xtr_recipes]
55
+ orm = "xtr_orm.recipe"
56
+
54
57
  [dependency-groups]
55
58
  dev = [
56
59
  "basedpyright>=1.21",
@@ -60,11 +63,11 @@ dev = [
60
63
  "anyio>=4.0",
61
64
  "ty>=0.0.83",
62
65
  "aiosqlite>=0.20",
63
- "xtr-console>=2.0,<3",
64
- "xtr-dependency-injection>=2.0,<3",
65
- "xtr-logging>=2.0,<3",
66
- "xtr-messenger>=2.0,<3",
67
- "xtr-service-contracts>=2.0,<3",
66
+ "xtr-console>=3.0,<4",
67
+ "xtr-dependency-injection>=3.0,<4",
68
+ "xtr-logging>=3.0,<4",
69
+ "xtr-messenger>=3.0,<4",
70
+ "xtr-service-contracts>=3.0,<4",
68
71
  ]
69
72
 
70
73
  [build-system]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xtr-orm"
3
- version = "2.0.0"
3
+ version = "3.0.0"
4
4
  description = "Databases for async applications: engines, sessions and versioned schema migrations, configured once per connection."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -26,7 +26,7 @@ dependencies = [
26
26
  "alembic>=1.20",
27
27
  "sqlalchemy[asyncio]>=2.0.41",
28
28
  "typing-extensions>=4.4",
29
- "xtr-logging-contracts>=2.0,<3",
29
+ "xtr-logging-contracts>=3.0,<4",
30
30
  ]
31
31
 
32
32
  [project.optional-dependencies]
@@ -40,14 +40,14 @@ mysql = [
40
40
  "asyncmy>=0.2.9",
41
41
  ]
42
42
  di = [
43
- "xtr-dependency-injection>=2.0,<3",
44
- "xtr-service-contracts>=2.0,<3",
43
+ "xtr-dependency-injection>=3.0,<4",
44
+ "xtr-service-contracts>=3.0,<4",
45
45
  ]
46
46
  console = [
47
- "xtr-console>=2.0,<3",
47
+ "xtr-console>=3.0,<4",
48
48
  ]
49
49
  messenger = [
50
- "xtr-messenger>=2.0,<3",
50
+ "xtr-messenger>=3.0,<4",
51
51
  ]
52
52
 
53
53
  # Advertises the bundle so debug:bundles can name it when installed but not listed.
@@ -55,6 +55,10 @@ messenger = [
55
55
  [project.entry-points."xtr_dependency_injection.bundles"]
56
56
  orm = "xtr_orm.bundle:OrmBundle"
57
57
 
58
+ # Advertises the recipe — what *Use in an application* takes — for recipes:sync.
59
+ [project.entry-points."xtr_recipes"]
60
+ orm = "xtr_orm.recipe"
61
+
58
62
  [dependency-groups]
59
63
  dev = [
60
64
  "basedpyright>=1.21",
@@ -65,11 +69,11 @@ dev = [
65
69
  "ty>=0.0.83",
66
70
  # Every test runs on SQLite files under the test's own directory: no server.
67
71
  "aiosqlite>=0.20",
68
- "xtr-console>=2.0,<3",
69
- "xtr-dependency-injection>=2.0,<3",
70
- "xtr-logging>=2.0,<3",
71
- "xtr-messenger>=2.0,<3",
72
- "xtr-service-contracts>=2.0,<3",
72
+ "xtr-console>=3.0,<4",
73
+ "xtr-dependency-injection>=3.0,<4",
74
+ "xtr-logging>=3.0,<4",
75
+ "xtr-messenger>=3.0,<4",
76
+ "xtr-service-contracts>=3.0,<4",
73
77
  ]
74
78
 
75
79
  [build-system]
@@ -0,0 +1,282 @@
1
+ ---
2
+ name: xtr-orm
3
+ description: How to configure database connections, inject async sessions and run versioned schema migrations with xtr-orm. Use when application code needs an AsyncEngine, an AsyncSession, a session per request or per message, read replicas, a DATABASE_URL, a new migration revision, a schema diff against models, the orm:migrations:* or orm:database:* console commands, one transaction per message on a bus, or when adding OrmBundle to an application on xtr-dependency-injection.
4
+ ---
5
+
6
+ # xtr-orm
7
+
8
+ A connection is configuration. You name each database once — URL, replicas, engine and session
9
+ options, where its revisions live — and the bundle hands out the engine, the session maker, the
10
+ session of the current unit of work, a `Migrator` and a `DatabaseManager`, each qualified by the
11
+ connection's name. Everything is async; there is no sync counterpart.
12
+
13
+ ## Quick reference
14
+
15
+ - Inject `Injected[AsyncSession]` for the default connection;
16
+ `Annotated[AsyncSession, Target("reports")]` for another. Never build a session yourself.
17
+ - Name databases in `<app>/config/orm.py` with a `@configure` function returning `OrmConfig`.
18
+ - Set `DATABASE_URL` with an async driver: `postgresql+asyncpg://`, `mysql+asyncmy://`,
19
+ `sqlite+aiosqlite:///`. Engine and session options may ride on its query.
20
+ - Write a revision from your models: `orm:migrations:diff --message "add invoices"`. Apply:
21
+ `orm:migrations:migrate`. Read it: `orm:migrations:status`.
22
+ - Without a container: `Migrator(engine, MigrationsConfig(directory="migrations"), Base.metadata)`.
23
+ - One transaction per message: list `orm_transaction` in the bus's `middleware`; handlers never
24
+ commit.
25
+ - On SQLite, set `MigrationsConfig(render_as_batch=True)` or table alterations cannot be written.
26
+
27
+ ## Inject a session
28
+
29
+ ```python
30
+ from typing import Annotated
31
+
32
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
33
+ from xtr_dependency_injection import Injected, Target, as_service
34
+
35
+
36
+ @as_service
37
+ class InvoiceRepository:
38
+ def __init__(self, session: Injected[AsyncSession]) -> None:
39
+ self._session = session
40
+
41
+
42
+ async def report(
43
+ session: Annotated[AsyncSession, Target("reports")],
44
+ engine: Annotated[AsyncEngine, Target("reports")],
45
+ ) -> None: ...
46
+ ```
47
+
48
+ Per connection, qualified by name — and unqualified for the default one:
49
+
50
+ | Service | What it is |
51
+ |---|---|
52
+ | `AsyncSession` | The session of the current unit of work: a request, a message, a command. Closed, and rolled back when not committed, as the unit ends |
53
+ | `AsyncEngine` | The engine on the primary database. Disposed when the container closes |
54
+ | `async_sessionmaker[AsyncSession]` | The session maker; `RoutingAsyncSessionMaker` when the connection has replicas |
55
+ | `SQLAlchemyAsyncConfig` | advanced-alchemy's configuration, for its repositories and services |
56
+ | `Migrator` / `DatabaseManager` | The connection's revisions / its database, both on the primary |
57
+ | `ConnectionRegistry` | Every connection by name, unqualified — what the commands use |
58
+
59
+ A session is scoped to the unit of work: asking for one outside any raises
60
+ `SessionUnavailableError`. Do not cache a session on a singleton.
61
+
62
+ ## Migrations
63
+
64
+ `Migrator` is the whole surface. Every method opens its own connection; `generate` reads files
65
+ only.
66
+
67
+ | Call | Does |
68
+ |---|---|
69
+ | `await migrator.status()` | `current`, `latest`, `previous`, `next`, `available`, `executed`, `new`, `executed_unavailable`, `is_up_to_date` |
70
+ | `await migrator.migrate(target)` | Runs every revision to `target`; returns `ExecutionResult`s. `plan(target)` plans it, `migrate_sql(target)` writes the SQL |
71
+ | `await migrator.execute(versions, Direction.UP)` | Exactly those revisions, nothing before or after. `plan_for_versions(versions, direction)` and `execute_sql(...)` alongside |
72
+ | `migrator.generate("message")` | Writes an empty revision after the latest (not awaited) |
73
+ | `await migrator.diff("message")` | Writes what the table definitions add; takes `allow_empty=`, `from_empty_schema=` |
74
+ | `await migrator.dump_schema("message", table_filters=...)` | A first revision creating every table an existing database has |
75
+ | `await migrator.add_versions(v)` / `add_all_versions()` | Records revisions as applied, running nothing |
76
+ | `await migrator.delete_versions(v)` / `delete_all_versions()` | Records them as not applied |
77
+ | `await migrator.rollup()` | Forgets every applied revision, then records the single remaining one |
78
+
79
+ A **target** is a revision id (full or partial), a relative one (`+1`, `-2`, `ae10+1`), or
80
+ `latest`, `first`, `next`, `prev`, `current`. A revision already applied is migrated *down* to.
81
+
82
+ Two tables, in one schema: `alembic_version` holds where the database **is** and decides what a
83
+ migration runs; `alembic_version_history` holds a row per revision ever applied, with
84
+ `executed_at` and `execution_time` in milliseconds.
85
+
86
+ `execute` is refused before touching the database unless the version table stays truthful: going
87
+ up a revision must not be applied and must follow only applied ones; going down it must be applied
88
+ and followed by none that is. **Up to date** compares the database's versions with the revision
89
+ files, not its schema with your models; `diff` does the latter.
90
+
91
+ Without a container:
92
+
93
+ ```python
94
+ from sqlalchemy.ext.asyncio import create_async_engine
95
+ from xtr_orm import MigrationsConfig, Migrator
96
+
97
+ from app.models import Base
98
+
99
+ engine = create_async_engine("sqlite+aiosqlite:///shop.db")
100
+ migrator = Migrator(
101
+ engine,
102
+ MigrationsConfig(directory="migrations", render_as_batch=True),
103
+ Base.metadata,
104
+ )
105
+
106
+ revision = await migrator.diff("add invoices") # AvailableMigration, or None with nothing to do
107
+ await migrator.migrate()
108
+ ```
109
+
110
+ `MigrationsConfig` fields you reach for most: `directory` (relative to the working directory —
111
+ under a kernel start it with `%kernel.project_dir%`), `render_as_batch` (SQLite),
112
+ `compare_type` / `compare_server_default` (off by default), `version_table` / `history_table`,
113
+ `file_template`, `post_write_hooks` (a formatter), `transaction_per_migration`,
114
+ `template_directory`, `user_module_prefix`. See [references/migrations.md](references/migrations.md)
115
+ for every field and for writing your own `script.py.mako`.
116
+
117
+ ## Console commands
118
+
119
+ Needs the `console` extra. Every command takes `--connection NAME`, defaulting to
120
+ `OrmConfig.default_connection`. The ones that change the database ask first; `-n` answers yes.
121
+
122
+ | Command | Does |
123
+ |---|---|
124
+ | `orm:migrations:diff [--message M]` | Writes what the models add; the database must be up to date first |
125
+ | `orm:migrations:migrate [VERSION]` | Runs every revision to `VERSION`, `latest` by default |
126
+ | `orm:migrations:status` / `:list` / `:current` / `:latest` | Where the database stands |
127
+ | `orm:migrations:up-to-date` | Exits non-zero when revisions are not applied — for a deploy check |
128
+ | `orm:database:create` / `:drop --force` | The database itself |
129
+ | `orm:run-sql SQL` | Runs one statement, committed |
130
+
131
+ Nine more, and every flag: [references/commands.md](references/commands.md).
132
+
133
+ Deploy order: `orm:migrations:migrate` from **one** place, before the application starts —
134
+ migrations take no lock.
135
+
136
+ Without a container, hand the commands their connections once:
137
+
138
+ ```python
139
+ from xtr_orm import ConnectionRegistry, DatabaseManager
140
+ from xtr_orm.command import use_connections
141
+
142
+ registry = ConnectionRegistry()
143
+ registry.register("default", engine=engine, migrator=migrator, database=DatabaseManager(url))
144
+ use_connections(registry)
145
+ ```
146
+
147
+ ## Use in an application
148
+
149
+ `uv run xtr-recipes recipes:sync` applies the recipe shipped with this package: it lists `OrmBundle`,
150
+ writes a starting `config/orm.py` with the `default` connection, and `DATABASE_URL` in `.env`. That
151
+ is the steps below a recipe can do; the middleware it prints for you to add.
152
+
153
+ 1. **Install** — `uv add "xtr-orm[di,console,postgres]"`; `mysql` or `sqlite` instead of
154
+ `postgres`; drop `console` to go without the commands; add `messenger` for the bus middleware.
155
+ 2. **Activate** — `OrmBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`
156
+ (`from xtr_orm.bundle import OrmBundle`).
157
+ 3. **Brings along** — the logging and console bundles when those packages are installed, each
158
+ optional. With the logging bundle active an `orm` channel is added and every migrator logs the
159
+ revisions it runs there.
160
+ 4. **Configure** — optional. With no configuration there is one connection, `default`, reading
161
+ `DATABASE_URL`, revisions in `migrations` in the project directory, diffing against the models
162
+ advanced-alchemy registers.
163
+
164
+ ```python
165
+ # <app>/config/orm.py
166
+ from xtr_dependency_injection import configure, env
167
+ from xtr_orm.bundle import ConnectionConfig, MigrationsConfig, OrmConfig
168
+
169
+ from app.models import Base
170
+
171
+
172
+ @configure
173
+ def orm() -> OrmConfig:
174
+ return OrmConfig(
175
+ connections={
176
+ "default": ConnectionConfig(
177
+ url=env("DATABASE_URL"),
178
+ replicas={"replica1": env("DATABASE_REPLICA_URL")},
179
+ engine_options={"pool_size": 10, "pool_pre_ping": True},
180
+ session_options={"expire_on_commit": False},
181
+ metadata=Base.metadata,
182
+ migrations=MigrationsConfig(directory="%kernel.project_dir%/migrations"),
183
+ ),
184
+ "reports": ConnectionConfig(url=env("REPORTS_URL"), bind_key="reports"),
185
+ },
186
+ )
187
+ ```
188
+
189
+ 5. **Environment** — `DATABASE_URL`, read when a connection is first used (engine, session,
190
+ migrator or command), not at boot. Its query may carry options — see
191
+ [references/connections.md](references/connections.md).
192
+ 6. **Use** — inject `AsyncSession`; run `orm:migrations:*` from the console.
193
+ 7. **Ignore** — nothing: commit `migrations/`.
194
+ 8. **Check** — `debug:bundles` shows `orm` as `listed` and `active`, and
195
+ `orm:migrations:status` reaches the database.
196
+ 9. **Remove** — take the `orm_*` entries out of every bus's `middleware`, drop the `BUNDLES`
197
+ entry, delete `<app>/config/orm.py`, then `uv remove xtr-orm`.
198
+
199
+ `OrmConfig` has two fields: `default_connection` (`"default"`) and `connections`
200
+ (`{"default": ConnectionConfig()}`). Two connections may not keep their revisions in one
201
+ directory; a non-default connection defaults to `%kernel.project_dir%/migrations/<name>`. Every
202
+ `ConnectionConfig` field, replicas and URL options: [references/connections.md](references/connections.md).
203
+
204
+ ## One transaction per message
205
+
206
+ With the `messenger` extra and both bundles active, three middleware are registered by name.
207
+ Nothing is added to a bus by itself — the bus lists what it wants, and order matters.
208
+
209
+ ```python
210
+ # <app>/config/messenger.py
211
+ MessageBusConfig(
212
+ middleware=[
213
+ "orm_close_connection", # outermost: closes after the transaction ended
214
+ "orm_transaction",
215
+ {"orm_transaction": {"connection_name": "reports"}},
216
+ "orm_open_transaction_logger", # inside orm_transaction
217
+ ],
218
+ )
219
+ ```
220
+
221
+ | Name | Class | Arguments |
222
+ |---|---|---|
223
+ | `orm_transaction` | `TransactionMiddleware` | `connection_name`, the default connection when left out |
224
+ | `orm_close_connection` | `CloseConnectionMiddleware` | `connection_names`, every connection in use when left out |
225
+ | `orm_open_transaction_logger` | `OpenTransactionLoggerMiddleware` | `connection_names`, as above |
226
+
227
+ A handler asks for `Injected[AsyncSession]` like any service and adds rows; it never commits.
228
+
229
+ - Every handler of a message shares one session per connection. A message dispatched while
230
+ another is handled joins its unit of work, in a savepoint of its own.
231
+ - A follow-up dispatched to a transport goes out at once unless stamped:
232
+ `await bus.dispatch(SendReceipt(order.id), DispatchAfterCurrentBusStamp())` sends it only after
233
+ the commit, and never after a rollback.
234
+ - Only a message a worker consumed closes connections afterwards; `sync://` closes nothing.
235
+ - Find a dropped connection with the engine option `pool_pre_ping`, not a middleware.
236
+
237
+ More, including building the middleware without a container:
238
+ [references/messenger.md](references/messenger.md).
239
+
240
+ ## Creating and dropping a database
241
+
242
+ ```python
243
+ from xtr_orm import DatabaseManager
244
+
245
+ database = DatabaseManager("postgresql+asyncpg://app:secret@db/shop")
246
+ await database.exists()
247
+ await database.create(if_not_exists=True) # True when it created one
248
+ await database.drop(if_exists=True)
249
+ database.backend, database.database, database.safe_url # properties
250
+ ```
251
+
252
+ The server is reached through its maintenance database — `postgres`, `master`, none on MySQL and
253
+ MariaDB — carrying every connection parameter of the URL but the database name. A SQLite database
254
+ is its file. Any other server raises `UnsupportedDatabaseError`.
255
+
256
+ ## Errors
257
+
258
+ Every error derives from `OrmError`.
259
+
260
+ | Error | Raised when |
261
+ |---|---|
262
+ | `InvalidArgumentError` | A configuration cannot be used: an empty name, an unknown engine/session option, a URL option that does not read. Also a `ValueError` |
263
+ | `MigrationError` | A migration is refused or revisions cannot be read: an unknown or ambiguous version, a revision out of order, a diff of a database behind its revisions |
264
+ | `UnknownConnectionError` | A connection asked for by a name nothing registered. Also a `LookupError` |
265
+ | `UnsupportedDatabaseError` | A database created or dropped on a server whose statements are not known |
266
+ | `SessionUnavailableError` | A session asked for outside a unit of work, or of a connection registered without one |
267
+
268
+ ## Do not
269
+
270
+ - Do not use a sync driver. `postgresql://` fails; write `postgresql+asyncpg://`.
271
+ - Do not commit in a handler under `orm_transaction`, and do not wrap one in
272
+ `async with session.begin()` — the transaction is already open and it is refused.
273
+ - Do not build engines or sessions by hand in application code, and do not hold a session past its
274
+ unit of work or store one on a singleton.
275
+ - Do not write an `env.py` or an `alembic.ini`; the package runs the revisions itself.
276
+ - Do not expect `up-to-date` to notice a model change — run `diff` — and do not run `diff` against
277
+ a database behind its revisions; migrate first.
278
+ - Do not leave `render_as_batch` off on SQLite.
279
+ - Do not run `orm:migrations:migrate` from several processes at once; it takes no lock.
280
+ - Do not rely on advanced-alchemy's process-wide model registry when two kernels share one
281
+ process: give each connection its `metadata`.
282
+ - Do not expect a timezone on `executed_at` outside PostgreSQL; it is naive elsewhere.
@@ -0,0 +1,67 @@
1
+ # The `orm:*` console commands
2
+
3
+ Needs the `console` extra: `uv add "xtr-orm[console]"`. With a container, the bundle declares them
4
+ when the console bundle is active, and each acts on the connections the bundle registered.
5
+
6
+ Every command takes `--connection NAME`; the default connection is used without it. The ones that
7
+ change the database ask first, and `-n` answers yes. `--message` names the revision and its file.
8
+
9
+ ## The database
10
+
11
+ | Command | Flags |
12
+ |---|---|
13
+ | `orm:database:create` | `--if-not-exists` — do nothing, successfully, when it exists |
14
+ | `orm:database:drop` | `--force` / `-f` — required, the confirmation that everything may be lost; `--if-exists` |
15
+ | `orm:run-sql SQL` | `--force-fetch` — show rows even when the statement does not look like it returns any. The statement is committed |
16
+
17
+ ## Reading where the database stands
18
+
19
+ | Command | Shows |
20
+ |---|---|
21
+ | `orm:migrations:current` | The revisions the version table holds; `base` for none |
22
+ | `orm:migrations:latest` | The revisions no other follows |
23
+ | `orm:migrations:list` | Every revision with its status, when it ran and how long it took |
24
+ | `orm:migrations:status` | The two tables, the database, previous / current / next / latest, and the counts |
25
+ | `orm:migrations:up-to-date` | Exits 1 with revisions not applied. `--fail-on-unregistered` / `-u` exits 2 for applied revisions whose file is gone; `--list-migrations` / `-l` names them |
26
+
27
+ `up-to-date` is the deploy check: it compares the database's versions with the revision files, not
28
+ its schema with your models.
29
+
30
+ ## Running revisions
31
+
32
+ | Command | Flags |
33
+ |---|---|
34
+ | `orm:migrations:migrate [VERSION]` | `VERSION` defaults to `latest`; also a full or partial id, a relative one (`+1`, `-2`), `first`, `next`, `prev`, `current`. `--dry-run` reports the plan; `--write-sql PATH` writes the SQL instead of running it; `--allow-no-migration` succeeds when there is nothing to run |
35
+ | `orm:migrations:execute VERSION...` | `--up` (the default) or `--down`; `--dry-run`; `--write-sql PATH`. Runs exactly those revisions and nothing before or after. Going up each must not be applied and must follow only applied ones; going down each must be applied and be followed by none that is |
36
+
37
+ `--write-sql` writes to the file named, or to a dated file in the directory named.
38
+
39
+ ## Writing revisions
40
+
41
+ | Command | Flags |
42
+ |---|---|
43
+ | `orm:migrations:generate` | `--message M`. An empty revision after the latest, to fill in by hand |
44
+ | `orm:migrations:diff` | `--message M`; `--allow-empty-diff` writes a revision even when nothing differs; `--from-empty-schema` writes every table as if the database were empty. The database must be at the latest revisions first |
45
+ | `orm:migrations:dump-schema` | `--message M`; `--filter-tables RE`, repeatable, dumps only the tables whose name the expression finds. For a database that predates its revisions, so there must be none yet. Written, not applied — follow with `orm:migrations:rollup` |
46
+
47
+ ## Recording without running
48
+
49
+ | Command | Flags |
50
+ |---|---|
51
+ | `orm:migrations:version [VERSION]` | Exactly one of `--add` / `--delete`; `--all` for every revision instead of one. Adding, the revision must follow only applied ones; deleting, no applied revision may follow it. An applied revision whose file is gone can always be deleted |
52
+ | `orm:migrations:rollup` | Forgets every applied revision, then records the single remaining one as all there is |
53
+
54
+ ## Without a container
55
+
56
+ ```python
57
+ from xtr_orm import ConnectionRegistry, DatabaseManager
58
+ from xtr_orm.command import use_connections
59
+
60
+ registry = ConnectionRegistry()
61
+ registry.register("default", engine=engine, migrator=migrator, database=DatabaseManager(url))
62
+ use_connections(registry)
63
+ ```
64
+
65
+ The command classes are exported from `xtr_orm.command` — `MigrationsMigrateCommand`,
66
+ `MigrationsDiffCommand`, `DatabaseCreateCommand` and the rest — for a console application to
67
+ register itself.
@@ -0,0 +1,97 @@
1
+ # ConnectionConfig, URL options and read replicas
2
+
3
+ `from xtr_orm.bundle import ConnectionConfig`. A frozen dataclass, buildable with no arguments.
4
+
5
+ | Field | Default | Meaning |
6
+ |---|---|---|
7
+ | `url` | `"%env(DATABASE_URL)%"` | The database URL, with an async driver. Its query may carry options — see below |
8
+ | `engine_options` | `{}` | `echo`, `pool_size`, `max_overflow`, `pool_pre_ping`, `pool_recycle`, `isolation_level`, `connect_args`, `execution_options`, … |
9
+ | `session_options` | `{}` | `expire_on_commit`, `autoflush`, `info`, … |
10
+ | `alchemy_options` | `{}` | Any other option of advanced-alchemy's configuration, such as `enable_touch_updated_timestamp_listener` |
11
+ | `replicas` | `{}` | Read replicas, each a URL by name |
12
+ | `keep_replica` | `False` | Go back to the replicas once a session commits |
13
+ | `bind_key` | `None` | Which table definitions belong to this database when models declare several |
14
+ | `metadata` | `None` | The table definitions a diff compares the database with; those registered for `bind_key` when `None` |
15
+ | `migrations` | `None` | A `MigrationsConfig`; `%kernel.project_dir%/migrations` for the default connection, `.../migrations/<name>` for any other, when `None` |
16
+
17
+ An unknown key in `engine_options`, `session_options` or `alchemy_options` raises
18
+ `InvalidArgumentError` at configuration time, naming the options that exist. Options the bundle
19
+ sets itself — `connection_string`, `engine_config`, `session_config`, `bind_key`, `metadata`,
20
+ `engine_instance`, `session_maker`, `alembic_config`, `create_engine_callable`,
21
+ `session_maker_class`, `routing_config` — are refused in `alchemy_options`.
22
+
23
+ An empty `url`, an empty replica name or an empty replica URL is refused too. The default
24
+ `"%env(DATABASE_URL)%"` is a placeholder resolved when the connection is first used, so a missing
25
+ variable fails that use, not the boot.
26
+
27
+ ## Options in the URL
28
+
29
+ One environment variable can configure a whole connection. An engine option, a session option or
30
+ `keep_replica` is taken out of the query and merged over what the configuration set — **the URL
31
+ wins**. Every other query parameter stays on the URL for the driver.
32
+
33
+ ```sh
34
+ DATABASE_URL="postgresql+asyncpg://app:secret@db/shop?pool_size=20&expire_on_commit=false&ssl=require"
35
+ ```
36
+
37
+ | Read from the URL | Options |
38
+ |---|---|
39
+ | Engine | `echo`, `echo_pool`, `enable_from_linting`, `hide_parameters`, `insertmanyvalues_page_size`, `isolation_level`, `label_length`, `logging_name`, `max_identifier_length`, `max_overflow`, `paramstyle`, `pool_logging_name`, `pool_pre_ping`, `pool_recycle`, `pool_size`, `pool_timeout`, `pool_use_lifo`, `query_cache_size`, `use_insertmanyvalues` |
40
+ | Session | `autobegin`, `autoflush`, `expire_on_commit`, `join_transaction_mode`, `twophase` |
41
+ | Connection | `keep_replica` |
42
+
43
+ Flags read `1`/`true`/`yes`/`on` and `0`/`false`/`no`/`off`; numbers are numbers; `echo` and
44
+ `echo_pool` also take `debug`. A value that does not read raises `InvalidArgumentError` naming the
45
+ option, failing the service that needed the URL. Only the connection's own URL is read this way,
46
+ never a replica's.
47
+
48
+ ## Read replicas
49
+
50
+ ```python
51
+ ConnectionConfig(
52
+ url=env("DATABASE_URL"),
53
+ replicas={"replica1": env("DATABASE_REPLICA_URL"), "replica2": env("DATABASE_REPLICA2_URL")},
54
+ keep_replica=False,
55
+ )
56
+ ```
57
+
58
+ - A session reads from a replica picked at random.
59
+ - It goes to the primary for a write, for a transaction's statements, and for every read after
60
+ them, so it sees what it wrote.
61
+ - It stays on the primary for the rest of its unit of work, unless `keep_replica=True` sends it
62
+ back to the replicas once it commits.
63
+ - Each unit of work starts on the replicas, whatever the one before wrote.
64
+ - Migrations and the `orm:database:*` commands always use the primary.
65
+ - The injected session maker is a `RoutingAsyncSessionMaker` instead of an `async_sessionmaker`.
66
+ - Send one block elsewhere with `primary_context()` / `replica_context()` from
67
+ `advanced_alchemy.routing`.
68
+
69
+ ## Without a container
70
+
71
+ ```python
72
+ from sqlalchemy.ext.asyncio import create_async_engine
73
+ from xtr_orm import ConnectionRegistry, DatabaseManager, MigrationsConfig, Migrator
74
+
75
+ url = "sqlite+aiosqlite:///shop.db"
76
+ engine = create_async_engine(url)
77
+
78
+ registry = ConnectionRegistry() # ConnectionRegistry(default="primary") renames the default
79
+ registry.register(
80
+ "default",
81
+ engine=engine,
82
+ migrator=Migrator(engine, MigrationsConfig(directory="migrations", render_as_batch=True)),
83
+ database=DatabaseManager(url),
84
+ session=None, # an async callable returning the unit of work's session; the middleware need it
85
+ close=None, # an async callable; the engine is disposed when left out
86
+ in_use=None, # a callable; the connection always counts as in use when left out
87
+ )
88
+
89
+ await registry.engine() # the default connection's
90
+ await registry.migrator("reports")
91
+ registry.names(), registry.in_use(), registry.has("reports"), registry.default
92
+ await registry.close()
93
+ ```
94
+
95
+ Each of `engine`, `migrator` and `database` takes the object itself or an async function building
96
+ it, called only when that piece is first asked for. `DEFAULT_CONNECTION` (`"default"`) is exported
97
+ from `xtr_orm`.
@@ -0,0 +1,95 @@
1
+ # Message bus middleware
2
+
3
+ Needs the `messenger` extra: `uv add "xtr-orm[messenger]"`. With both bundles active, importing
4
+ `xtr_orm.messenger` is done for you and each middleware is registered under its name for a bus
5
+ configuration to list.
6
+
7
+ | Name | Class | Does | Arguments |
8
+ |---|---|---|---|
9
+ | `orm_transaction` | `TransactionMiddleware` | Runs every handler of a message in one transaction, committed once they all succeeded and rolled back otherwise. A failure drops the other handlers' handled stamps, so a retry runs them all again | `connection_name`, the default connection when left out |
10
+ | `orm_close_connection` | `CloseConnectionMiddleware` | Closes the connections once a worker handled a message it consumed, so an idle worker holds none — once the last, when it handles several at once | `connection_names`, every connection in use when left out |
11
+ | `orm_open_transaction_logger` | `OpenTransactionLoggerMiddleware` | Logs an error when a handler began a transaction (`begin()`, `begin_nested()`) and left it open in a message that was handled | `connection_names`, as above |
12
+
13
+ "In use" means a connection whose engine has been built; opening one to find out would read its
14
+ configuration, so an untouched connection is left alone.
15
+
16
+ ## Order
17
+
18
+ ```python
19
+ # <app>/config/messenger.py
20
+ MessageBusConfig(
21
+ middleware=[
22
+ "orm_close_connection",
23
+ "orm_transaction",
24
+ {"orm_transaction": {"connection_name": "reports"}},
25
+ "orm_open_transaction_logger",
26
+ ],
27
+ )
28
+ ```
29
+
30
+ - `orm_close_connection` outermost, so it closes after the transaction ended.
31
+ - `orm_open_transaction_logger` inside `orm_transaction`, so it looks before the commit closes
32
+ everything.
33
+ - A second `orm_transaction` with another `connection_name` puts that connection in a transaction
34
+ too.
35
+
36
+ ## Handlers
37
+
38
+ ```python
39
+ # app/billing/handlers.py
40
+ @as_message_handler(IssueInvoice)
41
+ async def issue(message: IssueInvoice, session: Injected[AsyncSession]) -> None:
42
+ session.add(Invoice(order_id=message.order_id)) # no commit
43
+
44
+
45
+ @as_message_handler(IssueInvoice)
46
+ class NotifyAccounting:
47
+ # A scoped repository on the same session: one transaction for both handlers.
48
+ async def __call__(
49
+ self, message: IssueInvoice, invoices: Injected[InvoiceRepository]
50
+ ) -> None: ...
51
+ ```
52
+
53
+ - **One session per message.** Every handler of the message, and every scoped service built on the
54
+ session, gets the same one, released once the message is done with.
55
+ - **A nested dispatch joins the unit of work** and its transaction, in a savepoint of its own: when
56
+ it fails only its handlers' work is rolled back, and the handler that dispatched it decides
57
+ whether the rest goes on.
58
+ - **Handlers do not commit.** One that does ends the transaction early; one using
59
+ `async with session.begin()` is refused, the transaction being open already.
60
+ - **Follow-ups wait for the commit only when stamped.** A message dispatched to a transport is
61
+ sent at once, before the commit and whether or not it comes:
62
+
63
+ ```python
64
+ await bus.dispatch(SendReceipt(order.id), DispatchAfterCurrentBusStamp())
65
+ ```
66
+
67
+ With that stamp it goes out only once the message being handled was committed, and never after a
68
+ rollback.
69
+ - **Only messages a worker consumed** — one carrying a `ReceivedStamp` on arrival — close
70
+ connections afterwards. A message handled during its dispatch, `sync://` included, closes
71
+ nothing. A worker handling several at once closes once the last is done.
72
+ - **A dropped connection** is found by the engine option `pool_pre_ping` as a connection is taken
73
+ from the pool (`?pool_pre_ping=true` on a worker's URL), so there is no middleware for it.
74
+
75
+ ## Without a container
76
+
77
+ Build the middleware yourself and put the instances in the bus configuration. They need a
78
+ `ConnectionRegistry` whose connections were registered with a `session` callable — what returns the
79
+ session the handlers use — or `registry.session()` raises `SessionUnavailableError`.
80
+
81
+ ```python
82
+ from xtr_orm.messenger import (
83
+ CloseConnectionMiddleware,
84
+ OpenTransactionLoggerMiddleware,
85
+ TransactionMiddleware,
86
+ )
87
+
88
+ middleware = [
89
+ CloseConnectionMiddleware(registry),
90
+ TransactionMiddleware(registry),
91
+ OpenTransactionLoggerMiddleware(registry),
92
+ ]
93
+ ```
94
+
95
+ `connection_names` takes one name or a sequence of them.
@@ -0,0 +1,71 @@
1
+ # MigrationsConfig and revision templates
2
+
3
+ A frozen dataclass; every field has a default, so `MigrationsConfig()` is valid.
4
+ Import it from `xtr_orm` or from `xtr_orm.bundle`.
5
+
6
+ | Field | Default | Meaning |
7
+ |---|---|---|
8
+ | `directory` | `"migrations"` | Where revision files are read and written. A relative path is relative to the working directory — under a kernel start it with `%kernel.project_dir%` |
9
+ | `version_table` | `"alembic_version"` | Holds the revisions the database is at |
10
+ | `history_table` | `"alembic_version_history"` | A row per revision ever applied: `version`, `executed_at`, `execution_time` in milliseconds |
11
+ | `version_table_schema` | `None` | The schema of both; the connection's default when `None` |
12
+ | `file_template` | `"{year}{month}{day}{hour}{minute}{second}_{rev}_{slug}"` | How a file is named; `{epoch}` is available too |
13
+ | `truncate_slug_length` | `40` | The longest message part of a file name |
14
+ | `timezone` | `None` | The zone a revision's date is written in |
15
+ | `template_directory` | `None` | A directory holding a `script.py.mako` to render new revisions from |
16
+ | `recursive_version_locations` | `False` | Read subdirectories of `directory` too |
17
+ | `post_write_hooks` | `()` | Programs run on each file written, such as a formatter |
18
+ | `transaction_per_migration` | `False` | Commit after each revision rather than once |
19
+ | `transactional_ddl` | `None` | Run schema changes inside the transaction |
20
+ | `compare_type` | `False` | Compare column types in a diff |
21
+ | `compare_server_default` | `False` | Compare server defaults in a diff |
22
+ | `render_as_batch` | `False` | Write table changes as batches. **SQLite needs this** |
23
+ | `include_schemas` | `False` | Read other schemas in a diff |
24
+ | `include_name` / `include_object` | `None` | Callables deciding what a diff reads |
25
+ | `render_item` | `None` | Callable deciding how an item is written |
26
+ | `process_revision_directives` | `None` | Callable adjusting a diff before it is written |
27
+ | `user_module_prefix` | `None` | The prefix custom column types are written with |
28
+ | `context_options` | `{}` | Any other migration context option, passed through |
29
+
30
+ ## Custom column types in a diff
31
+
32
+ A custom type is written under its own module's name, and the revision imports that module:
33
+ `advanced_alchemy.types.guid.GUID(length=16)` plus `import advanced_alchemy.types.guid`. Set
34
+ `user_module_prefix` or `render_item` to write it otherwise.
35
+
36
+ `compare_type` is off by default on purpose: a type the database stores as another — a UUID kept
37
+ as bytes on SQLite — would otherwise differ in every diff.
38
+
39
+ ## Your own revision template
40
+
41
+ New revisions render from `script.py.mako`: the package's own, or the one in
42
+ `template_directory`. Nothing else is read from that directory.
43
+
44
+ The template receives `message`, `up_revision`, `down_revision`, `branch_labels`, `depends_on`,
45
+ `create_date`, `imports`, `upgrades`, `downgrades`, and the `comma` filter
46
+ (`${down_revision | comma,n}`). A template written for a plain migration-runner project works as
47
+ it is.
48
+
49
+ ```python
50
+ MigrationsConfig(
51
+ template_directory="%kernel.project_dir%/resources/migrations",
52
+ user_module_prefix="sa.", # for a template aliasing sa.GUID = GUID
53
+ )
54
+ ```
55
+
56
+ A template calling `op.get_context().autocommit_block()` commits the revision's schema changes
57
+ before the version and history tables are written, and gives up the transaction a failing
58
+ revision would roll back. Set `transaction_per_migration=True` alongside it, and keep it to the
59
+ revisions that need it (`CREATE INDEX CONCURRENTLY`).
60
+
61
+ ## Result objects
62
+
63
+ | Type | Fields |
64
+ |---|---|
65
+ | `MigrationPlan` | `version`, `direction: Direction`, `description` |
66
+ | `ExecutionResult` | `version`, `direction: Direction`, `duration` in seconds |
67
+ | `AvailableMigration` | `version`, `description`, `down_versions`, `path`, `is_head` |
68
+ | `ExecutedMigration` | the recorded row: its version and when it ran |
69
+ | `MigrationStatus` | `current`, `latest`, `previous`, `next`, `available`, `executed`, `new`, `executed_unavailable`, `is_up_to_date` |
70
+
71
+ `Direction` is an `Enum` with `Direction.UP` and `Direction.DOWN`.
@@ -105,25 +105,30 @@ class OrmBundle(Bundle[OrmConfig]):
105
105
  builder: ContainerBuilder,
106
106
  ) -> None:
107
107
  """Register every connection's services under its name, the registry, the commands."""
108
- for name, spec in config.connections.items():
108
+ for name, connection in config.connections.items():
109
109
  maker_type: type = (
110
- RoutingAsyncSessionMaker if spec.replicas else async_sessionmaker[AsyncSession]
110
+ RoutingAsyncSessionMaker
111
+ if connection.replicas
112
+ else async_sessionmaker[AsyncSession]
111
113
  )
112
114
  factories = (
113
- (_alchemy_factory(name, spec), "alchemy"),
115
+ (_alchemy_factory(name, connection), "alchemy"),
114
116
  (_engine_factory(name), "engine"),
115
117
  (
116
- _routing_maker_factory(name) if spec.replicas else _session_maker_factory(name),
118
+ _routing_maker_factory(name)
119
+ if connection.replicas
120
+ else _session_maker_factory(name),
117
121
  "session_maker",
118
122
  ),
119
- (_migrator_factory(name, spec), "migrator"),
120
- (_database_factory(spec), "database"),
123
+ (_migrator_factory(name, connection), "migrator"),
124
+ (_database_factory(connection), "database"),
121
125
  )
122
126
  for factory, kind in factories:
123
127
  _ = services.set(named_factory(factory, f"orm_{kind}_{name}"), qualifier=name)
124
128
  _ = services.set(
125
129
  named_factory(
126
- _session_factory(name, routed=bool(spec.replicas)), f"orm_session_{name}"
130
+ _session_factory(name, routed=bool(connection.replicas)),
131
+ f"orm_session_{name}",
127
132
  ),
128
133
  qualifier=name,
129
134
  lifetime="scoped",
@@ -133,7 +138,9 @@ class OrmBundle(Bundle[OrmConfig]):
133
138
  services.alias(service, service, target_qualifier=name)
134
139
 
135
140
  _ = services.set(_ConnectionsInUse)
136
- routed = {name: bool(spec.replicas) for name, spec in config.connections.items()}
141
+ routed = {
142
+ name: bool(connection.replicas) for name, connection in config.connections.items()
143
+ }
137
144
  _ = services.set(_connection_registry_factory(config.default_connection, routed))
138
145
 
139
146
  if bundle_active(builder, "console"):
@@ -165,24 +172,24 @@ def _alchemy_factory(
165
172
  """
166
173
 
167
174
  async def alchemy(in_use: _ConnectionsInUse) -> AsyncIterator[SQLAlchemyAsyncConfig]:
168
- spec = connection.with_url_options()
175
+ resolved = connection.with_url_options()
169
176
  # The options are forwarded by name, each checked against the target's
170
177
  # fields by ConnectionConfig; their values are the application's.
171
- engine = cast("Callable[..., EngineConfig]", EngineConfig)(**spec.engine_options)
178
+ engine = cast("Callable[..., EngineConfig]", EngineConfig)(**resolved.engine_options)
172
179
  session = cast("Callable[..., AsyncSessionConfig]", AsyncSessionConfig)(
173
- **spec.session_options
180
+ **resolved.session_options
174
181
  )
175
182
  built = cast("Callable[..., SQLAlchemyAsyncConfig]", SQLAlchemyAsyncConfig)(
176
183
  # With replicas, the primary is given through the routing instead.
177
- connection_string=None if spec.replicas else spec.url,
178
- routing_config=_routing(spec) if spec.replicas else None,
184
+ connection_string=None if resolved.replicas else resolved.url,
185
+ routing_config=_routing(resolved) if resolved.replicas else None,
179
186
  engine_config=engine,
180
187
  session_config=session,
181
- bind_key=spec.bind_key,
182
- **spec.alchemy_options,
188
+ bind_key=resolved.bind_key,
189
+ **resolved.alchemy_options,
183
190
  )
184
191
  routing_maker: RoutingAsyncSessionMaker | None = None
185
- if spec.replicas:
192
+ if resolved.replicas:
186
193
  routing_maker = cast("RoutingAsyncSessionMaker", built.create_session_maker())
187
194
  # One pool on the primary: the routing's, which migrations use too.
188
195
  built.engine_instance = routing_maker.primary_engine
@@ -198,7 +205,7 @@ def _alchemy_factory(
198
205
  return alchemy
199
206
 
200
207
 
201
- def _routing(spec: ConnectionConfig) -> RoutingConfig:
208
+ def _routing(connection: ConnectionConfig) -> RoutingConfig:
202
209
  """Map the connection's replicas onto the database layer's read/write routing.
203
210
 
204
211
  Reads go to a replica picked at random; a write, a transaction and every
@@ -207,14 +214,14 @@ def _routing(spec: ConnectionConfig) -> RoutingConfig:
207
214
  built.
208
215
  """
209
216
  return RoutingConfig(
210
- primary_connection_string=spec.url,
217
+ primary_connection_string=connection.url,
211
218
  read_replicas=[
212
219
  ReplicaEngine(connection_string=url, name=replica)
213
- for replica, url in spec.replicas.items()
220
+ for replica, url in connection.replicas.items()
214
221
  ],
215
222
  routing_strategy=RoutingStrategy.RANDOM,
216
223
  sticky_after_write=True,
217
- reset_stickiness_on_commit=spec.keep_replica,
224
+ reset_stickiness_on_commit=connection.keep_replica,
218
225
  )
219
226
 
220
227
 
@@ -281,13 +288,15 @@ def _migrator_factory(
281
288
  """Build the factory of the connection ``name``'s migrator, logging to ``orm`` when it can."""
282
289
 
283
290
  async def migrator(container: ContainerInterface) -> Migrator:
284
- spec = connection.with_url_options()
291
+ resolved = connection.with_url_options()
285
292
  metadata = (
286
- spec.metadata if spec.metadata is not None else metadata_registry.get(spec.bind_key)
293
+ resolved.metadata
294
+ if resolved.metadata is not None
295
+ else metadata_registry.get(resolved.bind_key)
287
296
  )
288
297
  built = Migrator(
289
298
  await container.get(AsyncEngine, name),
290
- spec.migrations or MigrationsConfig(),
299
+ resolved.migrations or MigrationsConfig(),
291
300
  metadata,
292
301
  name=name,
293
302
  )
@@ -303,10 +312,10 @@ def _database_factory(connection: ConnectionConfig) -> Callable[[], DatabaseMana
303
312
  """Build the factory of ``connection``'s database manager."""
304
313
 
305
314
  def database() -> DatabaseManager:
306
- spec = connection.with_url_options()
307
- connect_args = spec.engine_options.get("connect_args")
315
+ resolved = connection.with_url_options()
316
+ connect_args = resolved.engine_options.get("connect_args")
308
317
  return DatabaseManager(
309
- spec.url,
318
+ resolved.url,
310
319
  connect_args=connect_args if isinstance(connect_args, dict) else None, # pyright: ignore[reportUnknownArgumentType] -- an option mapping, read as given
311
320
  )
312
321
 
@@ -0,0 +1,15 @@
1
+ """This directory holds the package's recipe: its manifest and its templates.
2
+
3
+ The recipe is what *Use in an application* in the README lists, written as
4
+ data: the bundle to list, the environment variable to declare, and the steps
5
+ only a person can take. An application applies it with
6
+ ``xtr-recipes recipes:sync``, which finds this module through the
7
+ ``xtr_recipes`` entry point.
8
+
9
+ Nothing is importable from here. The manifest is read as a file, so a recipe
10
+ is the same from a wheel as from a working tree.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ __all__: list[str] = []
@@ -0,0 +1,24 @@
1
+ """The database: one connection, its URL read from ``DATABASE_URL``.
2
+
3
+ The URL is read when a connection is first used — an engine, a session, a migrator or a
4
+ command — not when the application boots, and its query may carry engine and session
5
+ options. Revisions live in ``migrations/`` in the project directory. Add replicas, more
6
+ connections or the metadata of your own models here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from xtr_dependency_injection import configure, env
12
+ from xtr_orm.bundle import ConnectionConfig, OrmConfig
13
+
14
+ __all__ = ["orm"]
15
+
16
+
17
+ @configure
18
+ def orm() -> OrmConfig:
19
+ """The ``default`` connection, on ``DATABASE_URL``."""
20
+ return OrmConfig(
21
+ connections={
22
+ "default": ConnectionConfig(url=env("DATABASE_URL")),
23
+ },
24
+ )
@@ -0,0 +1,32 @@
1
+ # The recipe for xtr-orm: what *Use in an application* in the README takes, as
2
+ # data. Keep the two in step — the README is the source.
3
+
4
+ [bundles]
5
+ # "<module>:<class>" = the environments it is listed with, exactly as in BUNDLES.
6
+ "xtr_orm.bundle:OrmBundle" = { all = true }
7
+
8
+ [files]
9
+ # Destination under the application package = template under this recipe.
10
+ # The default connection on DATABASE_URL, written out so the application has
11
+ # one place to add replicas, connections and its models' metadata.
12
+ "config/orm.py" = "files/config/orm.py.tmpl"
13
+
14
+ [env]
15
+ # Read when a connection is first used — engine, session, migrator or command —
16
+ # not when the application boots. A local PostgreSQL to start from, for the
17
+ # postgres extra the README installs; change it, or put the real one in
18
+ # .env.local.
19
+ DATABASE_URL = "postgresql+asyncpg://app:change-me@127.0.0.1:5432/app"
20
+
21
+ [gitignore]
22
+ # Nothing: migrations/ is committed.
23
+ lines = []
24
+
25
+ [notes]
26
+ steps = [
27
+ "With the messenger bundle active, list the orm_* middleware in every bus's middleware: every message is then a unit of work.",
28
+ ]
29
+ check = [
30
+ "<script> debug:bundles — orm is listed and active",
31
+ "<script> orm:migrations:status — it reaches the database",
32
+ ]
File without changes
File without changes
File without changes