diff --git a/.env.example b/.env.example index c1472369..b624c73c 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,7 @@ SM_VITE_DEV_URL=http://localhost:5050 # SM_AUTH_PUBLIC_PATHS=["/api/integrations/webhook", "/status"] # First-boot admin seed (optional). Only applied when the users table is empty. -# Leave unset and use `uv run sm-users create-admin` instead if you prefer. +# Leave unset and use `uv run smpy users create-admin` instead if you prefer. # SM_USERS_BOOTSTRAP_EMAIL=admin@example.com # SM_USERS_BOOTSTRAP_PASSWORD=changeme # Optional second non-admin seed user (handy in dev): diff --git a/CLAUDE.md b/CLAUDE.md index d351c508..aadb8405 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,8 +31,8 @@ All day-to-day tasks go through `make`: | Command | Purpose | |---|---| | `make install` | Install Python + JS deps | -| `make dev` | Docker up + regen module pages + API (8000) and Vite (5173) in parallel | -| `make kill` | Free ports 8000/5173 | +| `make dev` | Docker up + regen module pages + API (8000) and Vite (5050) in parallel | +| `make kill` | Free ports 8000/5050/5173 | | `make test` | Run `test-py` then `test-js` (e2e excluded by default) | | `make test-py` / `make test-js` | Run a single suite | | `make test-e2e` | Playwright smoke tests (requires `make dev` running + `uv run playwright install chromium`) | @@ -42,7 +42,7 @@ All day-to-day tasks go through `make`: | `make new-module name=` | Scaffold a new module package end-to-end | | `make gen-pages` | Regenerate `host/client_app/modules.{manifest.json,generated.ts,generated.css}` from installed modules | -Single test: `uv run pytest path/to/test_file.py::test_name` (root `pyproject.toml` sets `asyncio_mode=auto` and `-m 'not e2e'`). A single JS test: `npx vitest run `. +Single test: `uv run pytest path/to/test_file.py::test_name` (root `pyproject.toml` sets `asyncio_mode=auto` and `-m 'not e2e and not perf'`). A single JS test: `npx vitest run `. Entry point: `host/main.py` (`uv run --project host uvicorn host.main:app --reload`). Alembic runs from the repo root (`host/alembic.ini`) so it shares the `.env` / `SM_DATABASE_URL` with the API. diff --git a/README.md b/README.md index c6cedb18..5386a1c8 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # simple_module_python -A modular-monolith framework for Python. Each feature lives in its own self-contained module — its own SQLModel tables, schema, FastAPI endpoints, React pages — but everything ships as one FastAPI + Inertia.js + React app. No microservice tax, no API-client glue; just plugin modules that compose at boot. +A modular-monolith framework for Python. Each feature lives in its own self-contained module — its own SQLModel tables, FastAPI endpoints, React pages — but everything ships as one FastAPI + Inertia.js + React app. No microservice tax, no API-client glue; just plugin modules that compose at boot. ## Stack - **Backend:** Python 3.12, FastAPI, SQLModel (SQLAlchemy async + Pydantic), Alembic - **Frontend:** Inertia.js + React + Tailwind CSS 4, Vite HMR - **UI:** shadcn/ui primitives + emerald/teal design tokens, Sora display font, DM Sans body, JetBrains Mono code -- **Auth:** Local user management (email+password, cookie-based sessions) via fastapi-users +- **Auth:** Pluggable providers — local users (email+password + OAuth/OIDC: Google, GitHub, Microsoft/Entra) via fastapi-users, or Keycloak OIDC SSO; cookie sessions or bearer tokens resolved through a principal-resolver chain - **Tooling:** uv workspaces, Ruff, ty, Biome, pytest ## Use in a new project @@ -35,7 +35,7 @@ make install # 2. Copy env template (defaults work for local SQLite dev) cp .env.example .env -# 3. Start Postgres (skip if using SQLite — the default .env uses SQLite) +# 3. Start the shared dev-services stack — Postgres/Redis/MinIO (skip if using the default SQLite) make docker-up # 4. Run migrations @@ -71,9 +71,11 @@ The new module is automatically discovered (via Python entry points), its routes ``` framework/ + cli/ # smpy CLI — scaffolding, skills, package updates core/ # module system, discovery, events, diagnostics db/ # per-module Base, session, mixins, listeners hosting/ # app_builder, middleware, settings, Inertia glue + testing/ # shared pytest fixtures + helpers modules/ # plugin modules (auth, dashboard, users, settings, ...) host/ main.py # FastAPI entry point @@ -103,7 +105,7 @@ docs/ | `make migration msg="..."` | Autogenerate a new migration | | `make new-module name=` | Scaffold a new module | | `make kill` | Stop any running dev servers (ports 8000, 5050, 5173) | -| `make docker-up` / `docker-down` | Manage the Postgres container (SQLite needs no Docker) | +| `make docker-up` / `docker-down` | `docker-up` brings up the shared dev-services stack (Postgres/Redis/MinIO); `docker-down` stops only this repo's worker/beat (SQLite needs no Docker) | ## Configuration @@ -202,17 +204,19 @@ SM_USERS_SMTP_TLS=true ## Architecture - **Modules**: discovered via Python entry points at boot. Each module subclasses `ModuleBase` and opts into the lifecycle hooks it needs (`register_routes`, `register_menu_items`, `register_permissions`, `register_middleware`, `on_startup`, ...). -- **Database isolation**: PostgreSQL → one schema per module. SQLite → single schema, `__tablename__` prefixed with the module name. +- **Database**: a single shared schema on both Postgres and SQLite. Each module owns its own `MetaData` (so Alembic can attribute tables to it), and `__tablename__` is prefixed with the module name (`orders_order`) to avoid collisions. - **Middleware pipeline** (LIFO order of execution): CorrelationId → RequestLogging → SecurityHeaders → Session → `` → Tenant (opt-in) → Locale → InertiaLayoutData → app. - **Diagnostics**: `make doctor` runs a static analyzer over installed modules looking for orphan pages, phantom renders, empty modules, framework/plugin coupling, migration drift, and locale-file consistency. Errors fail the boot in production. - **Internationalization**: per-module `locales/.json` files merged at boot into `I18nRegistry`. Frontend uses `i18next` with type-safe keys; backend uses `Babel` for CLDR plurals. Locale resolved per request via cookie → `Accept-Language` → `SM_I18N_DEFAULT_LOCALE`. See `docs/framework-conventions.md` → Internationalization. -Deeper dives in `docs/plans/`: +Full documentation lives in [`docs/`](docs/index.md) — a VitePress site covering the guide, framework internals, database, frontend, testing, every bundled module, and reference. When conventions are ambiguous, the authoritative single-pagers are the source of truth: -- [Module lifecycle hooks](docs/superpowers/specs/2026-04-13-module-lifecycle-hooks-design.md) -- [Alembic migrations design](docs/plans/2026-04-13-alembic-migrations-design.md) -- [DB state refactor](docs/plans/2026-04-13-eliminate-global-mutable-db-state-design.md) -- [DX hardening (latest)](docs/plans/2026-04-14-dx-hardening-design.md) +- [Framework conventions](docs/framework-conventions.md) +- [Module authoring](docs/module-authoring.md) +- [E2E testing](docs/e2e-testing.md) +- [Release playbook](docs/release.md) + +Historical, point-in-time design docs live under [`docs/plans/`](docs/plans/) and [`docs/superpowers/`](docs/superpowers/). ## Contributing diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 7d76e59b..e65cfcd0 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -121,6 +121,8 @@ export default defineConfig({ { text: "Middleware pipeline", link: "/framework/middleware" }, { text: "Settings & app.state", link: "/framework/settings" }, { text: "Permissions", link: "/framework/permissions" }, + { text: "Principal resolvers", link: "/framework/principal-resolvers" }, + { text: "Public routes", link: "/framework/public-routes" }, { text: "Events", link: "/framework/events" }, { text: "Internationalization", link: "/framework/i18n" }, ], @@ -173,11 +175,13 @@ export default defineConfig({ { text: "Overview", link: "/modules/" }, { text: "auth", link: "/modules/auth" }, { text: "users", link: "/modules/users" }, + { text: "keycloak", link: "/modules/keycloak" }, { text: "permissions", link: "/modules/permissions" }, { text: "settings", link: "/modules/settings" }, { text: "feature_flags", link: "/modules/feature_flags" }, { text: "file_storage", link: "/modules/file_storage" }, { text: "background_tasks", link: "/modules/background_tasks" }, + { text: "audit_log", link: "/modules/audit_log" }, { text: "dashboard", link: "/modules/dashboard" }, ], }, diff --git a/docs/database/migrations.md b/docs/database/migrations.md index 8a56fb7c..bff07a36 100644 --- a/docs/database/migrations.md +++ b/docs/database/migrations.md @@ -1,11 +1,11 @@ # Migrations -All migrations live in `migrations/versions/` in your app — **not** in module packages. Alembic runs from the app root (`alembic.ini`) and shares the app's `.env` / `SM_DATABASE_URL`. +All migrations live in `host/migrations/versions/` — **not** in module packages. Alembic runs from the repo root via `host/alembic.ini` and shares the app's `.env` / `SM_DATABASE_URL`. ## Why centralized? -- **Dependency ordering is global.** If `invoices` depends on `orders.order.id`, their migrations must order correctly. One linear Alembic history enforces this. -- **Autogenerate sees everything.** `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. +- **Dependency ordering is global.** If `invoices` depends on `orders_order.id`, their migrations must order correctly. One linear Alembic history enforces this. +- **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`. @@ -15,10 +15,10 @@ Each module's *first* migration sets `branch_labels = ("",)` so you ### Create a migration ```bash -uv run alembic revision --autogenerate -m "add orders tables" +make migration msg="add orders tables" ``` -The resulting file lands in `migrations/versions/XXXX_add_orders_tables.py`. +This runs `alembic -c host/alembic.ini revision --autogenerate` from the repo root. The resulting file lands in `host/migrations/versions/XXXX_add_orders_tables.py`. **Always open and read the generated file** before committing. Autogenerate is good but not perfect: @@ -32,14 +32,14 @@ The resulting file lands in `migrations/versions/XXXX_add_orders_tables.py`. make migrate ``` -Runs `alembic upgrade head`. Idempotent. +Runs `alembic -c host/alembic.ini upgrade heads`. Idempotent. ### Downgrade ```bash -uv run alembic downgrade -1 # back one revision -uv run alembic downgrade # to a specific revision -uv run alembic downgrade orders@base # back to the state before the orders module existed +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. @@ -49,7 +49,7 @@ uv run alembic downgrade orders@base # back to the state before the orders m When you scaffold a module with `smpy create-module`, the *first* autogenerate revision produces a file that needs this marker added by hand: ```python -# migrations/versions/XXXX_add_orders_tables.py +# host/migrations/versions/XXXX_add_orders_tables.py revision = "..." down_revision = "..." @@ -61,27 +61,32 @@ Once the marker is in place, all future `orders` migrations inherit the branch. ## Alembic environment setup -`migrations/env.py` (in your app) looks roughly like: +`host/migrations/env.py` looks roughly like: ```python -from simple_module_db.base import build_module_metadata -from simple_module_db.migration_support import make_include_object +from simple_module_db import ( + build_module_metadata, + make_include_object, + make_process_revision_directives, + render_item, +) target_metadata = build_module_metadata() -include_object = make_include_object() +include_object = make_include_object(target_metadata) +process_revision_directives = make_process_revision_directives(target_metadata) context.configure( target_metadata=target_metadata, include_object=include_object, - compare_type=True, - compare_server_default=True, + render_item=render_item, + process_revision_directives=process_revision_directives, ) ``` - `target_metadata` — union of every module's `MetaData`. -- `include_object` — filters out system tables (`alembic_version`) and any host-owned tables you don't want tracked. -- `compare_type=True` — detects type changes (e.g. `VARCHAR(50)` → `VARCHAR(100)`). -- `compare_server_default=True` — detects default-value changes. +- `include_object` — accepts only tables present in `target_metadata`, so autogenerate never diffs system tables (`alembic_version`) or any host-owned tables outside the module system. +- `render_item` — collapses SQLModel's `AutoString` to `sa.String` and renders `StrEnum` columns with `values_callable` so generated migrations are importable. +- `process_revision_directives` — re-emits expression-based (functional) indexes that autogenerate silently drops under SQLite. ## Boot-time migration check @@ -100,17 +105,17 @@ Fires as a warning when a module's model declares a table that doesn't appear in - You renamed a table but the old migration still references the old name. - You used `__abstract__ = True` somewhere it shouldn't be. -The dev-mode boot log prints the offending table names. Resolution: run `uv run alembic revision --autogenerate -m "..."`, review, `make migrate`. +The dev-mode boot log prints the offending table names. Resolution: run `make migration msg="..."`, review, `make migrate`. ## Cross-module foreign keys -If `invoices` has an FK to `orders.order.id`: +If `invoices` has an FK to `orders_order.id`: - Alembic will emit `ADD CONSTRAINT` in the invoices table's migration. - The migration that creates `invoices_invoice` must come **after** the one that creates `orders_order` in linear history. -- `smpy create-module` + `uv run alembic revision --autogenerate` handle this naturally as long as `depends_on` is correct in `ModuleMeta`. +- `smpy create-module` + `make migration` handle this naturally as long as `depends_on` is correct in `ModuleMeta`. -On Postgres, cross-schema FKs work natively (`orders.order.id ← invoices.invoice.order_id`). +All tables share the host's single schema on both Postgres and SQLite, so a cross-module FK is just an ordinary same-schema reference (`orders_order.id ← invoices_invoice.order_id`). On SQLite, FKs are off by default but the test suite enables them; in production SQLite use (rare), set `PRAGMA foreign_keys = ON`. @@ -153,7 +158,7 @@ Keep merges small; a merge revision with its own `op.*` logic is a code smell. When you autogenerate a migration for a freshly-added module, autogenerate writes `op.create_table(...)` for every table in the module's `MetaData`. Inspect: -- Are the schema / table names right for your provider (`orders.order` on Postgres vs `orders_order` on SQLite)? +- Are the table names right (the module-prefixed `orders_order`, identical on Postgres and SQLite)? - Do indexes and constraints have stable names? Rename via `name=...` on the model if not. - Did autogenerate also pick up any **other** module's tables? That means you forgot `make migrate` after the last scaffold. Squash the file down to just this module's changes. @@ -168,7 +173,7 @@ async def test_migration_up_then_down(tmp_path): from alembic import command db_url = f"sqlite:///{tmp_path}/migrate_test.db" - cfg = Config("alembic.ini") + cfg = Config("host/alembic.ini") cfg.set_main_option("sqlalchemy.url", db_url) command.upgrade(cfg, "head") diff --git a/docs/database/mixins.md b/docs/database/mixins.md index 3e290605..e94a12cd 100644 --- a/docs/database/mixins.md +++ b/docs/database/mixins.md @@ -19,18 +19,18 @@ class Order( ... ``` -Each mixin adds columns and attaches SQLAlchemy event listeners. The session dependency (`get_db`) wires the request user / tenant into those listeners so writes are stamped automatically. +Each mixin adds columns; the framework's SQLAlchemy event listeners (registered once per engine in `simple_module_db.listeners`) populate them. Auth and tenant middleware push the current user / tenant into contextvars (`current_user_id`, `current_tenant_id`) that those listeners read, so writes are stamped automatically. ## `AuditMixin` Adds: -- `created_at: datetime` — set on INSERT. -- `updated_at: datetime` — set on INSERT and on every UPDATE. -- `created_by: int | None` — stamped from `request.state.principal.user_id`. -- `updated_by: int | None` — same, on every update. +- `created_at: datetime` — populated both Python-side (`default_factory`) and server-side (`server_default=func.now()`), so a freshly-instantiated instance can be serialized before flush. +- `updated_at: datetime | None` — `None` until the first UPDATE; set by the audit listener and by the column's `onupdate=func.now()`. +- `created_by: str | None` — stamped from the `current_user_id` contextvar. +- `updated_by: str | None` — same, on insert and on every update. -A `before_insert` listener and a `before_update` listener populate these. When no principal is in scope (e.g. a migration data-migration or a boot-time seeder), `created_by` and `updated_by` stay null. The timestamps always fire. +The `before_flush` listener populates `created_by`/`updated_by` and `updated_at`. When no principal is in scope (e.g. a migration data-migration or a boot-time seeder), `created_by` and `updated_by` stay null. Use on every table that participates in business workflows. Skip on pure enum / lookup tables. @@ -40,11 +40,11 @@ Adds: - `is_deleted: bool` — default `False`. - `deleted_at: datetime | None`. -- `deleted_by: int | None`. +- `deleted_by: str | None`. -On `session.delete(instance)`, a `before_delete` listener converts the delete into an UPDATE that sets the three fields. The row stays. +On `session.delete(instance)`, the `before_flush` listener cancels the hard delete and re-adds the instance with the three fields set, so the row stays as an UPDATE. -Selects auto-filter soft-deleted rows. A `before_compile` query rewrite appends `WHERE is_deleted = FALSE` to any query touching a `SoftDeleteMixin` table. +Selects auto-filter soft-deleted rows. A `do_orm_execute` listener attaches a per-mapper `with_loader_criteria(cls, cls.is_deleted.is_(False))` to every SELECT touching a `SoftDeleteMixin` table. ### Bypass for admin / audit @@ -76,40 +76,25 @@ Use sparingly — audit trails and downstream systems may depend on historical r Adds: -- `tenant_id: str` — populated from `request.state.tenant_id` (set by `TenantMiddleware`). +- `tenant_id: str | None` — Python-side optional (so callers don't have to thread the tenant through), but the **column is non-nullable**. `TenantMiddleware` sets `request.state.tenant_id` and pushes it into the `current_tenant_id` contextvar; the `before_flush` listener reads that contextvar to stamp the column. ### Automatic filtering -When `SM_MULTI_TENANT=true` and a request has an active tenant, selects auto-filter: `WHERE tenant_id = :current_tenant`. The filter runs for every `SELECT` that touches a `MultiTenantMixin` table. +When `SM_MULTI_TENANT=true` and a request has an active tenant, the `do_orm_execute` listener attaches `with_loader_criteria(cls, cls.tenant_id == tenant_id)` to every SELECT touching a `MultiTenantMixin` table. ### Automatic stamping -INSERTs populate `tenant_id` from the request context. Cross-tenant writes raise `ValueError` — if a session somehow ends up with two tenants' rows, the commit fails loudly. +INSERTs populate `tenant_id` from the `current_tenant_id` contextvar. Creating a row for a different tenant than the active one — or changing `tenant_id` on an existing row — raises `TenantIsolationError`. ### Bypass -Admin operations that span tenants (billing consolidation, platform-wide reports) need to opt out: - -```python -stmt = select(Order).execution_options(skip_tenant_filter=True) -``` - -Or run inside a context manager that clears the tenant: - -```python -from simple_module_db.tenancy import no_tenant - -async with no_tenant(): - rows = (await session.exec(select(Order))).all() -``` - -Use these escape hatches **rarely** and in well-named admin endpoints; they defeat the primary isolation guarantee. +A row inserted outside any tenant context (no active `current_tenant_id`) is stamped `None` and, because the column is non-nullable, fails at the DB rather than silently leaking. There is no per-statement `skip_tenant_filter` option or `no_tenant()` helper today; cross-tenant admin operations run outside a tenant-scoped request (e.g. a CLI or background worker where `current_tenant_id` is unset). ## `VersionedMixin` Adds: -- `version: int` — default `1`, incremented on every UPDATE via `before_update` listener. +- `version: int` — default `1`, incremented on every UPDATE by the `before_flush` listener. Useful for optimistic concurrency control: check the version didn't change between read and write, abort if it did. diff --git a/docs/database/models.md b/docs/database/models.md index 8a71b24f..d324bebc 100644 --- a/docs/database/models.md +++ b/docs/database/models.md @@ -53,7 +53,7 @@ class OrderLine(Base, table=True): order: "Order" = Relationship(back_populates="lines") ``` -For cross-module foreign keys, use the `contracts/` schema of the target module to know the expected column shape — but remember every module manages its own migrations, so the target table must exist at migration time. Cross-module FKs also complicate module uninstall; prefer application-level references where possible. +For cross-module foreign keys, use the `contracts/` schema of the target module to know the expected column shape — but remember migrations are centralized (one linear Alembic history under `host/migrations/`), so the target table's migration must come first. Cross-module FKs also complicate module uninstall; prefer application-level references where possible. ## DTOs (schemas) @@ -132,7 +132,7 @@ class Order(Base, table=True): class OrderLine(Base, table=True): id: int | None = Field(default=None, primary_key=True) - order_id: int = Field(foreign_key="orders.order.id") + order_id: int = Field(foreign_key="orders_order.id") order: Order = Relationship(back_populates="lines") ``` diff --git a/docs/database/per-module-base.md b/docs/database/per-module-base.md index 87dfae2c..430882f4 100644 --- a/docs/database/per-module-base.md +++ b/docs/database/per-module-base.md @@ -23,7 +23,7 @@ The same migrations apply to Postgres and SQLite. There is no provider branching ## The `build_module_metadata()` function -Alembic's autogenerate needs a **single** `MetaData` object describing every table it should manage. Each module has its own — so `migrations/env.py` calls: +Alembic's autogenerate needs a **single** `MetaData` object describing every table it should manage. Each module has its own — so `host/migrations/env.py` calls: ```python from simple_module_db.base import build_module_metadata @@ -37,13 +37,13 @@ If a module has no `models.py`, it contributes nothing — fine. If a module has ## `make_include_object()` -Alembic's `include_object` callback filters which tables `autogenerate` considers. `migrations/env.py` uses `make_include_object()` from `simple_module_db` to: +Alembic's `include_object` callback filters which tables `autogenerate` considers. `host/migrations/env.py` calls `make_include_object(target_metadata)` from `simple_module_db` to: -- **Include** tables from any discovered module's MetaData. -- **Exclude** tables owned by the Alembic runtime itself (`alembic_version`). -- **Exclude** host-owned tables that shouldn't be in a module migration (there are currently none, but the hook is there). +- **Include** only tables present in the combined module `MetaData` (the allowlist is `{t.name for t in metadata.tables.values()}`). +- **Exclude** everything else — tables owned by the Alembic runtime (`alembic_version`) and any host-owned tables that aren't part of an installed module. +- Optionally skip unmodeled foreign-key constraints (`ignore_unmodeled_fks=True` by default) so migration-level cross-module FKs aren't dropped on every autogen run. -If you write a one-off host-level table that autogenerate shouldn't track, extend `make_include_object()` — don't reach into a module's `models.py`. +If you write a one-off host-level table that autogenerate shouldn't track, keep it out of the module metadata — `make_include_object` already excludes anything not in the allowlist. ## Naming rules diff --git a/docs/database/sessions.md b/docs/database/sessions.md index 3e2cf926..ca8941c4 100644 --- a/docs/database/sessions.md +++ b/docs/database/sessions.md @@ -5,13 +5,13 @@ Each request opens exactly one `AsyncSession`. The framework commits only when t ## The `get_db` dependency ```python -# simple_module_db.session -async def get_db(request: Request) -> AsyncIterator[AsyncSession]: - engine = request.app.state.sm.db.engine_for(...) - async with AsyncSession(engine) as session: +# simple_module_db.deps +async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]: + factory = request.app.state.sm.db.session_factory + async with factory() as session: try: yield session - if _has_writes(session): + if _has_pending_writes(session): await session.commit() else: await session.rollback() @@ -26,7 +26,7 @@ Usage in endpoints: from typing import Annotated from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession -from simple_module_db.session import get_db +from simple_module_db.deps import get_db SessionDep = Annotated[AsyncSession, Depends(get_db)] @@ -87,26 +87,26 @@ Flushing sends the INSERT but keeps the transaction open. Rollback still works u ## Manual transactions -If you need finer control — e.g. a background worker that processes many items in its own transactions — use `DatabaseState` directly: +If you need finer control — e.g. a background worker that processes many items in its own transactions — use the `DatabaseState.session_factory` directly: ```python -from simple_module_db.state import DatabaseState +from simple_module_db import DatabaseState async def worker(db: DatabaseState): - async with db.session() as session: + async with db.session_factory() as session: async with session.begin(): # explicit transaction block session.add(...) # commits on exit, rolls back on exception ``` -The `async with session.begin()` pattern opens a sub-transaction you control. Use it in code that runs **outside** a request scope. +The `async with session.begin()` pattern opens a transaction you control. Use it in code that runs **outside** a request scope. ## Sessions and `MultiTenantMixin` -`get_db` captures `request.state.tenant_id` into the session's `info` dict. The tenant listeners read it to filter SELECTs and stamp INSERTs. This is why sessions are **request-scoped** — sharing one across tenants would silently leak data. +The active tenant is carried in the `current_tenant_id` contextvar (set by `TenantMiddleware`, not on the session). The tenant listeners read that contextvar to filter SELECTs and stamp INSERTs. Sessions stay **request-scoped** so they always run under the request's tenant context — sharing one across tenants would silently leak data. -For cross-tenant admin ops, use `no_tenant()` context or the `skip_tenant_filter=True` execution option. See [Mixins → MultiTenantMixin](/database/mixins). +Cross-tenant admin work runs outside a tenant-scoped request (a CLI or worker where `current_tenant_id` is unset). See [Mixins → MultiTenantMixin](/database/mixins). ## Sessions in tests diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index 2c13d4c4..50155963 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -1,9 +1,10 @@ # End-to-End Testing Playwright-driven smoke tests live in [tests/e2e/](../tests/e2e/) — currently -just [`test_settings_ui.py`](../tests/e2e/test_settings_ui.py), which logs in, -navigates to `/settings/modules`, toggles a module setting, and verifies the -change hot-reloads into `app.state` without a server restart. +[`test_settings_ui.py`](../tests/e2e/test_settings_ui.py) (logs in, navigates to +`/settings/modules`, toggles a module setting, and verifies the change +hot-reloads into `app.state` without a server restart) and +[`test_audit_log_ui.py`](../tests/e2e/test_audit_log_ui.py). End-to-end tests are gated behind the `e2e` pytest marker (declared in your app's `pyproject.toml`) and are **excluded from the default @@ -21,7 +22,7 @@ uv run playwright install chromium Then bring up the full stack (in a separate terminal, leave it running): ```bash -docker compose up -d postgres # skip if you're on the default SQLite config +make docker-up # shared dev-services stack (Postgres/Redis/MinIO); skip if on default SQLite make migrate # apply Alembic migrations make dev # FastAPI on :8000 + Vite on :5050 ``` diff --git a/docs/framework-conventions.md b/docs/framework-conventions.md index 287d8ef3..fa75ec52 100644 --- a/docs/framework-conventions.md +++ b/docs/framework-conventions.md @@ -20,7 +20,7 @@ modules// └── pages/ # *.tsx — auto-discovered by Vite ``` -Scaffold a fresh module with `smpy create-module --dest modules/` — it generates all of the above. Then run `uv add ./modules/` to register it on your app. +Scaffold a fresh module with `smpy create-module --dest modules/` — it generates a working subset (`module.py`, `services.py`, `settings.py`, `endpoints/api.py`, an empty `pages/`, plus packaging files); add `models.py`, `deps.py`, `contracts/`, `endpoints/views.py`, and `locales/` as your module needs them. Then run `uv add ./modules/` to register it on your app. ## ModuleMeta @@ -66,6 +66,7 @@ SecurityHeadersMiddleware SessionMiddleware # each module's register_middleware() TenantMiddleware (if multi_tenant=True) +LocaleMiddleware InertiaLayoutDataMiddleware # Added first → last executed ``` @@ -74,7 +75,7 @@ InertiaLayoutDataMiddleware ``` CorrelationId → RequestLogging → SecurityHeaders → Session - → → Tenant → InertiaLayoutData → app + → → Tenant → Locale → InertiaLayoutData → app ``` ### Module-registered middleware ordering @@ -99,7 +100,8 @@ once at boot. Consumers read `request.app.state.sm.` — never raw `app.s attributes for framework-owned state. Fields: `settings`, `db`, `event_bus`, `menu_registry`, `permissions`, -`feature_flags`, `health_registry`, `i18n_registry`, `inertia_config`, `modules`. +`feature_flags`, `health_registry`, `public_routes`, `i18n_registry`, +`inertia_config`, `modules`. Two attributes are intentionally kept outside `Services`: @@ -211,7 +213,7 @@ Service code should not call `session.commit()` directly. Flush for intermediate Sidebar items can also set `group="