Skip to content

Commit 2034f08

Browse files
authored
docs: plain-prose pass over agent and glossary docs (#65)
1 parent d77c6ea commit 2034f08

2 files changed

Lines changed: 37 additions & 38 deletions

File tree

‎AGENTS.md‎

Lines changed: 34 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
44

5-
`chat-app` was bootstrapped from `litestar-sqlalchemy-template`. It is **not**
6-
the template with new routes — three things below differ from what the
7-
template teaches, and getting any of them wrong by pattern-matching on the
8-
template breaks the transaction model or the DI wiring.
5+
`chat-app` was bootstrapped from `litestar-sqlalchemy-template`, but three
6+
things below differ from what the template teaches. Getting any of them wrong
7+
by pattern-matching on the template breaks the transaction model or the DI
8+
wiring.
99

1010
## The three things most likely to be got wrong
1111

@@ -16,13 +16,13 @@ template breaks the transaction model or the DI wiring.
1616
example.
1717
2. **Repositories run `auto_commit=False`.** Every `*_repository` provider in
1818
`app/ioc.py` is constructed with `kwargs={"session": ..., "auto_commit":
19-
False}` — the opposite of the template's `auto_commit=True`. A repository
19+
False}`, the opposite of the template's `auto_commit=True`. A repository
2020
here never commits on its own.
2121
3. **Use cases own the transaction boundary, not repositories.** Every use
2222
case that writes wraps its work in `async with self.transaction:` (a
2323
`db_retry.Transaction`) and calls `await self.transaction.commit()`
24-
explicitly once every write that must land together has been made — e.g.
25-
`CreateMessageUseCase` commits the new message row and the
24+
explicitly once every write that must land together has been made. For
25+
example, `CreateMessageUseCase` commits the new message row and the
2626
`chats.last_message_id` update together. This exists because a single
2727
operation can span more than one repository write and they must succeed or
2828
fail as a unit; giving that back to individually auto-committing
@@ -38,12 +38,12 @@ template breaks the transaction model or the DI wiring.
3838

3939
## Commands
4040

41-
Recipes live in the `justfile` — run `just --list` to see them; this section
41+
Recipes live in the `justfile`; run `just --list` to see them. This section
4242
only covers what isn't obvious from the recipe names.
4343

4444
Almost everything runs through Docker Compose: the app and Postgres come up
4545
together, and running tests/migrations outside Docker is **not** the
46-
supported path (`just install` and `just lint` are the exceptions — they run
46+
supported path (the exceptions are `just install` and `just lint`, which run
4747
on the host). Inside the container, raw commands look like `uv run pytest ...`,
4848
`uv run alembic ...`.
4949

@@ -52,18 +52,18 @@ on the host). Inside the container, raw commands look like `uv run pytest ...`,
5252
e.g. `just test tests/use_cases/test_create_chat.py -k race -x`.
5353
- `just test-migrations` runs the `pytest-alembic` suite (`tests/migrations/`):
5454
single head, upgrade, per-revision up/down consistency, and
55-
model-definitions-match-DDL — the last being `alembic check` as a test. That
55+
model-definitions-match-DDL, the last being `alembic check` as a test. That
5656
directory is excluded from `just test` (`--ignore` in `addopts`, and from
5757
`coverage`'s `omit`) because it cycles the schema out from under the
5858
transaction-rollback fixture, so it needs the `--override-ini=addopts=` the
5959
recipe passes. CI runs both.
6060
- Enum columns are native Postgres enums, and `alembic-postgresql-enum` is
61-
imported by `migrations/env.py` for its autogenerate hooks — that is what
62-
renders `CREATE TYPE` / `ALTER TYPE ... ADD VALUE` / `op.sync_enum_values`
61+
imported by `migrations/env.py` for its autogenerate hooks. Those hooks
62+
render `CREATE TYPE` / `ALTER TYPE ... ADD VALUE` / `op.sync_enum_values`
6363
instead of silently missing them. Add or rename an enum value and
6464
`just migration` writes the type change for you.
65-
- `just migration "message"` takes a **single positional argument** — not a
66-
`-m` flag — quoted so a multi-word message survives as one token (the
65+
- `just migration "message"` takes a **single positional argument** (not a
66+
`-m` flag), quoted so a multi-word message survives as one token (the
6767
recipe shell-quotes it with `quote()` before handing it to `alembic
6868
revision --autogenerate -m`). It runs against an already-upgraded DB; the
6969
recipe enforces that by upgrading first, so don't run autogen by hand.
@@ -102,28 +102,28 @@ parameters typed `NamedDependency[SomeUseCase]`; nothing wires them by hand.
102102
`build_app` passes `autowired_groups=[ioc.UseCases]` to the plugin, which
103103
registers one Litestar dependency per provider on that group, named after the
104104
provider attribute (`create_chat_use_case`, ...). `Database` and `Repositories`
105-
are deliberately **not** autowired — a route handler has no business resolving
105+
are deliberately **not** autowired: a route handler has no business resolving
106106
a session, a transaction or a repository directly. Provider scopes:
107-
- `Database.database_engine` — app-scoped factory, `cache=` finalizer disposes
107+
- `Database.database_engine`: app-scoped factory, `cache=` finalizer disposes
108108
the engine.
109-
- `Database.database_session` — request-scoped, finalizer closes the session.
110-
- `Database.transaction` — request-scoped `db_retry.Transaction`, the object
109+
- `Database.database_session`: request-scoped, finalizer closes the session.
110+
- `Database.transaction`: request-scoped `db_retry.Transaction`, the object
111111
every use case wraps its work in.
112-
- `Database.autocommit_transaction` — the same, opened at `AUTOCOMMIT` so a
112+
- `Database.autocommit_transaction`: the same, opened at `AUTOCOMMIT` so a
113113
use case that sends exactly one read statement (login, `GET /auth/me/`, the
114114
chat list and detail, the token revocation check) sends no BEGIN or COMMIT.
115115
Reads of two or more statements keep `Database.transaction`, so they see
116116
one snapshot. `rolled_back_db_session`
117117
overrides `Database.autocommit_isolation_level` to `None`, because switching
118118
the shared test connection would roll back its outer transaction.
119-
- `Repositories.*` — request-scoped, `auto_commit=False` (see above).
120-
- `UseCases.*` — request-scoped, one class per operation.
119+
- `Repositories.*`: request-scoped, `auto_commit=False` (see above).
120+
- `UseCases.*`: request-scoped, one class per operation.
121121

122122
**Persistence**: Models inherit `advanced_alchemy.base.BigIntAuditBase` /
123123
`BigIntBase`. `app/database/tables.py` shares metadata with
124124
`orm.DeclarativeBase.metadata` (`METADATA = orm_registry.metadata;
125-
orm.DeclarativeBase.metadata = METADATA`) so Alembic autogen sees everything —
126-
this line mutates a third-party base class at import time, because otherwise
125+
orm.DeclarativeBase.metadata = METADATA`) so Alembic autogen sees everything.
126+
This line mutates a third-party base class at import time, because otherwise
127127
models register on `orm.DeclarativeBase`'s own metadata and autogen sees no
128128
tables at all. Repositories are
129129
`SQLAlchemyAsyncRepositoryService[Model]` with a nested
@@ -134,22 +134,22 @@ template, but every service here is constructed with `auto_commit=False`.
134134
starts a transaction, then **overrides** `Database.database_engine` in the DI
135135
container to return that connection; `create_session`'s
136136
`join_transaction_mode="create_savepoint"` is what makes every session opened
137-
against it — fixture or route handler — nest as a savepoint instead of
137+
against it (fixture or route handler) nest as a savepoint instead of
138138
committing past the outer transaction. Teardown rolls the outer transaction
139139
back. `app`/`client` fixtures build the real app and run it through
140140
`httpx.ASGITransport` + `asgi_lifespan.LifespanManager`.
141141
`modern_di_pytest.expose(ioc.Repositories, ioc.UseCases,
142142
container_fixture="request_container")` (`tests/use_cases/conftest.py`)
143-
exposes every repository/use case provider as a same-named pytest fixture —
143+
exposes every repository/use case provider as a same-named pytest fixture;
144144
the template predates this and hand-assembles dependencies instead. The
145145
concurrent-create tests in `tests/use_cases/test_create_chat.py` and
146146
`tests/use_cases/test_create_message.py` use a second real connection: it holds
147147
an uncommitted insert, and the test polls `pg_stat_activity` until the use case
148148
waits on its lock. The invariant each one pins is in its docstring.
149149

150150
**Migrations**: `migrations/env.py` reads the shared `METADATA` and rewrites
151-
the DSN driver from `postgresql+asyncpg` → `postgresql` (Alembic uses sync
152-
psycopg2). Always run autogen against an upgraded DB — `just migration`
151+
the DSN driver from `postgresql+asyncpg` to `postgresql` (Alembic uses sync
152+
psycopg2). Always run autogen against an upgraded DB; `just migration`
153153
enforces this.
154154

155155
**Settings** (`app/settings.py`): `pydantic_settings.BaseSettings` reads from
@@ -174,8 +174,8 @@ env vars (see `docker-compose.yml`). `api_bootstrapper_config` builds the
174174
frozen=True, slots=True)` per operation with an async `__call__` decorated
175175
`@db_retry.postgres_retry`. Shared authorization logic that more than one
176176
use case needs (e.g. the author-and-member gate for edit/delete) lives in a
177-
plain module-level function, not a base class — see
178-
`app/use_cases/message_authorization.py`.
177+
plain module-level function, not a base class (see
178+
`app/use_cases/message_authorization.py`).
179179
- Pydantic schemas in `app/schemas/api.py` use `from_attributes=True` (via
180180
`Base`) so they validate directly from ORM instances
181181
(`schemas.X.model_validate(orm_instance)`). Collection responses go through
@@ -186,22 +186,22 @@ env vars (see `docker-compose.yml`). `api_bootstrapper_config` builds the
186186
exceptions (`NotFoundError`, `DuplicateKeyError`, `ForeignKeyError`).
187187
- **Comments.** None, unless the code would read as a bug without one; then a
188188
single line. Rationale, design decisions and "why not X" belong in
189-
the PR body, never in the source — that is where such reasoning is reviewed
190-
and kept, and a comment restating it goes stale in place.
189+
the PR body, never in the source. The PR body is where such reasoning is
190+
reviewed and kept, and a comment restating it goes stale in place.
191191
What survives in `app/` today is the whole permitted category: a setting that
192192
looks arbitrary (`join_transaction_mode`, `populate_existing`,
193193
`capture_parameters=False`), an `orm.foreign()` on a column with no
194194
`ForeignKey`, a `return` from inside a transaction block, discarded work that
195195
is not dead code (`AuthenticateUserUseCase`'s hash-anyway). Alembic's own
196-
`# ###` autogenerate markers stay — they are regenerated on every migration.
196+
`# ###` autogenerate markers stay, since they are regenerated on every migration.
197197
- **Coverage.** The suite runs at `--cov-fail-under=100` and no `# pragma: no cover`
198198
appears anywhere. The only exemptions are structural: `[tool.coverage.run] omit`
199199
for files the default run never imports, and `exclude_also` for
200200
`if typing.TYPE_CHECKING:`. A branch that looks untestable gets a test that
201201
produces its condition for real, such as the concurrent create tests'
202202
second connection holding an uncommitted insert.
203-
- `ruff` is configured with `select = ["ALL"]` and a line length of 120 —
204-
expect strict lint. Type-check with `ty`.
203+
- `ruff` is configured with `select = ["ALL"]` and a line length of 120,
204+
so expect strict lint. Type-check with `ty`.
205205

206206
## Vocabulary
207207

‎docs/adr/0007-repoint-last-message-on-delete.md‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@ together. The column therefore carries exactly one meaning, the newest
77
non-deleted message in this chat, and both the listing preview and the listing's
88
ordering (`coalesce(chats.last_message_id, 0) DESC`) read it. Adding
99
`deleted_at IS NULL` to the preview fetch is the smaller change and was
10-
considered first, but it leaves two half-broken behaviours instead of one
11-
correct one: the preview goes blank while older messages still exist, and the
10+
considered first, but it leaves two broken behaviours: the preview goes blank while older messages still exist, and the
1211
chat keeps sorting by the deleted message's id, because ordering reads the same
13-
column the preview stopped trusting. That filter is still present on the preview
14-
relationship, as a self-defending invariant guard rather than as the mechanism.
12+
column the preview stopped trusting. The preview relationship keeps that filter as an
13+
invariant guard; the repoint is what keeps the preview correct.
1514
`chats.last_message_id` stays a plain `BigInteger` rather than a foreign key,
1615
because `chats` is created before `messages` exists and a circular constraint
1716
pair would buy nothing at this scale.

0 commit comments

Comments
 (0)