diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2dc8a765..073f5036 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -37,6 +37,13 @@ jobs: cache-dependency-glob: ${{ env.UV_CACHE_GLOB }} - run: make install-py - run: make ci-python-lint + # The checked-in typed-key files must match what the installed modules' + # catalogs emit. Catches a catalog edit committed without regenerating, and + # (with the namespace guard in gen_i18n.py) a regeneration that dropped keys. + - name: i18n generated files are up to date + run: | + uv run --project host python scripts/gen_i18n.py + git diff --exit-code packages/i18n/src/ python-typecheck: name: Python typecheck diff --git a/CLAUDE.md b/CLAUDE.md index e34086f9..ce09ce93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ hence `SM022`/`SM023`. See `docs/module-authoring.md` § Styling. Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (bypass the read filter with `stmt.execution_options(include_deleted=True)`; purge by deleting an already-trashed row, or with `hard_delete(session, obj)`), `MultiTenantMixin`, `VersionedMixin`. The soft-delete/tenant filters cover every statement shape, not only selects that name the entity — joins, ORM subqueries, `select(func.count()).select_from(Model)`, and Core statements over `Model.__table__` (GH #332). **Tenancy fails closed**: with `multi_tenant` on, a query or insert on a `MultiTenantMixin` model with no `current_tenant_id` raises `TenantIsolationError` instead of reading every tenant; cross-tenant code says so with `all_tenants()` / `execution_options(all_tenants=True)`, and jobs/CLI act for one tenant with `tenant_context(id)`. With `multi_tenant` off and no tenant bound, inserts are stamped `DEFAULT_TENANT_ID` (`"default"`, from `simple_module_db`) and reads stay unfiltered; adoption migrations backfill with the same constant. Tenant roles reach the principal as `tenant:` — map them with `tenant_role(TenantRole.MEMBER)` from `simple_module_core.tenancy`, never by importing `tenants`; tests use the `tenant_client(role)` fixture. Unique keys on such tables must include `tenant_id` (`SM024`). The `tenants` module owns organisations, memberships and `app.state.tenant_resolver`; tenant-level routes act on the *active* tenant, never a tenant id from the URL. See [docs/framework/multi-tenancy.md](docs/framework/multi-tenancy.md). The per-request session (`get_db`) auto-commits **only if** there are pending writes (via `after_flush` listener); otherwise rollback. Service code should **not** call `session.commit()` — flush if you need DB-assigned values. DML executed through the session (`session.execute(update(Model)...)`) counts as a write; a raw `text("UPDATE ...")` does not, and needs `mark_written(session)`. The commit fires in `CommitBeforeResponseMiddleware`, at the ASGI `http.response.start` message, so a client that creates a row and immediately reads it back in a second request sees it — FastAPI runs a `yield` dependency's exit code *after* the response is delivered, which used to make that a deterministic 404 (GH #257). `get_db` keeps the same commit in its own exit code as a fallback for when the middleware isn't in the stack; whichever runs first wins. -**Migrations** live in `host/migrations/versions/` — not in module packages. `host/alembic/env.py` calls `build_module_metadata()` + `make_include_object()` so autogenerate covers every installed module and ignores host-owned tables. First migration of each module should set `branch_labels = ("",)` to enable per-module `downgrade @base`. +**Migrations** live in `host/migrations/versions/` — not in module packages. `host/alembic/env.py` calls `build_module_metadata()` + `make_include_object()` so autogenerate covers every installed module and ignores host-owned tables. First migration of each module should set `branch_labels = ("",)`. The label is only a **named target** (`alembic upgrade/downgrade @`, and the `module` column of the Doctor migration list): because autogenerate chains each first revision off the current head, the history is one linear chain, and `downgrade @base` walks the **entire chain beneath that revision, other modules included** — it is not a per-module rollback. To remove one module's schema, downgrade to the `down_revision` of its first revision, which is safe only while no other module's revisions sit above it; otherwise write a dedicated migration that drops that module's tables. See [docs/database/migrations.md](docs/database/migrations.md) § Removing one module's schema. **Admin section**. Administrative screens live under `/admin/*`, register into `MenuSection.ADMIN_SIDEBAR`, and render in `AdminLayout` — all three together, not one of the three. `SidebarLayout` renders whichever menu its `menuKey` names, so a page left on `AuthenticatedLayout` after its menu item moved shows a sidebar that no longer contains it. `group=` sub-clusters *within* the admin sidebar (`Access`, `Appearance`, `System`); it is no longer used to carve an admin area out of the main sidebar. `/admin` itself is a host route (`host/routes.py`) that renders from the `adminSidebar` shared prop, so an installed module contributes a card without touching it. Old URLs 301 from `host/routes_legacy.py`. Only view URLs moved — `/api/*` is a separate contract and stays put. diff --git a/Makefile b/Makefile index f10beff7..2f1b4a00 100644 --- a/Makefile +++ b/Makefile @@ -29,9 +29,9 @@ gen-pages: # Regenerate packages/i18n/src/{generated-resources,keys.generated}.ts from every # installed module's locales/, plus host/locales and packages/ui/locales — without -# booting the app. +# booting the app. Pass flags with ARGS, e.g. `make gen-i18n ARGS=--allow-removals`. gen-i18n: - uv run --project host python scripts/gen_i18n.py + uv run --project host python scripts/gen_i18n.py $(ARGS) # Install JS deps declared by installed modules into host/client_app/node_modules. # Wheel-installed modules need this; in-repo workspace modules do not. diff --git a/docs/database/migrations.md b/docs/database/migrations.md index 99937fef..6eb51d3b 100644 --- a/docs/database/migrations.md +++ b/docs/database/migrations.md @@ -8,7 +8,7 @@ All migrations live in `host/migrations/versions/` — **not** in module package - **Autogenerate sees everything.** `host/migrations/env.py` calls `build_module_metadata()` to union every installed module's `MetaData`. Autogenerate diffs the DB against that union and writes one migration covering all changes. - **Operators run one command.** `make migrate` is the only target. No "did you also run `orders/migrate`?" footgun. -Each module's *first* migration sets `branch_labels = ("",)` so you can still downgrade one module at a time with `alembic downgrade @base`. +Each module's *first* migration sets `branch_labels = ("",)`. The label is a named target for that revision; it does **not** isolate the module's history, because every first revision chains off the previous head. `alembic downgrade @base` therefore walks the whole chain beneath it, other modules included. See [Removing one module's schema](#removing-one-modules-schema). ## Day-to-day workflow @@ -39,10 +39,14 @@ Runs `alembic -c host/alembic.ini upgrade heads`. Idempotent. ```bash make downgrade # back one revision uv run --project host alembic -c host/alembic.ini downgrade # to a specific revision -uv run --project host alembic -c host/alembic.ini downgrade orders@base # back to the state before the orders module existed ``` -`orders@base` uses the `branch_labels` marker from the module's first migration. Module-level downgrade is the mechanism for uninstalling a module cleanly. +Do **not** use `downgrade orders@base` to roll back just the orders module. The `branch_labels` marker names a revision, but `orders@base` resolves to the base of the linear chain that revision sits on, so it rolls back every revision beneath it, including other modules'. + +### Removing one module's schema + +- If the module's revisions are the latest in the chain (nothing from another module sits above them), downgrade to the `down_revision` of the module's *first* revision: `alembic -c host/alembic.ini downgrade `. +- Otherwise write a dedicated migration that drops the module's tables (and anything depending on them). History stays linear and other modules are untouched. ## First migration of a new module @@ -57,7 +61,7 @@ branch_labels = ("orders",) # ← add this depends_on = None ``` -Once the marker is in place, all future `orders` migrations inherit the branch. +The marker names the revision so you can target it (for example `alembic upgrade orders@head`); later `orders` revisions do not need it. It does not turn the module into an independent branch. ## Alembic environment setup diff --git a/docs/database/models.md b/docs/database/models.md index 87d140f2..954b21b9 100644 --- a/docs/database/models.md +++ b/docs/database/models.md @@ -170,4 +170,4 @@ Do not re-enable these rules in module-local configs. Real bugs caused by wrong - [Per-module Base](/database/per-module-base) — how `create_module_base` and `build_module_metadata` work. - [Mixins](/database/mixins) — `AuditMixin`, `SoftDeleteMixin`, `MultiTenantMixin`, `VersionedMixin`. - [Session lifecycle](/database/sessions) — the `get_db` dependency and why you don't call `commit()`. -- [Migrations](/database/migrations) — Alembic autogenerate and per-module branch labels. +- [Migrations](/database/migrations) — Alembic autogenerate and branch labels (named revision targets). diff --git a/docs/guide/first-module.md b/docs/guide/first-module.md index 7b8c2c7f..0d080496 100644 --- a/docs/guide/first-module.md +++ b/docs/guide/first-module.md @@ -80,7 +80,7 @@ make migration msg="add orders tables" Open `host/migrations/versions/XXXX_add_orders_tables.py` and eyeball it (`make migration` runs Alembic from the host dir): - It should create the `orders_order` table. -- Add `branch_labels = ("orders",)` to the revision so you can later `alembic downgrade orders@base` to roll the module back to empty without touching other modules. +- Add `branch_labels = ("orders",)` to the revision to give it a named target. The label does not isolate the module: `alembic downgrade orders@base` rolls back the whole chain beneath it, other modules included. See [Removing one module's schema](/database/migrations#removing-one-modules-schema). Apply: diff --git a/docs/guide/project-structure.md b/docs/guide/project-structure.md index 73e22943..f00f832d 100644 --- a/docs/guide/project-structure.md +++ b/docs/guide/project-structure.md @@ -101,7 +101,7 @@ Never hand-edit the `.generated.*` files — they are overwritten on the next `m ## Where things intentionally *don't* live -- **No per-module `migrations/` folder.** All migrations live in `host/migrations/versions/`. Autogenerate discovers every installed module's metadata via `build_module_metadata()` in `host/migrations/env.py`. Each module's first migration sets a `branch_labels` marker to enable `alembic downgrade @base`. +- **No per-module `migrations/` folder.** All migrations live in `host/migrations/versions/`. Autogenerate discovers every installed module's metadata via `build_module_metadata()` in `host/migrations/env.py`. Each module's first migration sets a `branch_labels` marker, a named target for that revision (it does not make `alembic downgrade @base` a per-module rollback; see [Migrations](/database/migrations)). - **No host-level `api/` folder.** REST endpoints are attached by each module via `register_routes(api_router, view_router)`. `/api/*` is the union of every module's API router. - **No `schemas/` top-level folder.** DTOs live inside the owning module's `contracts/` so other modules import them by name — the reverse of a monolith's "shared schemas" directory. diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index 72c80155..3004e34b 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -76,7 +76,7 @@ make migration msg="add orders tables" make migrate ``` -`make migration` runs Alembic from the host directory (where `alembic.ini` lives). Autogenerate picks up the new `orders_*` tables and writes `host/migrations/versions/XXXX_add_orders_tables.py`. Add `branch_labels = ("orders",)` to that revision so you can later `alembic downgrade orders@base` to roll the module back in isolation. +`make migration` runs Alembic from the host directory (where `alembic.ini` lives). Autogenerate picks up the new `orders_*` tables and writes `host/migrations/versions/XXXX_add_orders_tables.py`. Add `branch_labels = ("orders",)` to that revision to give it a named target. The label does not isolate the module: `alembic downgrade orders@base` rolls back the whole chain beneath it. See [Removing one module's schema](/database/migrations#removing-one-modules-schema). ## 7. Hit the module diff --git a/docs/module-authoring.md b/docs/module-authoring.md index b6776898..0972feec 100644 --- a/docs/module-authoring.md +++ b/docs/module-authoring.md @@ -312,8 +312,23 @@ the module name: branch_labels = ("my_module",) ``` -This lets operators roll back a single module's schema with -`alembic downgrade my_module@base` without touching other modules. +The label gives the revision a stable, readable name you can target +(`alembic downgrade my_module@`, `alembic upgrade my_module@head`), and +it is what the Doctor's migration list shows as the owning module. It does +**not** isolate the module's history. `alembic revision --autogenerate` sets +`down_revision` to the current head, so every module's first revision chains +linearly off the previous one, and a label on a revision that has a +`down_revision` does not make it a separate branch. `