Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,11 @@ hence `SM022`/`SM023`. See `docs/module-authoring.md` § Styling.
`register_settings` → `register_menu_items` / `register_permissions` / `register_feature_flags` / `register_event_handlers` / `register_health_checks` / `register_public_routes` → `register_exception_handlers` → `register_middleware` → `register_routes(api_router, view_router)` → async `on_startup` / `on_shutdown` (reverse order). `register_public_routes(registry)` lets a module exempt anonymous/read-only routes (STAC/OGC, webhooks) from `AuthMiddleware`; rules are method-aware (`registry.add_regex(r"…/tilejson$", methods={"GET"})`), so a GET read route can be public while sibling POST/PATCH mutations under the same prefix stay gated. See [docs/framework/public-routes.md](docs/framework/public-routes.md).

**Middleware pipeline** (Starlette `add_middleware` is LIFO — last added runs first). Execution order on a request:
`(ProxyHeaders, if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → GZip → SecurityHeaders → Session → <module middleware> → Tenant (opt-in) → Locale → InertiaLayoutData → app`. `GZip` compresses any response over 500 bytes, including the `/static` mount — the built CSS is ~139 KB raw versus ~21 KB gzipped, and uncompressed assets dominated cold page load. `ProxyHeaders` (uvicorn's `ProxyHeadersMiddleware`) is installed only when `SM_TRUSTED_PROXY` is set, sitting outermost so the `X-Forwarded-*`-corrected scheme/client IP reach everything downstream (request logs and Inertia's absolute page url). When two modules add middleware at the same dependency tier, the module that sorts **later** wraps outermost. Use `depends_on` to express relative order — don't rely on names.
`(ProxyHeaders, if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → GZip → SecurityHeaders → Session → <module middleware> → Tenant (opt-in) → Locale → InertiaLayoutData → CommitBeforeResponse → app`. `GZip` compresses any response over 500 bytes, including the `/static` mount — the built CSS is ~139 KB raw versus ~21 KB gzipped, and uncompressed assets dominated cold page load. `ProxyHeaders` (uvicorn's `ProxyHeadersMiddleware`) is installed only when `SM_TRUSTED_PROXY` is set, sitting outermost so the `X-Forwarded-*`-corrected scheme/client IP reach everything downstream (request logs and Inertia's absolute page url). When two modules add middleware at the same dependency tier, the module that sorts **later** wraps outermost. Use `depends_on` to express relative order — don't rely on names.

**Database**: per-module `Base` via `create_module_base("<name>")`. Every module owns its own `MetaData` (so Alembic autogenerate can attribute tables to a module), but all tables live in the host's single schema. `__tablename__` must be prefixed with the module name to avoid collisions (`orders_order`). Postgres and SQLite share the same layout.

Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (bypass with `stmt.execution_options(include_deleted=True)`), `MultiTenantMixin`, `VersionedMixin`. 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.
Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (bypass with `stmt.execution_options(include_deleted=True)`), `MultiTenantMixin`, `VersionedMixin`. 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. 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 = ("<module_name>",)` to enable per-module `downgrade <module>@base`.

Expand Down
13 changes: 11 additions & 2 deletions docs/framework-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ InertiaLayoutDataMiddleware

```
(ProxyHeaders) → CorrelationId → RequestLogging → SecurityHeaders → Session
→ <modules> → Tenant → Locale → InertiaLayoutData → app
→ <modules> → Tenant → Locale → InertiaLayoutData → CommitBeforeResponse → app
```

`ProxyHeaders` is installed only when `SM_TRUSTED_PROXY` is set (uvicorn's
Expand Down Expand Up @@ -191,7 +191,16 @@ Each request opens one session:
- Read-only requests exit via rollback — cheaper, and keeps the session out of write-side profiling.
- Exceptions always rollback.

Service code should not call `session.commit()` directly. Flush for intermediate reads if you need DB-assigned values, then let the dependency commit.
Service code should not call `session.commit()` directly. Flush for intermediate reads if you need DB-assigned values, then let the framework commit.

The commit lands in `CommitBeforeResponseMiddleware`, which intercepts the ASGI
`http.response.start` message — the last point still inside the request. That is
what makes create-then-immediately-use work: FastAPI runs a `yield` dependency's
exit code after the response has been delivered, so committing there let a
follow-up request open a fresh session and 404 on a row that was already
returned to the client (GH #257). `get_db` still finalizes in its own exit code
when the middleware is absent, and the session is finalized exactly once either
way.

## Inertia

Expand Down
4 changes: 3 additions & 1 deletion docs/guide/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ This generates a publishable starter module at `modules/orders/` with:
- `orders/settings.py` — the module's `pydantic_settings` settings class.
- `orders/endpoints/api.py` — starter REST endpoints at `/api/orders`.
- `orders/pages/` — empty page dir; add `.tsx` pages (and a `register_routes` view router) as you build views.
- `tests/test_module.py` — pytest smoke test.
- `tests/test_orders.py` — pytest smoke test. Named after the package so a repo
with several modules doesn't collide at collection: without `tests/__init__.py`,
pytest derives the test module's name from the basename alone.

You add the domain model (`models.py`), DTOs (`contracts/`), service, Inertia views, and pages yourself — the [first-module guide](/guide/first-module) walks through that.

Expand Down
32 changes: 27 additions & 5 deletions framework/cli/simple_module_cli/new.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@
_ALEMBIC = ("uv", "run", "alembic")


def _alembic_argv(target: Path, host_dir: Path) -> list[str]:
"""Alembic argv to run **from the project root** (``target``).

Never from ``host/``. ``BootstrapSettings`` reads ``.env`` relative to the
cwd, and the scaffolded default database URL is itself root-relative
(``sqlite+aiosqlite:///./host/app.db``), so bootstrapping from ``host/``
migrates ``host/host/app.db`` while the app — and ``make migrate``, which
now also runs from the root — use ``host/app.db``. Same defect as GH #262,
one step earlier: the scaffold's own first migration lands in a database
nothing else reads.

In the flat ``create-host`` layout the host *is* the project root, so the
ini path collapses to ``alembic.ini``.
"""
if host_dir == target:
return [*_ALEMBIC, "-c", "alembic.ini"]
return ["uv", "run", "--project", "host", "alembic", "-c", "host/alembic.ini"]


class Db(StrEnum):
sqlite = "sqlite"
postgres = "postgres"
Expand Down Expand Up @@ -164,27 +183,30 @@ def new_project(
)
return

_bootstrap_initial_migration(host_dir)
alembic = _alembic_argv(target, host_dir)
_bootstrap_initial_migration(target, host_dir, alembic)
# `heads` (plural) applies every per-module branch head; `head` (singular)
# errors once a second module ships its own migration branch label.
subprocess.run([*_ALEMBIC, "upgrade", "heads"], cwd=host_dir, check=False)
subprocess.run([*alembic, "upgrade", "heads"], cwd=target, check=False)
typer.echo("\nSetup complete. Run `make dev` in the new directory.")
typer.echo("To run the full stack in containers instead: make docker-up")
if "background_tasks" in resolved:
typer.echo("For background jobs, also run: docker compose up -d redis worker beat")


def _bootstrap_initial_migration(host_dir: Path) -> None:
def _bootstrap_initial_migration(target: Path, host_dir: Path, alembic: list[str]) -> None:
"""Autogenerate the baseline migration if the scaffold ships none.

Without a real revision, ``alembic upgrade head`` is a silent no-op
against an empty schema — the bundled modules' tables never exist.

Runs from ``target`` (the project root) — see :func:`_alembic_argv`.
"""
versions_dir = host_dir / "migrations" / "versions"
if any(p.name != "__init__.py" for p in versions_dir.glob("*.py")):
return
subprocess.run(
[*_ALEMBIC, "revision", "--autogenerate", "-m", "initial schema"],
cwd=host_dir,
[*alembic, "revision", "--autogenerate", "-m", "initial schema"],
cwd=target,
check=False,
)
6 changes: 5 additions & 1 deletion framework/cli/simple_module_cli/templates/host/alembic.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
[alembic]
script_location = migrations
# Resolve script_location relative to this ini file, not the invocation cwd,
# so `alembic -c host/alembic.ini ...` works from the repo root. Running from
# the repo root is what lets alembic read the repo-root .env — and therefore
# the same SM_DATABASE_URL the app itself uses.
script_location = %(here)s/migrations
sqlalchemy.url =

[loggers]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// What the installed Python modules contribute to the frontend build.
//
// Split out of vite.config.ts to keep each file under the 300-line cap and to
// give this one job a name. Everything here is derived from the two files
// `smpy gen-pages` writes: `modules.manifest.json` (name -> absolute pages/
// dir) and the richer `modules.assets.json`. Both are absent until gen-pages
// has run, which is a normal state — a fresh checkout resolves to empty.
import fs from 'node:fs';
import path from 'node:path';

export type Alias = { find: string; replacement: string };

export type ModuleAssetIndex = {
fsAllow: string[];
optimizeEntries: string[];
pkgJsonPaths: string[];
/** `<dir><sep>` prefixes for module-owned TSX (pages/ and components/). */
pagesPrefixes: string[];
aliases: Alias[];
npmNames: Set<string>;
};

export function loadModuleAssets(clientAppDir: string): ModuleAssetIndex {
// Load the module pages manifest written by the Python host at boot.
// Each entry points at an absolute pages/ directory — typically inside a
// pip-installed module wheel. Vite needs these in server.fs.allow so the
// dev server can read files outside the host root, and in
// optimizeDeps.entries so its dependency scanner discovers bare imports
// from wheel-installed pages and pre-bundles them.
//
// We also collect each module's package.json — wheels embed it next to
// the Python package (one level up from pages/, force-included by Hatch),
// while editable/workspace installs leave it at the source-tree module
// root (two levels up). We accept either. The dep walk in
// `collectOptimizeIncludes` uses it to reach packages a module's pages
// import directly (`sonner`, `lucide-react`, `maplibre-gl`, …). Without
// this seed, Vite's pre-bundler never sees those bare specifiers and Node
// module resolution walks up from inside .venv/site-packages — never
// reaching host/client_app/node_modules.
const manifestPath = path.resolve(clientAppDir, 'modules.manifest.json');
const fsAllow: string[] = [];
const optimizeEntries: string[] = [];
const pkgJsonPaths: string[] = [];
const pagesPrefixes: string[] = [];
if (fs.existsSync(manifestPath)) {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record<string, string>;
for (const pagesDir of Object.values(manifest)) {
const pkgDir = path.dirname(pagesDir);
fsAllow.push(pkgDir);
optimizeEntries.push(path.join(pagesDir, '**/*.tsx'));
pagesPrefixes.push(pagesDir + path.sep);
for (const candidate of [
path.join(pkgDir, 'package.json'),
path.join(path.dirname(pkgDir), 'package.json'),
]) {
if (fs.existsSync(candidate)) {
pkgJsonPaths.push(candidate);
break;
}
}
}
}

// Three things come out of modules.assets.json.
//
// 1. `server.fs.allow` entries. The dev server must be allowed to read each
// module's package dir. Read from modules.assets.json rather than
// modules.manifest.json because the manifest is keyed off `pages/`, so a
// module shipping only CSS never appears in it.
//
// 2. A convenience `#module/<pkg>` alias. This is NOT required by
// `modules.generated.css` — that file imports module stylesheets by
// absolute path, so it resolves with no alias configured at all. Emitting
// an alias there made a generated file depend on this hand-owned config,
// and since `vite.config.ts` is scaffolded once and then owned by the app,
// a Python-only version bump broke every host scaffolded earlier
// (GH issue #253). The alias stays because it costs nothing.
//
// 3. An `<npm_name>` alias per module, so one module can import another's
// TS/TSX by package name. Aimed at the module's *Python package* dir —
// a wheel ships `site-packages/foo/**` and nothing above it, so the
// source-tree module root is not a target both layouts have. Needed in
// both: a wheel module is never in node_modules, and npm symlinks a
// workspace member onto the module root, one level too high.
// See docs/module-authoring.md § Importing another module's TS/TSX.
//
// `@tailwindcss/vite` builds its CSS import resolver with
// `createResolver({ ...config.resolve, ... })`, so `resolve.alias` governs
// CSS `@import` as well as JS — verified against @tailwindcss/vite 4.2.4.
type ModuleAsset = {
package_name: string;
package: string;
npm_name?: string | null;
// Wheel modules ship widgets here; the pages-keyed manifest never sees them.
components?: string | null;
};
const aliases: { find: string; replacement: string }[] = [];
const npmNames = new Set<string>();
const assetsPath = path.resolve(clientAppDir, 'modules.assets.json');
let assets: Record<string, ModuleAsset> = {};
try {
assets = JSON.parse(fs.readFileSync(assetsPath, 'utf-8'));
} catch {
// Absent until `smpy gen-pages` runs — proceed with no aliases.
}
for (const entry of Object.values(assets)) {
aliases.push({ find: `#module/${entry.package_name}`, replacement: entry.package });
if (entry.npm_name) {
aliases.push({ find: entry.npm_name, replacement: entry.package });
npmNames.add(entry.npm_name);
}
if (!fsAllow.includes(entry.package)) fsAllow.push(entry.package);
// Without this the bare-specifier fallback below skips widgets, and a
// component's `@simple-module-py/ui` import fails to resolve.
if (entry.components) {
const prefix = entry.components + path.sep;
if (!pagesPrefixes.includes(prefix)) pagesPrefixes.push(prefix);
optimizeEntries.push(path.join(entry.components, '**/*.tsx'));
}
}
// Keep the alias list in a stable, longest-first order. Vite matches a string
// `find` on exact equality or a `/`-bounded prefix, so `#module/gis` could not
// swallow `#module/gis_extra` in any order — this is just determinism, not a
// correctness fix.
aliases.sort((a, b) => b.find.length - a.find.length);
return { fsAllow, optimizeEntries, pkgJsonPaths, pagesPrefixes, aliases, npmNames };
}
Loading