From 3bbb58f163f6d49ac15fbcfc0eaf09ca99d07c1b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 05:22:06 +0000 Subject: [PATCH] docs: scaffold VitePress site with detailed guides Add a VitePress-powered documentation site under docs/ organized into guide / framework / database / frontend / testing / reference sections. Covers installation, module authoring end-to-end, discovery and lifecycle hooks, middleware pipeline ordering, SQLModel conventions, per-module Base and mixins, session commit semantics, migrations, Inertia page discovery and shared props, i18n, permissions, events, pytest fixtures, make targets, env vars, diagnostic codes, and deployment. Links through to the pre-existing authoritative single-page docs (framework-conventions, module-authoring, e2e-testing, release). Excludes plans/ superpowers/ release-notes/ from the VitePress build (pre-existing design docs contain unescaped angle brackets that vue-sfc chokes on). .gitignore covers .vitepress/cache and .vitepress/dist. https://claude.ai/code/session_01HFcdxte9HSQpdG8BeNXAp6 --- .gitignore | 4 + docs/.vitepress/config.ts | 180 ++ docs/README.md | 55 + docs/database/migrations.md | 178 ++ docs/database/mixins.md | 151 ++ docs/database/models.md | 171 ++ docs/database/per-module-base.md | 100 ++ docs/database/sessions.md | 136 ++ docs/framework/discovery.md | 118 ++ docs/framework/events.md | 156 ++ docs/framework/i18n.md | 187 +++ docs/framework/lifecycle.md | 211 +++ docs/framework/middleware.md | 139 ++ docs/framework/overview.md | 94 ++ docs/framework/permissions.md | 149 ++ docs/framework/settings.md | 174 ++ docs/frontend/inertia.md | 157 ++ docs/frontend/pages.md | 185 ++ docs/frontend/shared-props.md | 190 +++ docs/guide/configuration.md | 113 ++ docs/guide/first-module.md | 291 ++++ docs/guide/installation.md | 117 ++ docs/guide/introduction.md | 66 + docs/guide/project-structure.md | 124 ++ docs/guide/quickstart.md | 110 ++ docs/index.md | 45 + docs/package-lock.json | 2514 ++++++++++++++++++++++++++++ docs/package.json | 16 + docs/reference/deployment.md | 168 ++ docs/reference/diagnostic-codes.md | 69 + docs/reference/env-vars.md | 113 ++ docs/reference/make-commands.md | 108 ++ docs/testing/fixtures.md | 209 +++ docs/testing/overview.md | 98 ++ 34 files changed, 6896 insertions(+) create mode 100644 docs/.vitepress/config.ts create mode 100644 docs/README.md create mode 100644 docs/database/migrations.md create mode 100644 docs/database/mixins.md create mode 100644 docs/database/models.md create mode 100644 docs/database/per-module-base.md create mode 100644 docs/database/sessions.md create mode 100644 docs/framework/discovery.md create mode 100644 docs/framework/events.md create mode 100644 docs/framework/i18n.md create mode 100644 docs/framework/lifecycle.md create mode 100644 docs/framework/middleware.md create mode 100644 docs/framework/overview.md create mode 100644 docs/framework/permissions.md create mode 100644 docs/framework/settings.md create mode 100644 docs/frontend/inertia.md create mode 100644 docs/frontend/pages.md create mode 100644 docs/frontend/shared-props.md create mode 100644 docs/guide/configuration.md create mode 100644 docs/guide/first-module.md create mode 100644 docs/guide/installation.md create mode 100644 docs/guide/introduction.md create mode 100644 docs/guide/project-structure.md create mode 100644 docs/guide/quickstart.md create mode 100644 docs/index.md create mode 100644 docs/package-lock.json create mode 100644 docs/package.json create mode 100644 docs/reference/deployment.md create mode 100644 docs/reference/diagnostic-codes.md create mode 100644 docs/reference/env-vars.md create mode 100644 docs/reference/make-commands.md create mode 100644 docs/testing/fixtures.md create mode 100644 docs/testing/overview.md diff --git a/.gitignore b/.gitignore index 6c612b0b..1d84a4ad 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,10 @@ uploads/ # Vite host/static/dist/ +# VitePress (docs) +docs/.vitepress/cache/ +docs/.vitepress/dist/ + # Auto-generated frontend module manifest (regenerated by the host at boot # or via `make gen-pages`). host/client_app/modules.manifest.json diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts new file mode 100644 index 00000000..2b607852 --- /dev/null +++ b/docs/.vitepress/config.ts @@ -0,0 +1,180 @@ +import { defineConfig } from "vitepress"; + +export default defineConfig({ + title: "simple_module_python", + description: + "A modular-monolith framework for Python: FastAPI + SQLModel + Inertia + React, with plugin modules that compose at boot.", + lastUpdated: true, + cleanUrls: true, + ignoreDeadLinks: true, + + srcExclude: [ + "**/node_modules/**", + "plans/**", + "superpowers/**", + "release-notes/**", + "README.md", + ], + + head: [ + ["meta", { name: "theme-color", content: "#3c82f6" }], + ["meta", { property: "og:title", content: "simple_module_python" }], + [ + "meta", + { + property: "og:description", + content: "Modular-monolith framework for Python", + }, + ], + ], + + themeConfig: { + nav: [ + { text: "Guide", link: "/guide/introduction", activeMatch: "/guide/" }, + { + text: "Framework", + link: "/framework/overview", + activeMatch: "/framework/", + }, + { + text: "Database", + link: "/database/models", + activeMatch: "/database/", + }, + { + text: "Frontend", + link: "/frontend/inertia", + activeMatch: "/frontend/", + }, + { text: "Testing", link: "/testing/overview", activeMatch: "/testing/" }, + { + text: "Reference", + link: "/reference/make-commands", + activeMatch: "/reference/", + }, + ], + + socialLinks: [ + { + icon: "github", + link: "https://github.com/antosubash/simple_module_python", + }, + ], + + search: { provider: "local" }, + + editLink: { + pattern: + "https://github.com/antosubash/simple_module_python/edit/main/docs/:path", + text: "Edit this page on GitHub", + }, + + footer: { + message: "Released under the MIT License.", + copyright: "Copyright © 2026 simple_module_python contributors", + }, + + outline: { level: [2, 3] }, + + sidebar: { + "/guide/": [ + { + text: "Getting Started", + collapsed: false, + items: [ + { text: "Introduction", link: "/guide/introduction" }, + { text: "Installation", link: "/guide/installation" }, + { text: "Quickstart", link: "/guide/quickstart" }, + { text: "Project structure", link: "/guide/project-structure" }, + { text: "Configuration", link: "/guide/configuration" }, + { text: "Your first module", link: "/guide/first-module" }, + ], + }, + { + text: "Existing deep dives", + collapsed: true, + items: [ + { + text: "Framework conventions", + link: "/framework-conventions", + }, + { text: "Module authoring", link: "/module-authoring" }, + { text: "E2E testing", link: "/e2e-testing" }, + { text: "Release playbook", link: "/release" }, + ], + }, + ], + + "/framework/": [ + { + text: "Framework", + collapsed: false, + items: [ + { text: "Overview", link: "/framework/overview" }, + { text: "Discovery & entry points", link: "/framework/discovery" }, + { text: "Lifecycle hooks", link: "/framework/lifecycle" }, + { text: "Middleware pipeline", link: "/framework/middleware" }, + { text: "Settings & app.state", link: "/framework/settings" }, + { text: "Permissions", link: "/framework/permissions" }, + { text: "Events", link: "/framework/events" }, + { text: "Internationalization", link: "/framework/i18n" }, + ], + }, + ], + + "/database/": [ + { + text: "Database", + collapsed: false, + items: [ + { text: "Models with SQLModel", link: "/database/models" }, + { text: "Per-module Base", link: "/database/per-module-base" }, + { text: "Mixins", link: "/database/mixins" }, + { text: "Session lifecycle", link: "/database/sessions" }, + { text: "Migrations", link: "/database/migrations" }, + ], + }, + ], + + "/frontend/": [ + { + text: "Frontend", + collapsed: false, + items: [ + { text: "Inertia basics", link: "/frontend/inertia" }, + { text: "Pages & discovery", link: "/frontend/pages" }, + { text: "Shared props & layout", link: "/frontend/shared-props" }, + ], + }, + ], + + "/testing/": [ + { + text: "Testing", + collapsed: false, + items: [ + { text: "Overview", link: "/testing/overview" }, + { text: "Fixtures", link: "/testing/fixtures" }, + { text: "E2E tests", link: "/e2e-testing" }, + ], + }, + ], + + "/reference/": [ + { + text: "Reference", + collapsed: false, + items: [ + { text: "Make commands", link: "/reference/make-commands" }, + { text: "Environment variables", link: "/reference/env-vars" }, + { + text: "Diagnostic codes", + link: "/reference/diagnostic-codes", + }, + { text: "Deployment", link: "/reference/deployment" }, + ], + }, + ], + }, + }, +}); diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..5f06643d --- /dev/null +++ b/docs/README.md @@ -0,0 +1,55 @@ +# Documentation site + +This is a [VitePress](https://vitepress.dev) site — markdown files under `docs/`, rendered by a Vite-powered dev server. + +## Run locally + +```bash +cd docs +npm install +npm run dev +``` + +Open `http://localhost:5173`. Hot-reloads on any `.md` edit. + +## Build + +```bash +cd docs +npm run build # output in docs/.vitepress/dist +npm run preview # serve the built site +``` + +## Structure + +```text +docs/ +├── .vitepress/ +│ └── config.ts # nav, sidebar, theme +├── index.md # home page +├── guide/ # getting-started +├── framework/ # module system deep dives +├── database/ # SQLModel, mixins, migrations +├── frontend/ # Inertia, pages, shared props +├── testing/ # fixtures, unit + E2E +├── reference/ # commands, env vars, diagnostic codes, deployment +├── plans/ # dated design docs (pre-existing) +├── superpowers/ # spec/plan pairs (pre-existing) +├── release-notes/ # per-release notes (pre-existing) +├── framework-conventions.md # authoritative reference (pre-existing) +├── module-authoring.md # authoritative reference (pre-existing) +├── e2e-testing.md # authoritative reference (pre-existing) +└── release.md # authoritative reference (pre-existing) +``` + +The four pre-existing root-level `.md` files are **authoritative** when conventions are ambiguous. The themed sub-directories are the narrative onboarding path; they link back to the authoritative docs where appropriate. + +## Adding a page + +1. Create `docs/
/.md`. +2. Add it to the sidebar in `docs/.vitepress/config.ts`. +3. `npm run dev` to preview. + +## Publishing + +Not yet wired into CI. The `docs:build` output can be served by any static host; the repo's release workflow is scoped to Python + npm packages, not docs. diff --git a/docs/database/migrations.md b/docs/database/migrations.md new file mode 100644 index 00000000..f9569f33 --- /dev/null +++ b/docs/database/migrations.md @@ -0,0 +1,178 @@ +# Migrations + +All migrations live in `host/migrations/versions/` — **not** in module packages. Alembic runs from the repo root (`host/alembic.ini`) and shares the host'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.** `host/alembic/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`. + +## Day-to-day workflow + +### Create a migration + +```bash +make migration msg="add orders tables" +``` + +This runs `alembic revision --autogenerate -m "..."` 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: + +- It detects column additions, drops, type changes, index/constraint changes, new tables. +- It **misses** semantic constraints (e.g. "this ENUM now has one more value"), and it sometimes produces drops that should be renames. +- Constraint naming needs to be stable — provide explicit `name="..."` kwargs when creating indexes/constraints in your models. + +### Apply migrations + +```bash +make migrate +``` + +Runs `alembic upgrade head`. 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 +``` + +`orders@base` uses the `branch_labels` marker from the module's first migration. Module-level downgrade is the mechanism for uninstalling a module cleanly. + +## First migration of a new module + +When you scaffold a module with `make new-module`, the *first* `make migration msg=...` produces a file that needs this marker added by hand: + +```python +# host/migrations/versions/XXXX_add_orders_tables.py + +revision = "..." +down_revision = "..." +branch_labels = ("orders",) # ← add this +depends_on = None +``` + +Once the marker is in place, all future `orders` migrations inherit the branch. + +## Alembic environment setup + +`host/alembic/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 + +target_metadata = build_module_metadata() +include_object = make_include_object() + +context.configure( + target_metadata=target_metadata, + include_object=include_object, + compare_type=True, + compare_server_default=True, +) +``` + +- `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. + +## Boot-time migration check + +On startup, the framework compares `alembic_version` to the migration head: + +- **Development:** prints a warning if the DB is behind. Result stored on `app.state.migration`. +- **Production:** fails boot with `SM010`. Don't ship a web process that's pointed at an unmigrated DB. + +Fix locally with `make migrate`. In production, run migrations before rolling over the web tier. + +## Diagnostic: `SM011` + +Fires as a warning when a module's model declares a table that doesn't appear in any Alembic migration. Typical causes: + +- You added a model but haven't run `make migration` yet. +- You renamed a table but the old migration still references the old name. +- You used `__abstract__ = True` somewhere it shouldn't be. + +`make doctor` prints the offending table names. Resolution: run `make migration msg="..."`, review, apply. + +## Cross-module foreign keys + +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. +- `make new-module` and `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`). + +On SQLite, FKs are off by default but the test suite enables them; in production SQLite use (rare), set `PRAGMA foreign_keys = ON`. + +## Data migrations + +Alembic supports ad-hoc `op.execute("UPDATE ...")` inside a migration. Use for: + +- Backfilling new NOT NULL columns — add as nullable, backfill, alter to NOT NULL. +- Renaming columns — `op.alter_column` with `new_column_name=...` preserves data. +- Migrating enum values. + +Data migrations run as part of `alembic upgrade`. If they fail mid-way, you're left at an intermediate state — design them to be **idempotent** (use `ON CONFLICT`, `IF EXISTS`, `UPDATE ... WHERE x IS NULL`). + +For long-running backfills on big tables, break into batches and run outside Alembic: + +```python +# scripts/backfill_orders_total.py +async def backfill(batch_size=1000): + ... +``` + +## Migration drift in monorepos + +`git pull` introducing two parallel branches of Alembic revisions: + +```text +revision A ← you created +revision B ← teammate's branch created +``` + +Both have `down_revision = `. Alembic will refuse `upgrade head` on a tree with multiple heads. Resolve by: + +1. `uv run alembic heads` — list them. +2. `uv run alembic merge -m "merge A and B" ` — creates a merge revision with both as parents. +3. Commit the merge revision. + +Keep merges small; a merge revision with its own `op.*` logic is a code smell. + +## Initial-migration gotchas + +When you `make migration msg="initial"` 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)? +- 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. + +## Testing migrations + +The `db_session` fixture stamps `alembic_version` at head on a fresh in-memory DB, which validates that `build_module_metadata()` + your migrations produce a schema the tables can operate against. That said, integration tests don't run every Alembic op — if you want to exercise the migrations explicitly: + +```python +@pytest.mark.asyncio +async def test_migration_up_then_down(tmp_path): + from alembic.config import Config + from alembic import command + + db_url = f"sqlite:///{tmp_path}/migrate_test.db" + cfg = Config("host/alembic.ini") + cfg.set_main_option("sqlalchemy.url", db_url) + + command.upgrade(cfg, "head") + command.downgrade(cfg, "base") +``` + +That catches the common "autogenerate wrote a broken downgrade" class of bugs. diff --git a/docs/database/mixins.md b/docs/database/mixins.md new file mode 100644 index 00000000..3e290605 --- /dev/null +++ b/docs/database/mixins.md @@ -0,0 +1,151 @@ +# Mixins + +Standard mixins in `simple_module_db.mixins`. Compose as many as you need alongside the per-module `Base`. + +```python +from simple_module_db.base import create_module_base +from simple_module_db.mixins import ( + AuditMixin, SoftDeleteMixin, MultiTenantMixin, VersionedMixin, +) + +Base = create_module_base("orders") + +class Order( + Base, AuditMixin, SoftDeleteMixin, MultiTenantMixin, VersionedMixin, + table=True, +): + __tablename__ = "orders_order" + id: int | None = Field(default=None, primary_key=True) + ... +``` + +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. + +## `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. + +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. + +Use on every table that participates in business workflows. Skip on pure enum / lookup tables. + +## `SoftDeleteMixin` + +Adds: + +- `is_deleted: bool` — default `False`. +- `deleted_at: datetime | None`. +- `deleted_by: int | None`. + +On `session.delete(instance)`, a `before_delete` listener converts the delete into an UPDATE that sets the three fields. The row stays. + +Selects auto-filter soft-deleted rows. A `before_compile` query rewrite appends `WHERE is_deleted = FALSE` to any query touching a `SoftDeleteMixin` table. + +### Bypass for admin / audit + +To include soft-deleted rows explicitly (admin view, audit reports), pass `include_deleted=True` as an execution option: + +```python +from sqlmodel import select + +stmt = select(Order).execution_options(include_deleted=True) +rows = (await session.exec(stmt)).all() +``` + +The flag is checked in the query-rewrite hook; when set, the filter is skipped. + +### Hard-delete + +If you genuinely need to remove the row, call the underlying SQLAlchemy DELETE — the soft-delete hook only triggers for `session.delete(instance)`: + +```python +from sqlmodel import delete +await session.exec( + delete(Order).where(Order.id == order_id) +) +``` + +Use sparingly — audit trails and downstream systems may depend on historical rows. + +## `MultiTenantMixin` + +Adds: + +- `tenant_id: str` — populated from `request.state.tenant_id` (set by `TenantMiddleware`). + +### 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. + +### 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. + +### 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. + +## `VersionedMixin` + +Adds: + +- `version: int` — default `1`, incremented on every UPDATE via `before_update` listener. + +Useful for optimistic concurrency control: check the version didn't change between read and write, abort if it did. + +```python +async def update(self, order_id: int, expected_version: int, data): + order = await self.session.get(Order, order_id) + if order.version != expected_version: + raise StaleVersion(order.id, order.version, expected_version) + for k, v in data.model_dump(exclude_none=True).items(): + setattr(order, k, v) + await self.session.flush() + return order +``` + +`VersionedMixin` doesn't ship with a check — you perform the comparison in the service layer. The mixin only ensures `version` increments. + +## Composing mixins + +Order matters only for MRO of columns that share names; the mixins here don't overlap, so combine in any order. A typical "fully-featured" table: + +```python +class Order( + Base, + AuditMixin, # created/updated timestamps + author + SoftDeleteMixin, # is_deleted flag + auto-filter + MultiTenantMixin, # tenant_id + auto-filter + table=True, +): + __tablename__ = "orders_order" + ... +``` + +That covers: who created it, who last touched it, when, whether it's been deleted, and which tenant owns it — 8 columns of framework concern, 0 lines of service code. + +## What the mixins don't do + +- **They don't add indexes** beyond the PKs. Add your own on `created_at` / `tenant_id` / `is_deleted` when you query them heavily. +- **They don't cascade.** Soft-deleting an `Order` does not soft-delete its `OrderLine`s. Implement cascades explicitly in the service if needed. +- **They don't replace authorization.** `MultiTenantMixin` filters reads; it does not check that the requesting user is allowed to see rows in *this* tenant. Enforce that in middleware. diff --git a/docs/database/models.md b/docs/database/models.md new file mode 100644 index 00000000..69e441e0 --- /dev/null +++ b/docs/database/models.md @@ -0,0 +1,171 @@ +# Models with SQLModel + +SQLModel is the **project-wide standard for every model** — DB tables *and* DTOs. Do not use plain Pydantic `BaseModel`, and do not use SQLAlchemy's `DeclarativeBase` + `Mapped[...]` in module code. + +One type system means one set of edge cases. You'll hit ty (type checker) false positives around SQLModel instrumentation — those are globally ignored in `pyproject.toml`. See the bottom of this page. + +## Tables + +```python +from simple_module_db.base import create_module_base +from simple_module_db.mixins import AuditMixin +from sqlmodel import Field + +Base = create_module_base("orders") + +class Order(Base, AuditMixin, table=True): + __tablename__ = "orders_order" + + id: int | None = Field(default=None, primary_key=True) + customer_email: str = Field(max_length=200, index=True) + status: str = Field(default="pending", max_length=20) +``` + +### Table naming + +- **Postgres.** `create_module_base("orders")` gives the class a per-module schema. The `__tablename__` can be just `order`, and the fully-qualified name is `orders.order`. The prefix `orders_` is redundant but harmless. +- **SQLite.** One schema, so `__tablename__` must be prefixed with the module name to avoid collisions: `orders_order`. + +**Convention: always prefix the table name with the module name.** This makes migrations and DB dumps readable in both providers and avoids a "works on my machine" footgun when swapping between them. + +### Primary keys + +Use `int | None = Field(default=None, primary_key=True)` for auto-increment. `None` is acceptable before insert; the session flush populates it. + +For UUID keys: + +```python +import uuid +from sqlmodel import Field + +id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) +``` + +### Foreign keys + +```python +from sqlmodel import Field, Relationship + +class OrderLine(Base, table=True): + __tablename__ = "orders_order_line" + + id: int | None = Field(default=None, primary_key=True) + order_id: int = Field(foreign_key="orders.order.id") # Postgres + # order_id: int = Field(foreign_key="orders_order.id") # SQLite + quantity: int + + 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. + +## DTOs (schemas) + +DTOs are plain `SQLModel` subclasses — **not** `table=True`: + +```python +# modules/orders/orders/contracts/schemas.py +from decimal import Decimal +from datetime import datetime +from pydantic import ConfigDict +from sqlmodel import Field, SQLModel + +class OrderCreate(SQLModel): + customer_email: str = Field(min_length=1, max_length=200) + total: Decimal = Field(ge=0) + +class OrderUpdate(SQLModel): + status: str | None = Field(default=None, max_length=20) + +class OrderOut(SQLModel): + model_config = ConfigDict(from_attributes=True) + + id: int + customer_email: str + total: Decimal + status: str + created_at: datetime +``` + +### Why not Pydantic `BaseModel`? + +- You'd have two different field-definition syntaxes (`Field` meaning two different things depending on the import path). +- Validators don't transfer between `BaseModel` and SQLModel. +- One less place for someone to mistakenly mix them and hit cryptic errors. + +### `model_config = ConfigDict(from_attributes=True)` + +Required on output DTOs so you can do `OrderOut.model_validate(order)` where `order` is a SQLAlchemy-instrumented table instance. Without it you'd have to spread fields manually. + +### Field validation + +```python +class OrderCreate(SQLModel): + customer_email: str = Field(min_length=1, max_length=200) + total: Decimal = Field(ge=0, decimal_places=2) +``` + +Applied on input. If validation fails, FastAPI returns a 422 with the violations — no handler code needed. + +## Contracts — the public surface + +Everything a **consumer module** should import lives in `/contracts/`: + +```text +modules/orders/orders/contracts/ +├── __init__.py # re-exports +├── schemas.py # SQLModel DTOs +├── events.py # domain events +└── service.py # Protocol (only if needed — see below) +``` + +Other modules import from `orders.contracts.schemas`, never from `orders.models` or `orders.service`. This boundary is social-not-enforced today (no diagnostic yet), but it's the first thing to check in a code review when coupling gets weird. + +### Protocols + +Only define a `Protocol` for a service when you have a **real** extension point — e.g. the storage backend where `LocalStorage`, `S3Storage`, and `InMemoryStorage` all need to be drop-in. For a single-implementation service, export the concrete class from `service.py` and have consumers type-hint against it directly. Don't ship dead `IFooService` boilerplate. + +## Relationships + +SQLModel `Relationship` is the usual pattern: + +```python +class Order(Base, table=True): + id: int | None = Field(default=None, primary_key=True) + lines: list["OrderLine"] = Relationship(back_populates="order") + +class OrderLine(Base, table=True): + id: int | None = Field(default=None, primary_key=True) + order_id: int = Field(foreign_key="orders.order.id") + order: Order = Relationship(back_populates="lines") +``` + +For **async** loading, prefer explicit `selectinload` in queries over implicit lazy loading, which doesn't work with the async session: + +```python +from sqlalchemy.orm import selectinload +from sqlmodel import select + +stmt = select(Order).options(selectinload(Order.lines)) +result = await session.exec(stmt) +orders = result.all() +``` + +## Ty false positives + +SQLModel declares fields with plain Python types (`str`, `int`) at class definition time, but SQLAlchemy instruments them with descriptors at runtime. Ty can't see through that, so the following rules are **globally ignored** in `pyproject.toml`: + +- `unresolved-attribute` +- `unsupported-operator` +- `unknown-argument` +- `no-matching-overload` +- `invalid-argument-type` + +Do not re-enable these rules in module-local configs. Real bugs caused by wrong field access surface in the test suite — these rules produce only noise. + +## Next + +- [Per-module Base](/database/per-module-base) — how provider detection and schema isolation works. +- [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. diff --git a/docs/database/per-module-base.md b/docs/database/per-module-base.md new file mode 100644 index 00000000..0b3995f1 --- /dev/null +++ b/docs/database/per-module-base.md @@ -0,0 +1,100 @@ +# Per-module Base + +Every module that declares SQLModel tables calls `create_module_base("")` once, and inherits from the returned class: + +```python +from simple_module_db.base import create_module_base + +Base = create_module_base("orders") + +class Order(Base, table=True): + __tablename__ = "orders_order" + id: int | None = Field(default=None, primary_key=True) + ... +``` + +`create_module_base` returns a SQLModel base that's bound to a private `MetaData` object. This isolation is what lets modules live side-by-side without their tables trampling each other's Alembic autogenerate output. + +## Provider auto-detection + +The function inspects `SM_DATABASE_URL` at import time and picks the right strategy: + +- **PostgreSQL** → the Base sets `__table_args__ = {"schema": ""}`. Tables live at `orders.`. Each module effectively owns a namespace; one `DROP SCHEMA orders CASCADE` can cleanly uninstall a module. +- **SQLite** → no schema (SQLite has one). The `__tablename__` must still be prefixed with the module name to avoid collisions. `create_module_base` does not enforce the prefix — it's a convention. + +You can override detection explicitly for tests or special cases: + +```python +from simple_module_db.base import create_module_base +from simple_module_db.types import DatabaseProvider + +Base = create_module_base("orders", provider=DatabaseProvider.SQLITE) +``` + +Production code should let auto-detection do its thing. + +## 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 `host/alembic/env.py` calls: + +```python +from simple_module_db.base import build_module_metadata + +target_metadata = build_module_metadata() +``` + +This function iterates every discovered module, imports its `models` submodule (if one exists), and unions all the per-module `MetaData`s into one. Autogenerate then diffs the DB against that union. + +If a module has no `models.py`, it contributes nothing — fine. If a module has a `models.py` that doesn't import, the union fails — fix the import. + +## `make_include_object()` + +Alembic's `include_object` callback filters which tables `autogenerate` considers. `host/alembic/env.py` uses `make_include_object()` 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). + +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`. + +## Naming rules + +- Module name must match `ModuleMeta.name.lower()`. The framework caches the Base by module name; a mismatch causes silent schema drift. +- Module names should be identifiers: `[a-z][a-z0-9_]*`. Hyphens break SQL identifier parsing on some providers. +- Don't rename a module after it ships without migrating data. Both the schema name (Postgres) and the table prefix (SQLite) are durable. + +## Tables across modules + +If your module needs to reference another module's table by foreign key, import its model: + +```python +# modules/invoices/invoices/models.py +from orders.models import Order + +class Invoice(Base, table=True): + __tablename__ = "invoices_invoice" + id: int | None = Field(default=None, primary_key=True) + order_id: int = Field(foreign_key="orders.order.id") +``` + +Caveats: + +- Add `depends_on=["Orders"]` in `InvoicesModule.meta` — modules are loaded in topological order; without `depends_on`, `orders.models` might not be imported when `invoices.models` runs. +- Autogenerate handles cross-schema FKs on Postgres natively. +- Uninstalling `Orders` while `Invoices` still references it produces a DB error — cross-module FKs are a commitment. + +If you can avoid a hard FK (store `order_id: int` without the constraint), module lifecycles stay more independent. Prefer application-level validation for loose coupling. + +## Inspecting at runtime + +For debugging, you can dump the registered tables: + +```python +from simple_module_db.base import build_module_metadata + +meta = build_module_metadata() +for t in meta.sorted_tables: + print(t.schema or "(no schema)", t.name) +``` + +This is also what the `make doctor` `SM011` check uses — it compares this set against the Alembic history to detect tables that exist in code but not in any migration. diff --git a/docs/database/sessions.md b/docs/database/sessions.md new file mode 100644 index 00000000..3e2cf926 --- /dev/null +++ b/docs/database/sessions.md @@ -0,0 +1,136 @@ +# Session lifecycle + +Each request opens exactly one `AsyncSession`. The framework commits only when there are pending writes; read-only requests rollback. **Service code should not call `session.commit()` directly.** + +## 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: + try: + yield session + if _has_writes(session): + await session.commit() + else: + await session.rollback() + except Exception: + await session.rollback() + raise +``` + +Usage in endpoints: + +```python +from typing import Annotated +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession +from simple_module_db.session import get_db + +SessionDep = Annotated[AsyncSession, Depends(get_db)] + +@router.post("") +async def create_order(data: OrderCreate, session: SessionDep): ... +``` + +Most modules wrap `get_db` in their own `deps.py` so the service is injected rather than the raw session: + +```python +# modules/orders/orders/deps.py +from orders.service import OrderService + +def _order_service(session: SessionDep) -> OrderService: + return OrderService(session) + +OrderServiceDep = Annotated[OrderService, Depends(_order_service)] +``` + +## The `has_writes` flag + +To distinguish "this request wrote something" from "this request only read", an `after_flush` event listener sets `session.info["has_writes"] = True`. The dependency checks: + +```python +if session.info.get("has_writes") or session.new or session.dirty or session.deleted: + await session.commit() +else: + await session.rollback() +``` + +Why both? `session.new/dirty/deleted` show **pending** writes that haven't flushed yet; `has_writes` catches the case where writes flushed but were already synced. Together they cover every path to "we touched the DB with intent to change it." + +## Why not always commit? + +- **Cheaper.** A rollback on a clean session is a single Postgres message; a commit with no writes still goes through two-phase round-trips on some drivers. +- **Observability.** The write/read distinction is useful in profiling and logs — you can answer "how many requests mutated data?" without guessing. +- **Safety.** If a GET handler accidentally stages a write (rare, but happens with `session.merge`), the rollback prevents silent persistence. + +## Why service code shouldn't commit + +The per-request session is **owned** by the dependency. If your service calls `commit()` mid-request, two problems arise: + +1. The dependency commits again on exit — harmless but confusing. +2. If a later step in the request fails, the partial writes are already persisted and can't be rolled back. + +Instead, **flush** when you need a DB-assigned value (e.g. an auto-generated `id`): + +```python +async def create(self, data: OrderCreate) -> OrderOut: + order = Order(**data.model_dump()) + self.session.add(order) + await self.session.flush() # populates order.id, stays inside the transaction + # further logic can see order.id but won't persist until the dependency commits + return OrderOut.model_validate(order) +``` + +Flushing sends the INSERT but keeps the transaction open. Rollback still works until the dependency commits. + +## Manual transactions + +If you need finer control — e.g. a background worker that processes many items in its own transactions — use `DatabaseState` directly: + +```python +from simple_module_db.state import DatabaseState + +async def worker(db: DatabaseState): + async with db.session() 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. + +## 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. + +For cross-tenant admin ops, use `no_tenant()` context or the `skip_tenant_filter=True` execution option. See [Mixins → MultiTenantMixin](/database/mixins). + +## Sessions in tests + +The `db_session` fixture in `conftest.py` creates a fresh in-memory SQLite DB, creates every module's tables, stamps `alembic_version` at head, and yields an `AsyncSession`. Each test gets a fresh one — no shared state, no transaction rollback hacks. + +```python +@pytest.mark.asyncio +async def test_order_insert(db_session): + order = Order(customer_email="a@b.c", total=Decimal("1")) + db_session.add(order) + await db_session.flush() + assert order.id is not None +``` + +For endpoint tests, use `client` or `authenticated_client` — they share the same `db_session` via dependency override, so writes performed by the handler are visible to test-side queries. + +## Commit vs. flush checklist + +| Situation | Call | +|---|---| +| Inside an endpoint handler / service, need DB-assigned id | `await session.flush()` | +| Inside an endpoint handler / service, done with work | nothing — dependency commits on exit | +| Outside a request (worker, CLI, lifespan) | explicit `async with session.begin():` | +| A handler that reads-only but wants to persist an audit row | stage the row; the dependency detects pending writes and commits | +| A handler that must explicitly discard partial changes | `raise`ing propagates to the dependency, which rolls back | + +If you find yourself writing `await session.commit()` inside a service, step back — there's a cleaner way using flush or a dedicated transactional helper. diff --git a/docs/framework/discovery.md b/docs/framework/discovery.md new file mode 100644 index 00000000..120b1a4a --- /dev/null +++ b/docs/framework/discovery.md @@ -0,0 +1,118 @@ +# Discovery & entry points + +Modules are **Python packages** registered via the `simple_module` entry-point group. There is no module registry file, no `INSTALLED_MODULES` list in settings — if the package is installed in the environment and declares the entry point, it's a module. + +## Declaring a module + +Every module's `pyproject.toml` ends with: + +```toml +[project.entry-points.simple_module] +orders = "orders.module:OrdersModule" +``` + +- The **key** (`orders`) is the entry-point name. It's used for reporting only — the framework authoritative name is `ModuleMeta.name`. +- The **value** is `:`. The class must inherit from `simple_module_core.module.ModuleBase` and declare a non-null `meta = ModuleMeta(...)` class attribute. + +Entry points are registered **at install time**, not at import time. After editing a module's `pyproject.toml`, re-run `uv sync --all-packages` (or `make install`) so the installer writes the new metadata to the venv's `*.dist-info/entry_points.txt`. + +## Discovery algorithm + +`simple_module_core.discovery.discover_modules()`: + +1. Iterates `importlib.metadata.entry_points(group="simple_module")`. +2. For each entry point, calls `.load()` to import the module's `module.py` and pull out the class. +3. Validates: the loaded object must be a `ModuleBase` subclass with a `meta` attribute of type `ModuleMeta`. +4. If `SM_MODULES_ENABLED` is set, filters to only those listed. +5. Topologically sorts by `ModuleMeta.depends_on` using a stable tiebreaker (name). +6. Returns a tuple of `ModuleBase` **instances** (one per module, constructed with no args). + +Failures: + +- **Import error** (e.g. syntax error in `module.py`) — lenient mode: logged + skipped. Strict mode: raises `InvalidModuleError`. +- **Missing `meta`** — emits `SM001`. Strict mode: raises. +- **Duplicate `meta.name`** — emits `SM008` (error). Always fails boot, even in dev, because names are used as DB schemas/prefixes. +- **Cycle in `depends_on`** — raises `CircularDependencyError`. Always fails boot. + +## ModuleMeta + +```python +from simple_module_core.module import ModuleMeta + +meta = ModuleMeta( + name="Orders", # PascalCase, globally unique + route_prefix="/api/orders", # where the API router mounts + view_prefix="/orders", # where the view router mounts + depends_on=["Products"], # hard ordering requirements + version="1.0.0", # semver for the module +) +``` + +### `name` + +The authoritative identifier. It's used for: + +- Postgres schema name (lowercased → `orders`). +- SQLite table-name prefix (`orders_*`). +- Inertia page namespace (`"Orders/Browse"`). +- Diagnostic reporter name. +- Feature-flag and permission grouping. + +Rule of thumb: use `PascalCase` and keep it stable. Renaming a module after release requires a data migration and a coordinated client-side rename. + +### `depends_on` + +A list of other modules' `meta.name` values that **must be loaded first**. The framework topo-sorts on this before invoking lifecycle hooks. Use this when: + +- Your module's middleware must sit inside/outside another module's middleware. See [Middleware pipeline](/framework/middleware). +- Your module's `register_routes` reads another module's service to compose endpoints. +- Your module subscribes to events published by another module. + +`depends_on` is **not** a dependency-injection mechanism. It only controls order — you still `import` the services you need. + +### `version` + +Semver string. Used in diagnostic output and can be surfaced in `/admin/health` to help operators correlate deployed module versions with bug reports. Not currently parsed for automated version-range checks. + +## The `simple_module` group + +Entry points are the same mechanism that ships with `importlib.metadata`: + +```text +.venv/lib/python3.12/site-packages/orders-1.0.0.dist-info/entry_points.txt: + + [simple_module] + orders = orders.module:OrdersModule +``` + +You can inspect what the framework sees with: + +```bash +uv run python -c " +from importlib.metadata import entry_points +for ep in entry_points(group='simple_module'): + print(ep.name, '->', ep.value) +" +``` + +## Disabling modules at runtime + +Set `SM_MODULES_ENABLED` to a comma-separated allow-list of entry-point names. Useful for: + +- Running tests against a minimal world (`SM_MODULES_ENABLED=users,orders`). +- Temporarily disabling a misbehaving module in production without a redeploy. +- Sharding a large app into multiple deployments (e.g. background-worker process loads only the `background_tasks` module). + +Disabling via env doesn't remove the module's database tables. Re-enabling picks up where it left off. + +## Strict-mode checklist + +Before flipping to `SM_ENVIRONMENT=production`, verify: + +- [ ] Every module's `pyproject.toml` declares the `simple_module` entry point. +- [ ] Every `ModuleBase` subclass has `meta = ModuleMeta(...)`. +- [ ] `make doctor` reports zero errors (`SM001`, `SM008`, `SM009`, `SM010`, `SM016` are all ERROR-level). +- [ ] `alembic upgrade head` has been run against the production DB. +- [ ] `SM_SECRET_KEY` is not `change-me-in-production`. + +Failure on any of the above produces a clean boot-time exception rather than a silent misbehavior. diff --git a/docs/framework/events.md b/docs/framework/events.md new file mode 100644 index 00000000..002e4314 --- /dev/null +++ b/docs/framework/events.md @@ -0,0 +1,156 @@ +# Events + +`EventBus` is an **in-process** pub/sub mechanism. It's not a message broker — no persistence, no retries, no cross-process delivery. For durable async work, use the `background_tasks` module (Celery) or publish a domain event and have a handler enqueue a Celery task. + +Events are the preferred way for modules to react to **other modules'** actions without creating a direct code dependency. + +## Defining events + +Base class is `Event` from `simple_module_core.events`. Subclass per domain event. Use `@dataclass`: + +```python +# modules/orders/orders/contracts/events.py +from dataclasses import dataclass +from decimal import Decimal +from simple_module_core.events import Event + +@dataclass +class OrderPlaced(Event): + order_id: int + customer_email: str + total: Decimal + +@dataclass +class OrderCancelled(Event): + order_id: int + reason: str +``` + +Events live in `contracts/events.py` — the **public surface** of the module. Other modules import them with `from orders.contracts.events import OrderPlaced`. + +## Subscribing + +Inside `register_event_handlers`: + +```python +# modules/invoices/invoices/module.py +from orders.contracts.events import OrderPlaced + +class InvoicesModule(ModuleBase): + meta = ModuleMeta( + name="Invoices", + depends_on=["Orders"], # subscribe to its events + ... + ) + + def register_event_handlers(self, bus: EventBus) -> None: + bus.subscribe(OrderPlaced, self._on_order_placed) + + async def _on_order_placed(self, event: OrderPlaced) -> None: + await self._invoice_service.create_for(event.order_id) +``` + +`depends_on=["Orders"]` is needed so `Invoices.register_event_handlers` runs *after* `Orders` has been discovered. Cross-module subscriptions should always have the corresponding `depends_on`. + +Handlers can be sync or async — the bus detects coroutines and awaits them. + +## Publishing + +Grab the bus from the framework services and await the publish: + +```python +class OrderService: + def __init__(self, session: AsyncSession, bus: EventBus) -> None: + self.session = session + self.bus = bus + + async def place(self, data: OrderCreate) -> Order: + order = Order(**data.model_dump()) + self.session.add(order) + await self.session.flush() # need the id + await self.bus.publish(OrderPlaced( + order_id=order.id, + customer_email=order.customer_email, + total=order.total, + )) + return order +``` + +Get the bus as a FastAPI dependency: + +```python +# modules/orders/orders/deps.py +from fastapi import Depends, Request +from simple_module_core.events import EventBus + +def _event_bus(request: Request) -> EventBus: + return request.app.state.sm.event_bus + +EventBusDep = Annotated[EventBus, Depends(_event_bus)] +``` + +## MRO dispatch + +The bus walks the event's MRO, so subscribing to a **base class** delivers every subclass event: + +```python +@dataclass +class OrderEvent(Event): ... + +@dataclass +class OrderPlaced(OrderEvent): ... + +@dataclass +class OrderCancelled(OrderEvent): ... + +bus.subscribe(OrderEvent, audit_handler) # receives both +bus.subscribe(OrderPlaced, specific_handler) # receives only placed +``` + +Use base-class subscriptions sparingly — they're magnets for unintended coupling when new subclasses appear. + +## Delivery semantics + +- **Synchronous** from the publisher's perspective: `await bus.publish(...)` resolves after all handlers have run (or raised). +- **In-process only.** Not delivered to other uvicorn workers, other processes, or other hosts. +- **No persistence.** A crash mid-publish loses undelivered events. +- **Handler failures don't stop the publisher.** The bus logs handler exceptions and continues with the next handler. The publish itself does not raise. + +If you need durable delivery across processes, handlers should enqueue a Celery task: + +```python +def register_event_handlers(self, bus: EventBus) -> None: + bus.subscribe(OrderPlaced, self._enqueue_invoice) + +async def _enqueue_invoice(self, event: OrderPlaced) -> None: + from invoices.tasks import create_invoice_task + create_invoice_task.delay(event.order_id) +``` + +## Testing + +Subscribe a spy in a test fixture: + +```python +@pytest.mark.asyncio +async def test_place_order_publishes_event(db_session, app): + received: list[OrderPlaced] = [] + app.state.sm.event_bus.subscribe( + OrderPlaced, lambda e: received.append(e) + ) + + service = OrderService(db_session, app.state.sm.event_bus) + await service.place(OrderCreate(customer_email="a@b.c", total=Decimal("1"))) + + assert len(received) == 1 + assert received[0].customer_email == "a@b.c" +``` + +The `app` fixture from `conftest.py` provides a fresh app with a fresh `EventBus` per test. + +## Design guidelines + +- **Events describe facts about the past** (`OrderPlaced`), not commands (`PlaceOrder`). If you're tempted to name an event imperatively, you actually want a service call. +- **Keep events small and stable.** They're a public API. Breaking the shape of `OrderPlaced` breaks every subscriber. Add new fields with defaults; don't remove or rename. +- **Don't use events for request/response.** If the publisher needs a result, call a service directly. Events are fire-and-observe. +- **Avoid circular publishing.** Handler A publishes event X; handler B (subscribed to X) publishes event Y; handler C (subscribed to Y) publishes X. The bus doesn't detect this — it'll run forever. Guard with a flag or redesign the flow. diff --git a/docs/framework/i18n.md b/docs/framework/i18n.md new file mode 100644 index 00000000..a3630902 --- /dev/null +++ b/docs/framework/i18n.md @@ -0,0 +1,187 @@ +# Internationalization + +Translations live in each module as JSON files under `/locales/.json`. They're collected into a single `I18nRegistry` at boot and surfaced to React via the Inertia shared props. + +## Declaring locale files + +```text +modules/orders/orders/locales/ +├── en.json +├── es.json +└── de.json +``` + +Declare them from `ModuleBase.locale_dirs()`: + +```python +import importlib.resources +from pathlib import Path + +class OrdersModule(ModuleBase): + def locale_dirs(self) -> dict[str, Path]: + return { + "orders": Path( + str(importlib.resources.files(__package__) / "locales") + ), + } +``` + +The key (`"orders"`) is the **namespace** — it prefixes every key in the files. `make new-module` scaffolds this method and a starter `en.json` automatically. + +## Key naming + +Keys are `..` with hierarchical JSON objects that flatten at boot: + +```json +{ + "browse": { + "title": "Orders", + "empty": "No orders yet" + }, + "fields": { + "customer": "Customer" + } +} +``` + +Under namespace `orders`, these become: + +- `orders.browse.title` +- `orders.browse.empty` +- `orders.fields.customer` + +Convention: `snake_case` at leaves, `camelCase` or `snake_case` consistently at levels. + +## Interpolation + +Placeholders use `{name}` syntax, identical between frontend and backend: + +```json +{ "greeting": "Hello, {name}" } +``` + +Frontend: + +```tsx +import { useT } from "@simple-module-py/i18n-react"; +const { t } = useT(); +t("orders.greeting", { name: user.name }); +``` + +Backend (endpoints, emails): + +```python +from simple_module_core.i18n import get_translator + +t = get_translator(request.state.locale) +t.t("orders.greeting", name=user.name) +``` + +Missing placeholders are left verbatim (`"Hello, {name}"`) rather than raising — intentional, so a translation bug doesn't 500 a page. + +## Pluralization + +Use CLDR suffixes: `_zero`, `_one`, `_two`, `_few`, `_many`, `_other`. Only `_other` is required. Pass `count` as a param: + +```json +{ + "items_one": "{count} item", + "items_other": "{count} items" +} +``` + +```tsx +t("orders.items", { count: orders.length }); +``` + +```python +t.t("orders.items", count=len(orders)) +``` + +Behavior matches across the stack: Babel's CLDR rules on the backend, `Intl.PluralRules` in i18next on the frontend. + +## Locale resolution + +`LocaleMiddleware` picks the active locale per request in this order: + +1. Cookie named by `SM_I18N_COOKIE_NAME` (default `locale`), validated against `SM_I18N_SUPPORTED_LOCALES`. +2. `Accept-Language` header, with q-value parsing and longest-prefix match (`es-MX` → `es`). +3. `SM_I18N_DEFAULT_LOCALE`. + +Resolved locale lands on `request.state.locale`. + +### `` + +Ships in `@simple-module-py/ui`. POSTs to `/i18n/set-locale`, which sets a 1-year cookie and redirects back. + +## Zod schemas with translated messages + +Zod schemas that reference translations **must** be constructed inside a hook — never at module scope: + +```tsx +import { z } from "zod"; +import { useT } from "@simple-module-py/i18n-react"; + +// ✅ Correct — resolves against the active locale per render +export function useProductSchema() { + const { t } = useT(); + return z.object({ + name: z.string().min(1, t("products.validation.name_required")), + }); +} + +// ❌ Wrong — freezes against whichever locale was active at module load +const schema = z.object({ + name: z.string().min(1, t("products.validation.name_required")), +}); +``` + +Module-scope `t(...)` calls capture the first-render locale and never update. + +## Host and shared-package strings + +- **Host strings** (landing page, error page): `host/locales/.json`, namespace `host.*`. +- **Shared UI strings** (packages/ui): `packages/ui/locales/.json`, namespace `ui.*`. + +Both are auto-discovered alongside module contributions — no manual wiring. + +## Configuration + +```bash +SM_I18N_DEFAULT_LOCALE=en +SM_I18N_SUPPORTED_LOCALES=en,es,de +SM_I18N_COOKIE_NAME=locale +``` + +A pydantic validator enforces that the default locale is in the supported list. + +## Diagnostics (`SM013`–`SM016`) + +`make doctor` (and app boot) runs `I18nDiagnostics` against every declared locale dir: + +| Code | Level | Trigger | +|---|---|---| +| `SM013` | WARNING | Locale file missing for a supported locale (e.g. declared `es` but no `es.json`). | +| `SM014` | WARNING | Non-default locale missing keys present in the default (untranslated). | +| `SM015` | WARNING | Non-default locale has keys **not** in the default (stale / orphan translation). | +| `SM016` | ERROR | Locale JSON invalid or contains non-string leaves. | + +In dev these print as warnings; in production `SM016` fails boot. + +## Testing + +For tests that assert on translated strings, set the locale explicitly: + +```python +async def test_landing_page_i18n(client): + r = await client.get("/", headers={"Accept-Language": "es"}) + assert "Bienvenido" in r.text +``` + +Or cookie: + +```python +r = await client.get("/", cookies={"locale": "es"}) +``` + +The `LocaleMiddleware` resolves and the shared props carry the right bundle to the Inertia page. diff --git a/docs/framework/lifecycle.md b/docs/framework/lifecycle.md new file mode 100644 index 00000000..edb1cce3 --- /dev/null +++ b/docs/framework/lifecycle.md @@ -0,0 +1,211 @@ +# Lifecycle hooks + +`ModuleBase` defines ten lifecycle hooks. All are no-ops by default — override the ones you need. + +At boot, for each module (in topological order), the framework calls them in this sequence: + +``` +register_settings +register_menu_items +register_permissions +register_feature_flags +register_event_handlers +register_health_checks +register_exception_handlers +register_middleware +register_routes +-- on_startup (async, after middleware is installed) +``` + +On shutdown, `on_shutdown` runs in **reverse** dependency order. + +## `register_settings(app)` + +**Called first.** Attach per-module state to `app.state.`. This is where your pydantic-settings object and any runtime-computed services go. + +```python +def register_settings(self, app: FastAPI) -> None: + from orders.settings import OrdersEnv + from orders.state import OrdersState + + app.state.orders = OrdersState(settings=OrdersEnv()) +``` + +If you override this hook but don't touch `app.state.`, diagnostic `SM012` warns in dev. See [Settings & app.state](/framework/settings). + +## `register_menu_items(registry)` + +Add entries to the global `MenuRegistry`. Items are grouped by `MenuSection` and role-filtered per request by `InertiaLayoutDataMiddleware`. + +```python +def register_menu_items(self, registry: MenuRegistry) -> None: + registry.add(MenuItem( + section=MenuSection.SIDEBAR, + key="orders", + label_key="orders.menu.orders", # i18n key + href="/orders", + icon="package", + required_permission="orders.view", + order=20, + )) +``` + +`required_permission` filters the item from users without the permission. `order` is a stable sort key (lower = earlier). + +Sections: `SIDEBAR`, `ADMIN_SIDEBAR`, `NAVBAR`, `USER_DROPDOWN`. + +## `register_permissions(registry)` + +Declare permission strings your module enforces. Grouped by a display name for the admin UI. + +```python +def register_permissions(self, registry: PermissionRegistry) -> None: + registry.add_group("Orders", [ + "orders.view", "orders.create", "orders.edit", "orders.delete", + ]) +``` + +Permissions become available in the role admin UI (`/settings/permissions`). See [Permissions](/framework/permissions). + +## `register_feature_flags(registry)` + +Declare feature flags with defaults. The admin can toggle them at `/settings/feature-flags`. + +```python +def register_feature_flags(self, registry: FeatureFlagRegistry) -> None: + registry.add("orders.new_checkout", default=False) +``` + +Query from code: + +```python +flags = request.app.state.sm.feature_flags +if flags.is_enabled("orders.new_checkout", user=request.state.principal): + ... +``` + +## `register_event_handlers(bus)` + +Subscribe to events on the in-process `EventBus`. Handlers can be sync or async; the bus awaits async ones. + +```python +def register_event_handlers(self, bus: EventBus) -> None: + from orders.contracts.events import OrderPlaced + bus.subscribe(OrderPlaced, self._on_order_placed) + +async def _on_order_placed(self, event: OrderPlaced) -> None: + ... +``` + +Dispatch walks the event's MRO, so subscribing to a base class delivers subclass events. See [Events](/framework/events). + +## `register_health_checks(registry)` + +Register named async checks. They're surfaced at `/admin/health`: + +```python +def register_health_checks(self, registry: HealthRegistry) -> None: + registry.add("orders.db", self._check_db) + +async def _check_db(self) -> HealthStatus: + ... +``` + +Return `HealthStatus.ok()` or `HealthStatus.failing(detail=...)`. The health endpoint aggregates all checks and returns 503 if any critical check fails. + +## `register_exception_handlers(app)` + +Register FastAPI exception handlers scoped to your module's exceptions: + +```python +def register_exception_handlers(self, app: FastAPI) -> None: + app.add_exception_handler(OrderNotFound, self._handle_not_found) + +async def _handle_not_found( + self, request: Request, exc: OrderNotFound +) -> Response: + return JSONResponse({"detail": str(exc)}, status_code=404) +``` + +Handlers are registered on the main `app`, so they apply globally. Keep them tight to types your module owns. + +## `register_middleware(app)` + +Install ASGI middleware. Starlette's `add_middleware` is **LIFO** — the last middleware added runs first. Modules' middleware runs *between* the framework's built-in middleware and the app itself. See [Middleware pipeline](/framework/middleware) for ordering rules. + +```python +def register_middleware(self, app: FastAPI) -> None: + app.add_middleware(OrdersRateLimitMiddleware, rate=10) +``` + +When two modules at the same dependency tier both add middleware, the module that sorts **later** wraps outermost (executes first). + +## `register_routes(api_router, view_router)` + +Mount your API and Inertia view routers onto the two framework-provided routers. + +```python +def register_routes( + self, api: APIRouter, views: APIRouter +) -> None: + from orders.endpoints.api import router as api_router + from orders.endpoints.views import router as view_router + + api.include_router(api_router, prefix="/api/orders") + views.include_router(view_router, prefix="/orders") +``` + +- `api_router` is mounted at `/api` on the main app. +- `view_router` is mounted at `/` on the main app. + +Prefixes in `ModuleMeta.route_prefix` / `view_prefix` are **documentation**; the framework does not auto-apply them. You specify the prefix in `include_router`. + +## `on_startup()` / `on_shutdown()` + +Async lifespan hooks that run after all modules are registered. + +```python +async def on_startup(self) -> None: + await self._worker_pool.start() + +async def on_shutdown(self) -> None: + await self._worker_pool.stop() +``` + +- `on_startup` runs in **topological** order. +- `on_shutdown` runs in **reverse** topological order. + +Typical uses: + +- Start Celery/RQ consumers (`background_tasks`). +- Warm caches. +- Register webhooks with external services. +- Probe required dependencies and fail boot on missing ones. + +## Full example + +```python +class OrdersModule(ModuleBase): + meta = ModuleMeta( + name="Orders", + route_prefix="/api/orders", + view_prefix="/orders", + depends_on=["Users", "Products"], + version="1.0.0", + ) + + def register_settings(self, app): ... + def register_menu_items(self, registry): ... + def register_permissions(self, registry): ... + def register_feature_flags(self, registry): ... + def register_event_handlers(self, bus): ... + def register_health_checks(self, registry): ... + def register_exception_handlers(self, app): ... + def register_middleware(self, app): ... + def register_routes(self, api, views): ... + + async def on_startup(self): ... + async def on_shutdown(self): ... +``` + +If your module overrides zero hooks, diagnostic `SM007` (INFO) asks whether the module is still doing anything useful. Empty modules are typically deletable. diff --git a/docs/framework/middleware.md b/docs/framework/middleware.md new file mode 100644 index 00000000..151db48f --- /dev/null +++ b/docs/framework/middleware.md @@ -0,0 +1,139 @@ +# Middleware pipeline + +Starlette's `app.add_middleware(...)` is **LIFO**. The last middleware added is the **first** one executed on an incoming request (and the last to see the response on the way out). Keep this in mind — the order you see in `create_app` reads "inside out". + +## Installation order (inside `create_app`) + +```python +# Added last → executed first (outermost wrapper) +app.add_middleware(CorrelationIdMiddleware) +app.add_middleware(RequestLoggingMiddleware) +app.add_middleware(SecurityHeadersMiddleware) +app.add_middleware(SessionMiddleware, secret_key=...) + +for module in discovered_modules: + module.register_middleware(app) # each module may add 0+ middleware + +if settings.multi_tenant: + app.add_middleware(TenantMiddleware) + +app.add_middleware(LocaleMiddleware) +app.add_middleware(InertiaLayoutDataMiddleware) +# Added first → executed last (closest to the app) +``` + +## Execution order (per request) + +``` +CorrelationId + ↓ +RequestLogging + ↓ +SecurityHeaders + ↓ +Session + ↓ + ← in whatever order each module installed them + ↓ +Tenant (only if SM_MULTI_TENANT=true) + ↓ +Locale + ↓ +InertiaLayoutData + ↓ +app (route handler) +``` + +The response flows back up in the reverse of this order. + +## What each built-in does + +### `CorrelationIdMiddleware` + +Reads the `X-Request-ID` header (or generates a UUID4), puts it on `request.state.correlation_id`, and adds it to every log line via `contextvars`. Included in the response as `X-Request-ID` so clients can cross-reference logs. + +### `RequestLoggingMiddleware` + +Emits a structured log line per request with method, path, status, duration, and correlation ID. Respects `SM_LOG_FORMAT` (plain vs JSON) and `SM_LOG_LEVEL`. + +### `SecurityHeadersMiddleware` + +Sets conservative defaults: `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Frame-Options: DENY`, plus CSP / HSTS in production. Override on a per-route basis with your own response headers. + +### `SessionMiddleware` + +Starlette's built-in signed-cookie sessions. Cookie name is `session`; attributes are `HttpOnly`, `SameSite=Lax`. `SameSite=Lax` is the CSRF defence: browsers don't attach the cookie to cross-site POST/PUT/DELETE, so a forged form submission from another origin is unauthenticated. + +### `TenantMiddleware` *(opt-in)* + +Reads `SM_TENANT_HEADER` (default `X-Tenant-ID`) and sets `request.state.tenant_id`. The `MultiTenantMixin` auto-filters SELECTs and auto-populates INSERTs using this value. + +### `LocaleMiddleware` + +Resolves the active locale in this order per request: +1. Cookie named by `SM_I18N_COOKIE_NAME` (validated against supported locales). +2. `Accept-Language` header with q-value parsing and longest-prefix match (`es-MX` → `es`). +3. `SM_I18N_DEFAULT_LOCALE`. + +The resolved locale lands on `request.state.locale` and is used by `InertiaLayoutDataMiddleware` to pick the translation bundle for the shared props. + +### `InertiaLayoutDataMiddleware` + +Runs last (closest to the app). Populates `request.state.inertia_shared` with: + +- `auth.user`, `auth.isAuthenticated`, `auth.permissions` +- `menus` — grouped by `MenuSection`, filtered by the current user's permissions +- `i18n` — `{ locale, bundle }` for the resolved locale + +Inertia responses pick these up automatically via `InertiaDep` from `simple_module_hosting.inertia_deps`. + +## Module middleware ordering + +When two modules at the **same dependency tier** both call `app.add_middleware(...)` in their `register_middleware` hook, the framework invokes their hooks in topological order with a stable tiebreaker (module name). Because `add_middleware` is LIFO, the module that sorts **later** wraps its middleware **outermost** — so it runs **first** on the request. + +Concretely, if modules `alpha` and `beta` both register middleware: + +- `alpha` runs first (alphabetical tiebreaker, no `depends_on`). +- `beta.register_middleware` runs last, so its middleware is the outermost wrap. +- On a request: `beta.mw → alpha.mw → tenant → locale → app`. + +If you need a specific relative order, express it with `ModuleMeta.depends_on`. **Do not rely on names** — another module could be installed tomorrow that sorts differently. + +## Writing a module middleware + +Use the Starlette pattern. Keep it asynchronous. + +```python +# modules/orders/orders/middleware.py +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp + +class OrdersRateLimitMiddleware(BaseHTTPMiddleware): + def __init__(self, app: ASGIApp, rate: int = 10) -> None: + super().__init__(app) + self.rate = rate + + async def dispatch(self, request, call_next): + # quick: consult request.app.state.sm.db / Redis for counters + response = await call_next(request) + response.headers["X-Orders-Rate-Limit"] = str(self.rate) + return response +``` + +Register: + +```python +# modules/orders/orders/module.py +def register_middleware(self, app: FastAPI) -> None: + app.add_middleware(OrdersRateLimitMiddleware, rate=10) +``` + +## Patterns to avoid + +**Reading request bodies.** Middleware that calls `await request.body()` consumes the stream; downstream handlers see an empty body. Use `ASGIApp` directly and replace the receive channel if you truly need this, or move the logic into a dependency. + +**Mutating `app.state` per-request.** `app.state` is shared across requests. Use `request.state` for request-scoped data. + +**Expensive setup per-request.** Middleware `__init__` runs once at installation; `dispatch` runs per request. Put constants in `__init__`. + +**Ordering via naming tricks.** Prefixing modules with `aa_` to make them sort first works — until another dev copies the pattern and two modules collide. Use `depends_on` instead. diff --git a/docs/framework/overview.md b/docs/framework/overview.md new file mode 100644 index 00000000..87393149 --- /dev/null +++ b/docs/framework/overview.md @@ -0,0 +1,94 @@ +# Framework overview + +simple_module_python is a **modular-monolith framework**. The framework is `framework/core`, `framework/db`, and `framework/hosting` — three packages that provide the module system, DB session management, and the FastAPI app builder. Everything else is a plugin module. + +```text +framework/ + core/ # ModuleBase, discovery, event bus, diagnostics + db/ # create_module_base, mixins, session lifecycle + hosting/ # create_app, middleware pipeline, settings +``` + +The framework has **no knowledge of any specific plugin module**. Diagnostic `SM009` fires as an error if framework code imports from anything under `modules/*`. + +## Boot sequence + +What actually happens when you run `uvicorn host.main:app`: + +1. **`create_app(settings)`** is invoked (in `host/main.py` via FastAPI's lifespan). +2. **Framework singletons** are constructed: + `Settings`, `DatabaseState` (engines per provider), `EventBus`, `MenuRegistry`, `PermissionRegistry`, `FeatureFlagRegistry`, `HealthRegistry`, `I18nRegistry`. They are bundled into a frozen `Services` dataclass and attached to `app.state.sm`. +3. **Discovery** — `discover_modules()` reads Python entry points under the `simple_module` group, imports each one, validates it's a `ModuleBase` subclass with a non-null `meta`, and topologically sorts by `ModuleMeta.depends_on`. +4. **Lifecycle hooks run in sorted order**. For each module, in this order: + `register_settings` → `register_menu_items` → `register_permissions` → `register_feature_flags` → `register_event_handlers` → `register_health_checks` → `register_exception_handlers` → `register_middleware` → `register_routes(api_router, view_router)`. +5. **Middleware is installed** — framework middleware first, then whatever modules registered. See [Middleware pipeline](/framework/middleware). +6. **Routers mount** — `api_router` at `/api`, `view_router` at `/`. Each module's sub-routers were attached via `register_routes`. +7. **Lifespan `on_startup`** — each module's async `on_startup` runs in dependency order. This is where background workers, warm caches, or remote-service health probes start. +8. **Migration-head check (dev only)** — compares `alembic_version` to the migration head and logs a warning if behind. In production this is a hard failure (`SM010`). Result goes on `app.state.migration`. +9. App is ready to serve. + +On shutdown, `on_shutdown` hooks run in **reverse** order. + +## Strict mode vs. lenient mode + +Discovery and diagnostics behave differently based on `SM_ENVIRONMENT`: + +| Environment | Mode | Behavior | +|---|---|---| +| `development` (default) | Lenient | Entry-point load errors are logged; the module is skipped. `SM001` warnings printed. | +| `test`, `testing` | Lenient | Same as development, so fixtures can load partial worlds. | +| Anything else (e.g. `production`) | Strict | Any missing `meta`, duplicate name (`SM008`), framework→plugin import (`SM009`), or DB drift (`SM010`) fails boot. | + +The distinction exists so developer ergonomics aren't gated on fixing every warning, but production deployments can't silently run with broken modules. + +## The Services container + +Framework singletons live on `app.state.sm`, a frozen dataclass defined in `simple_module_hosting.services`: + +```python +@dataclass(frozen=True) +class Services: + settings: Settings + db: DatabaseState + event_bus: EventBus + menu_registry: MenuRegistry + permissions: PermissionRegistry + feature_flags: FeatureFlagRegistry + health_registry: HealthRegistry + i18n_registry: I18nRegistry + inertia_config: InertiaConfig + modules: tuple[ModuleBase, ...] +``` + +Read from a request with `request.app.state.sm.`. **Do not** attach new attributes to `app.state` for framework-owned state — that's what `Services` is for. Two attributes are intentionally kept outside: + +- `app.state.inertia_dependency` — request-scoped `Depends` factory from fastapi-inertia. +- `app.state.migration` — the dev-only boot-time migration check result. + +Module-owned state goes on `app.state.` (a per-module dataclass you define). See [Settings & app.state](/framework/settings). + +## Framework vs. plugin boundary + +Framework code must never import from `modules/*`. Diagnostic `SM009` enforces this at CI time. To invert dependencies when framework code needs per-module behavior, register a callback from the module: + +```python +# Framework defines a registry +class PrincipalSerializerRegistry: + def register(self, fn: Callable[[User], dict]) -> None: ... + +# Module registers during register_settings +class UsersModule(ModuleBase): + def register_settings(self, app: FastAPI) -> None: + app.state.sm.inertia_config.register_principal_serializer( + serialize_user + ) +``` + +This is how the `auth.user` shared prop is built: framework middleware calls whatever serializer the `users` module registered — without importing `users`. + +## What's next + +- [Discovery & entry points](/framework/discovery) — how modules are installed and found. +- [Lifecycle hooks](/framework/lifecycle) — the 10 hooks in call order with examples. +- [Middleware pipeline](/framework/middleware) — execution order and how to slot your own in. +- [Settings & app.state](/framework/settings) — framework vs. module state. diff --git a/docs/framework/permissions.md b/docs/framework/permissions.md new file mode 100644 index 00000000..b31eea12 --- /dev/null +++ b/docs/framework/permissions.md @@ -0,0 +1,149 @@ +# Permissions + +Permissions are **strings** owned by modules and enforced at endpoint boundaries. The framework doesn't know about specific permission strings — each module declares its own and attaches them to roles via the admin UI. + +## Declaring permissions + +Inside a module's `register_permissions(registry)`: + +```python +from simple_module_core.permissions import PermissionRegistry + +class OrdersModule(ModuleBase): + def register_permissions(self, registry: PermissionRegistry) -> None: + registry.add_group("Orders", [ + "orders.view", + "orders.create", + "orders.edit", + "orders.delete", + "orders.export", + ]) +``` + +The group name (`"Orders"`) is the display label in the role editor. Permission strings conventionally use `.` lowercase. + +## Enforcing at endpoints + +Use the `RequiresPermission` dependency from `simple_module_hosting.permissions`: + +```python +from fastapi import APIRouter, Depends +from simple_module_hosting.permissions import RequiresPermission + +router = APIRouter() + +@router.post( + "", + status_code=201, + dependencies=[Depends(RequiresPermission("orders.create"))], +) +async def create_order(...): ... + +@router.get( + "/export", + dependencies=[ + Depends(RequiresPermission("orders.view")), + Depends(RequiresPermission("orders.export")), + ], +) +async def export_orders(...): ... +``` + +`RequiresPermission` raises `HTTPException(403)` if the current principal lacks the permission. Unauthenticated requests return 401 — the auth middleware runs earlier and attaches `request.state.principal`. + +## Roles + +Roles are named collections of permissions stored in the `users_role` table (owned by the `users` module). The mapping from role to permissions lives in `DEFAULT_ROLE_PERMISSIONS` (framework default) + per-role overrides in the DB. + +```python +# simple_module_hosting.permissions +DEFAULT_ROLE_PERMISSIONS = { + "admin": ["*"], +} +``` + +Host apps customize this by: + +1. Editing roles in the admin UI at `/settings/permissions`. +2. Or seeding via code during `on_startup` in a custom host-level module. + +The framework ships only `admin: ["*"]`. The wildcard grants every declared permission. + +## Principal resolution + +For each request, middleware resolves the current **principal** — a normalized user snapshot with expanded permissions — onto `request.state.principal`: + +```python +@dataclass +class Principal: + user_id: int | None + email: str | None + roles: list[str] + permissions: set[str] +``` + +The `users` module owns the extraction from the session cookie. The framework's `InertiaLayoutDataMiddleware` reads `request.state.principal` and serializes `auth.user`, `auth.isAuthenticated`, `auth.permissions` into the Inertia shared props. + +## Permission shorthand for menus + +`MenuItem.required_permission` hides an item from users who don't have it. Evaluated in `InertiaLayoutDataMiddleware` *before* sending the menu to the client — the client never sees menu items it can't use. + +```python +registry.add(MenuItem( + section=MenuSection.SIDEBAR, + key="orders", + label_key="orders.menu.orders", + href="/orders", + required_permission="orders.view", +)) +``` + +If you need a conjunction of permissions, use a comma-separated string (`"a,b"` means both) or a dedicated `required_any_of: list[str]` — see `simple_module_core.menu`. + +## Client-side checks + +The Inertia shared props include `auth.permissions`. Use them for UI affordances (hide/disable buttons), not for security: + +```tsx +import { useAuth } from "@simple-module-py/ui"; + +export default function OrdersToolbar() { + const { permissions } = useAuth(); + return ( +
+ {permissions.has("orders.create") && ( + + )} +
+ ); +} +``` + +**Security is enforced server-side.** A client-side check only hides the button; `RequiresPermission` on the POST endpoint prevents the actual creation. + +## Testing with permissions + +The `authenticated_client` fixture in `conftest.py` seeds an admin user (who has `*`). For tests that need a less-privileged user, build one from the `users` module fixtures or flip the principal temporarily: + +```python +@pytest.mark.asyncio +async def test_create_requires_permission(client, db_session): + # Create a user without `orders.create` + from users.bootstrap import create_user + user = await create_user(db_session, email="u@e.com", password="x") + # Sign in via HTTP to get the session cookie + r = await client.post( + "/users/login", data={"email": "u@e.com", "password": "x"} + ) + assert r.status_code in (200, 303) + + r = await client.post("/api/orders", json={...}) + assert r.status_code == 403 +``` + +## Conventions + +- **Namespace with the module name.** `orders.create`, not `create_order`. +- **Use `.` form.** Nouns like `orders.admin` are fine for broad grants. +- **Don't inline string checks.** `if "orders.create" in principal.permissions: ...` scattered through code is hard to audit. Use `RequiresPermission` or refactor into a dependency. +- **Don't use roles in business logic.** Check permissions, not `principal.roles`. Roles are an admin concept; permissions are the enforcement boundary. diff --git a/docs/framework/settings.md b/docs/framework/settings.md new file mode 100644 index 00000000..fcb63331 --- /dev/null +++ b/docs/framework/settings.md @@ -0,0 +1,174 @@ +# Settings & app.state + +There are **three** separate settings surfaces in a running app. They serve different purposes and live in different places. + +| Surface | Lives in | Mutable at runtime? | Use for | +|---|---|---|---| +| **Framework env** (`Settings`) | `app.state.sm.settings` | No (read once at boot) | DB URL, secret key, log level, anything needed before the DB is open. | +| **Module env** (`Env`) | `app.state..settings` | No | Module bootstrap knobs that must be resolved before DB-backed settings load. | +| **DB-backed settings** | `sm_settings.settings` table, edited via `/settings/modules` | Yes | Everything else: SMTP creds, storage backends, feature toggles that operators tune. | + +## Framework settings + +Defined as a pydantic `BaseSettings` subclass in `simple_module_hosting.settings`: + +```python +class Settings(BaseSettings): + database_url: str = "sqlite+aiosqlite:///./app.db" + environment: str = "development" + secret_key: str = "change-me-in-production" + vite_dev_url: str = "http://localhost:5050" + debug: bool = False + log_level: str = "INFO" + log_format: str = "plain" + multi_tenant: bool = False + tenant_header: str = "X-Tenant-ID" + modules_enabled: list[str] | None = None + + class Config: + env_prefix = "SM_" +``` + +Construct once at boot, put on `app.state.sm.settings`, never mutated. + +### Placeholder-secret check + +In production (`environment != "development"`), boot fails if `secret_key == "change-me-in-production"`. The check runs before middleware installation to avoid issuing signed cookies against a known-placeholder key. + +## Module settings + +Each module that needs configuration declares a **`Env`** pydantic-settings class with its own `env_prefix` and a **`State`** dataclass to bundle settings plus any runtime-computed singletons. + +```python +# modules/users/users/settings.py +from pydantic_settings import BaseSettings + +class UsersEnv(BaseSettings): + allow_signup: bool = False + mailer: str = "console" + base_url: str | None = None + smtp_host: str | None = None + smtp_port: int = 587 + smtp_username: str | None = None + smtp_password: str | None = None + + class Config: + env_prefix = "SM_USERS_" +``` + +```python +# modules/users/users/state.py +from dataclasses import dataclass + +@dataclass +class UsersState: + settings: UsersEnv + mailer: Mailer # picked based on settings.mailer + principal_serializer: Callable[[User], dict] +``` + +Attach in `register_settings`: + +```python +# modules/users/users/module.py +class UsersModule(ModuleBase): + def register_settings(self, app: FastAPI) -> None: + settings = UsersEnv() + app.state.users = UsersState( + settings=settings, + mailer=build_mailer(settings), + principal_serializer=serialize_user, + ) +``` + +### Why `app.state.`? + +- **Discoverable.** Every module follows the same pattern; diagnostics check for it (`SM012`). +- **Type-safe.** Code reads `request.app.state.users.settings.allow_signup` with full IDE autocomplete. +- **No global state.** The app instance owns it; tests get a fresh one per fixture. + +### Accessing from an endpoint + +```python +from fastapi import Request + +@router.get("/config") +async def users_config(request: Request): + state = request.app.state.users + return {"allow_signup": state.settings.allow_signup} +``` + +Or as a typed dependency — cleaner when you use it in many handlers: + +```python +# modules/users/users/deps.py +from typing import Annotated +from fastapi import Depends, Request + +def _users_state(request: Request) -> UsersState: + return request.app.state.users + +UsersStateDep = Annotated[UsersState, Depends(_users_state)] + +@router.get("/config") +async def users_config(state: UsersStateDep): + return {"allow_signup": state.settings.allow_signup} +``` + +## DB-backed settings + +After bootstrap, most configuration lives in the `sm_settings.settings` table and is edited via the admin UI at `/settings/modules`. Reads go through `SettingsService` which caches in-memory with invalidation on write: + +```python +from settings.service import SettingsService + +service = SettingsService(session) +max_size = await service.get_int("file_storage.max_upload_mb", default=10) +``` + +Values are keyed by `.`. Conventional namespace is the module name lowercased. + +### Seeding from env on upgrade + +Existing deployments that used env vars for module settings should run once: + +```bash +uv run sm-settings import-from-env +``` + +This reads the current environment, looks up keys the settings service knows about, and writes overrides into the DB. Idempotent — skips keys that already have a DB override. After running, you can remove those env vars from deployment config. + +## Framework state (`app.state.sm`) + +Framework singletons — not settings, but long-lived services — live on `app.state.sm`, a frozen `Services` dataclass populated once at boot: + +```python +@dataclass(frozen=True) +class Services: + settings: Settings + db: DatabaseState + event_bus: EventBus + menu_registry: MenuRegistry + permissions: PermissionRegistry + feature_flags: FeatureFlagRegistry + health_registry: HealthRegistry + i18n_registry: I18nRegistry + inertia_config: InertiaConfig + modules: tuple[ModuleBase, ...] +``` + +**Read** from a request: `request.app.state.sm.`. +**Never** mutate `app.state` for framework-owned state — add a field to `Services` if you need new shared infrastructure. + +Two attributes are intentionally outside `Services`: + +- `app.state.inertia_dependency` — request-scoped `Depends` factory, provided by the fastapi-inertia package (framework doesn't own the type). +- `app.state.migration` — a dev-only boot-time migration check result set inside the lifespan (after `Services` is already frozen). + +## Diagnostic: `SM012` + +Fires at dev-boot if a module overrides `register_settings` but does **not** assign anything to `app.state.`. Interpretations: + +- The override is vestigial — delete it (revert to `pass` or remove the method). +- You stored state under a non-standard key — rename to `app.state.` so other tooling can find it. +- You're attaching state inside a different hook (e.g. `on_startup`). Move settings creation to `register_settings`; `on_startup` should assume `app.state.` already exists. diff --git a/docs/frontend/inertia.md b/docs/frontend/inertia.md new file mode 100644 index 00000000..dfe5d843 --- /dev/null +++ b/docs/frontend/inertia.md @@ -0,0 +1,157 @@ +# Inertia basics + +Inertia.js turns classic server-side routing into a single-page app without a separate API. The server returns a JSON payload describing which React page to render and what props to pass; the client's Inertia runtime swaps in the page component with a `fetch`-level round-trip. No bespoke API, no client-side router, no state-sync code. + +## What's on the server + +Two moving parts: + +1. **`fastapi-inertia`** — renders templates and serializes responses in Inertia's expected shape. +2. **`InertiaLayoutDataMiddleware`** (framework) — attaches shared props (`auth`, `menus`, `i18n`) to every response. + +## Rendering a page + +```python +# modules/orders/orders/endpoints/views.py +from fastapi import APIRouter +from simple_module_hosting.inertia_deps import InertiaDep + +from orders.contracts.schemas import OrderOut +from orders.deps import OrderServiceDep + +router = APIRouter() + +@router.get("") +async def browse(inertia: InertiaDep, service: OrderServiceDep): + orders: list[OrderOut] = await service.list() + return await inertia.render( + "Orders/Browse", + props={"orders": [o.model_dump(mode="json") for o in orders]}, + ) +``` + +Breakdown: + +- `InertiaDep` is a request-scoped dependency provided by fastapi-inertia. It's attached to `app.state.inertia_dependency` at boot. +- The first argument to `render` is the **page key** — `"Orders/Browse"` maps to `modules/orders/orders/pages/Browse.tsx`. See [Pages & discovery](/frontend/pages). +- `props` is a plain dict serialized to JSON. Use `model_dump(mode="json")` on SQLModel DTOs to get JSON-safe types (`Decimal → str`, `datetime → iso string`). + +## Responding with JSON vs. Inertia + +- **`endpoints/api.py`** — returns JSON. Called by background/non-Inertia code, or by Inertia's `router.get/post` on the client (which sends `X-Inertia: true` and receives a full Inertia response, **not** raw JSON). +- **`endpoints/views.py`** — returns Inertia pages. Routed at `view_prefix`, rendered as full HTML on the first request and as JSON page updates on client-side navigation. + +The split is convention: keep GET-for-page-data calls in `views.py`, keep POST/PATCH/DELETE data mutations in `api.py` even if triggered from Inertia forms. This way JSON APIs are usable from scripts and Inertia pages route cleanly. + +## Inertia form submission + +From a page, use Inertia's `router.post`: + +```tsx +import { router } from "@inertiajs/react"; + +function CreateOrder() { + const [data, setData] = useState({ customer_email: "", total: "" }); + return ( +
{ + e.preventDefault(); + router.post("/api/orders", data); + }} + > + ... + + ); +} +``` + +Inertia sends the request with the `X-Inertia` header and expects an Inertia response back (a redirect or a page render). **Do not** point Inertia's `router.post` at a JSON endpoint — fastapi-inertia sees a non-Inertia response and throws. + +This is exactly what diagnostic `SM018` warns about: an `api.py` route that returns JSON, called from a page via `router.post/patch/put/delete`. Either: + +- Change the endpoint to live in `views.py` and return `inertia.render(...)` or a redirect. +- Or change the page code to use `fetch(...)` for a true JSON call. + +## Redirect-after-post + +Inertia expects a redirect response (302/303) after a successful mutation: + +```python +# modules/orders/orders/endpoints/views.py +from fastapi.responses import RedirectResponse + +@router.post("") +async def create( + data: OrderCreate, inertia: InertiaDep, service: OrderServiceDep +): + order = await service.create(data) + return RedirectResponse(f"/orders/{order.id}", status_code=303) +``` + +Inertia's client follows the redirect and renders the new page. + +## Flash messages + +For "created successfully" toasts, use Starlette's session to flash a message: + +```python +request.session["flash"] = {"type": "success", "message": "Order created"} +return RedirectResponse("/orders", status_code=303) +``` + +Then expose it in the shared props — `InertiaLayoutDataMiddleware` already reads `request.session.pop("flash", None)` and attaches it to `request.state.inertia_shared["flash"]`. + +## What the shared props look like + +Every Inertia response includes: + +```ts +{ + auth: { + user: { id, email, full_name, ... } | null, + isAuthenticated: boolean, + permissions: string[], + }, + menus: { + sidebar: MenuItem[], + adminSidebar: MenuItem[], + navbar: MenuItem[], + userDropdown: MenuItem[], + }, + i18n: { + locale: string, + bundle: Record, // flat key → value + }, + flash: { type: string, message: string } | null, +} +``` + +Accessed from any page via `usePage().props.auth` etc., or via typed helpers from `@simple-module-py/ui` (`useAuth`, `useMenus`, `useT`). + +## Frontend runtime setup + +`host/client_app/src/main.tsx` bootstraps Inertia: + +```tsx +import { createInertiaApp } from "@inertiajs/react"; +import { resolvePage } from "./modules.generated"; + +createInertiaApp({ + resolve: resolvePage, + setup: ({ el, App, props }) => { + createRoot(el).render(); + }, +}); +``` + +`resolvePage` is generated by `make gen-pages` from the module manifest. It maps a page key (e.g. `"Orders/Browse"`) to a dynamic import of the matching `.tsx` file. + +## CSRF + +There is no explicit CSRF token middleware. Protection comes from `SameSite=Lax` on the session cookie: + +- Browsers don't attach the session cookie to **cross-site** POST/PUT/DELETE. +- A forged form-submit from another origin arrives without authentication, so it's 401/403. +- Same-site form submissions work normally. + +This is sufficient because Inertia mutations go through `fetch`, which honors `SameSite`. Raw `fetch()` calls in page code don't need a CSRF header. diff --git a/docs/frontend/pages.md b/docs/frontend/pages.md new file mode 100644 index 00000000..be3ec2ab --- /dev/null +++ b/docs/frontend/pages.md @@ -0,0 +1,185 @@ +# Pages & discovery + +Every `.tsx` file under `modules///pages/` is an Inertia page, automatically discovered by `make gen-pages` and resolvable from server-side `inertia.render(...)` calls. + +## Page keys + +The server identifies a page by a **key**: `"/"`. + +| File | Page key | +|---|---| +| `modules/orders/orders/pages/Browse.tsx` | `"Orders/Browse"` | +| `modules/orders/orders/pages/Create.tsx` | `"Orders/Create"` | +| `modules/orders/orders/pages/admin/Settings.tsx` | `"Orders/admin/Settings"` | +| `modules/blog_posts/blog_posts/pages/Edit.tsx` | `"BlogPosts/Edit"` | +| `host/client_app/pages/Landing.tsx` | `"Landing"` (host-level, no namespace) | + +- **``** is the **PascalCase** of the module's package directory: `blog_posts` → `BlogPosts`, `file_storage` → `FileStorage`. +- **``** is the path under `pages/` minus `.tsx`. Subdirectories become slashes: `admin/Settings.tsx` → `admin/Settings`. +- **Host-level pages** live under `host/client_app/pages/` and use just the page name without a namespace prefix. + +## Rendering + +```python +# In an endpoint +return await inertia.render( + "Orders/Browse", + props={"orders": [...]}, +) +``` + +The key must match a generated entry in `modules.generated.ts`. Mismatches produce: + +- **`SM003`** (warning) — the `.tsx` file exists, but no `inertia.render()` call in any module references it. The page is orphaned. +- **`SM004`** (warning) — an `inertia.render("Orders/Unknown", ...)` call exists, but there's no matching `.tsx` file. You'll get a runtime error when a user hits that route. + +Both fire during `make doctor` and at dev boot. + +## The generation pipeline + +`make gen-pages` (invoked automatically by `make dev` before starting Vite) runs `scripts/gen_pages.py`. It: + +1. Discovers every installed module's page directory. +2. Builds an object literal mapping page keys to dynamic imports. +3. Writes three files to `host/client_app/`: + - `modules.generated.ts` — the `resolvePage` function used by Inertia. + - `modules.manifest.json` — metadata used by diagnostics. + - `modules.generated.css` — imports for any module-scoped CSS. + +These files are **regenerated on every `make dev` run**. Never hand-edit them. Add them to `.gitignore` if they aren't already. + +```ts +// host/client_app/modules.generated.ts (generated) +export async function resolvePage(name: string) { + switch (name) { + case "Orders/Browse": + return (await import("../../modules/orders/orders/pages/Browse.tsx")).default; + case "Orders/Create": + return (await import("../../modules/orders/orders/pages/Create.tsx")).default; + // ... + default: + throw new Error(`Unknown page: ${name}`); + } +} +``` + +Vite resolves these imports normally at build time and HMR-watches them in dev. + +## Writing a page + +A minimal page: + +```tsx +// modules/orders/orders/pages/Browse.tsx +import { useT } from "@simple-module-py/i18n-react"; +import { PageHeader } from "@simple-module-py/ui"; +import type { OrderOut } from "../contracts"; + +interface BrowseProps { + orders: OrderOut[]; +} + +export default function Browse({ orders }: BrowseProps) { + const { t } = useT(); + return ( + <> + +
    + {orders.map((o) => ( +
  • + {o.customer_email} — {o.status} +
  • + ))} +
+ + ); +} +``` + +- `export default` is **required** — Inertia's resolver expects a default export. +- Props are passed from the server; type them explicitly. +- Use `useT()` for i18n and `useAuth()` / `useMenus()` from `@simple-module-py/ui` for shared props. + +## Subdirectories + +Organize larger modules into subdirectories. The page key uses forward slashes: + +``` +modules/orders/orders/pages/ +├── Browse.tsx +├── admin/ +│ ├── Dashboard.tsx → "Orders/admin/Dashboard" +│ └── Settings.tsx → "Orders/admin/Settings" +└── customer/ + └── Portal.tsx → "Orders/customer/Portal" +``` + +## Layouts + +Shared layouts live in `packages/ui/src/layouts/`. Use them via JSX composition: + +```tsx +import { AuthenticatedLayout } from "@simple-module-py/ui"; + +export default function Browse({ orders }) { + return ( + + + ... + + ); +} +``` + +Or use Inertia's persistent-layout pattern for layouts that survive navigation: + +```tsx +Browse.layout = (page: ReactNode) => {page}; +``` + +The second form re-uses the layout instance across navigations — good for layouts with expensive setup or local state you want preserved. + +## Shared components + +`packages/ui` is a shared shadcn-based component library. It's a proper npm workspace — import via its package name, not relative paths: + +```tsx +// ✅ +import { Button, DataTable, Input } from "@simple-module-py/ui"; + +// ❌ +import { Button } from "../../../../packages/ui/src/components/ui/Button"; +``` + +The Vite alias is set up so `@simple-module-py/ui` resolves at build time. + +## Module-scoped CSS + +If your module needs its own CSS (beyond Tailwind classes), put it in `modules///pages/.css` and import it from the page: + +```tsx +import "./Browse.css"; +``` + +`make gen-pages` also emits `modules.generated.css`, which imports any `/styles.css` declared at the module level. + +## Page-level TypeScript + +Each module's `tsconfig.json` extends the host's; page files can import across module boundaries (from `/contracts`) freely. Cross-module imports from anything *other than* `contracts` is a code smell — you're reaching into implementation details. + +## Testing pages + +React tests use Vitest + Testing Library, configured in `vitest.setup.ts`. A minimal page test: + +```tsx +// modules/orders/orders/pages/__tests__/Browse.test.tsx +import { render, screen } from "@testing-library/react"; +import Browse from "../Browse"; + +it("renders orders", () => { + render(); + expect(screen.getByText(/a@b.c/)).toBeInTheDocument(); +}); +``` + +For tests that hit shared props (`useT`, `useAuth`), wrap in a mock provider — see `packages/ui/src/test-utils.tsx`. diff --git a/docs/frontend/shared-props.md b/docs/frontend/shared-props.md new file mode 100644 index 00000000..9da8dc41 --- /dev/null +++ b/docs/frontend/shared-props.md @@ -0,0 +1,190 @@ +# Shared props & layout + +Every Inertia response carries a set of shared props attached by `InertiaLayoutDataMiddleware`. They're available on every page without the server-side handler having to pass them. + +## The contract + +```ts +interface SharedProps { + auth: { + user: UserSummary | null; + isAuthenticated: boolean; + permissions: string[]; + }; + menus: { + sidebar: MenuItem[]; + adminSidebar: MenuItem[]; + navbar: MenuItem[]; + userDropdown: MenuItem[]; + }; + i18n: { + locale: string; + bundle: Record; + }; + flash: { type: "success" | "error" | "info"; message: string } | null; +} +``` + +## How they're populated + +`InertiaLayoutDataMiddleware` runs **last** in the middleware pipeline (closest to the app). For every outgoing Inertia response, it reads: + +- `request.state.principal` (set by the users module's auth middleware) and the registered `principal_serializer` to build `auth.user`. +- `app.state.sm.menu_registry`, filtered by the current principal's permissions, to build `menus`. +- `request.state.locale` + `app.state.sm.i18n_registry` to build `i18n`. +- `request.session.pop("flash", None)` to build `flash`. + +Then it merges those into the Inertia payload under the "shared" slot. + +## Accessing from a page + +### Typed helpers + +Prefer the helpers from `@simple-module-py/ui`: + +```tsx +import { useAuth, useMenus, useT } from "@simple-module-py/ui"; + +export default function Browse() { + const { user, permissions } = useAuth(); + const { sidebar } = useMenus(); + const { t, locale } = useT(); + ... +} +``` + +### Raw + +For cases where a helper doesn't cover your need, use Inertia's `usePage`: + +```tsx +import { usePage } from "@inertiajs/react"; +import type { SharedProps } from "@simple-module-py/ui"; + +const { props } = usePage(); +console.log(props.auth.user, props.i18n.locale); +``` + +## `auth.user` — the principal serializer + +The framework doesn't know the shape of your `User`. A module (typically `users`) registers a `principal_serializer: Callable[[User], dict]` during `register_settings`: + +```python +# modules/users/users/module.py +class UsersModule(ModuleBase): + def register_settings(self, app: FastAPI) -> None: + app.state.sm.inertia_config.register_principal_serializer( + serialize_user + ) + +def serialize_user(user: User) -> dict: + return { + "id": user.id, + "email": user.email, + "full_name": user.full_name, + "roles": [r.name for r in user.roles], + } +``` + +Without a registered serializer, `auth.user` is `None` even when a user is authenticated. This is intentional — the framework has no opinion on what a "user" is. If you replace the users module with your own auth, register your own serializer. + +## Menus + +Populated from the `MenuRegistry`. Each module adds items during `register_menu_items`: + +```python +registry.add(MenuItem( + section=MenuSection.SIDEBAR, + key="orders", + label_key="orders.menu.orders", + href="/orders", + icon="package", + required_permission="orders.view", + order=20, +)) +``` + +`InertiaLayoutDataMiddleware` filters by `required_permission` before sending — users who lack it never see the item. + +Render in a layout: + +```tsx +import { useMenus } from "@simple-module-py/ui"; + +export function Sidebar() { + const { sidebar } = useMenus(); + return ( + + ); +} +``` + +Labels are **pre-translated**. The middleware runs through `I18nRegistry` before serializing, so the client just renders `item.label`. + +## `flash` — flash messages + +Flash a message server-side with Starlette's session: + +```python +@router.post("") +async def create(...): + ... + request.session["flash"] = {"type": "success", "message": "Order created"} + return RedirectResponse("/orders", status_code=303) +``` + +`InertiaLayoutDataMiddleware` pops and forwards it. Consume in a toast component: + +```tsx +import { useFlash } from "@simple-module-py/ui"; +import { useEffect } from "react"; +import { toast } from "sonner"; + +export function FlashToaster() { + const flash = useFlash(); + useEffect(() => { + if (flash) toast[flash.type](flash.message); + }, [flash]); + return null; +} +``` + +Shows once per request, then gone. + +## `i18n` — the active bundle + +`useT` reads the bundle and exposes `t(key, params?)`: + +```tsx +const { t, locale } = useT(); +t("orders.browse.title"); +t("orders.items", { count: orders.length }); // pluralization +``` + +The bundle is the **resolved locale's** translations merged across all modules. Switching the locale (via `` or a direct POST to `/i18n/set-locale`) triggers a full-page navigation so the bundle is re-fetched. + +## Extending shared props + +If your module needs to inject a new shared prop (e.g. feature flags), do **not** mutate `request.state.inertia_shared` from random handlers — wrap in a dedicated middleware: + +```python +class FeatureFlagsSharedPropsMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + flags = request.app.state.sm.feature_flags.evaluate_all( + principal=getattr(request.state, "principal", None) + ) + if not hasattr(request.state, "inertia_shared"): + request.state.inertia_shared = {} + request.state.inertia_shared["flags"] = flags + return await call_next(request) +``` + +Register in `register_middleware`. Because it runs *before* `InertiaLayoutDataMiddleware` (framework middleware runs first on the way in), the flag dict is already attached when the framework middleware merges its own props. + +On the client, extend the `SharedProps` type in your module's `contracts/` and re-export. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md new file mode 100644 index 00000000..f62b17bc --- /dev/null +++ b/docs/guide/configuration.md @@ -0,0 +1,113 @@ +# Configuration + +Runtime configuration has **two** sources, in order of precedence: + +1. **Environment variables** (`SM_*`) — read at boot, before the DB connection is open. Use for bootstrap plumbing (DB URL, secret key, feature-flag overrides for tests). +2. **DB-backed settings** — edited from `/settings/modules` at runtime. Use for everything else: SMTP creds, storage backends, module-specific toggles. + +Most deployments only need `SM_DATABASE_URL` and `SM_SECRET_KEY` in the environment — everything else is configurable from the admin UI. + +## Framework env vars + +Prefix is always `SM_`. These are parsed by `simple_module_hosting.settings.Settings` (pydantic) at startup. + +| Variable | Default | Notes | +|---|---|---| +| `SM_DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | **Required in production.** Async URL. Postgres: `postgresql+asyncpg://…` | +| `SM_ENVIRONMENT` | `development` | Any value other than `development`, `test`, `testing` triggers strict discovery + placeholder-secret checks. | +| `SM_SECRET_KEY` | `change-me-in-production` | **Must** be overridden in production — session cookie signing key. | +| `SM_VITE_DEV_URL` | `http://localhost:5050` | Dev only — where the Vite HMR client connects. | +| `SM_DEBUG` | `false` | Enables debug mode (shows tracebacks in HTTP responses). | +| `SM_LOG_LEVEL` | `INFO` | `DEBUG`/`INFO`/`WARNING`/`ERROR` | +| `SM_LOG_FORMAT` | `plain` | `plain` for dev, `json` for structured logs in prod. | +| `SM_MULTI_TENANT` | `false` | Enables `TenantMiddleware` + `MultiTenantMixin` auto-filter. | +| `SM_TENANT_HEADER` | `X-Tenant-ID` | HTTP header that identifies the current tenant. | +| `SM_MODULES_ENABLED` | unset (all enabled) | Comma-separated allow-list to disable modules without uninstalling them. | + +## Database bootstrap knobs + +Only change if you know what you're doing — these must be set *before* the DB connection opens, so they can't live in the DB-backed settings store. + +| Variable | Default | Notes | +|---|---|---| +| `SM_DB_POOL_SIZE` | `5` | SQLAlchemy `pool_size` | +| `SM_DB_MAX_OVERFLOW` | `10` | SQLAlchemy `max_overflow` | +| `SM_DB_POOL_PRE_PING` | `true` | Test connections before use | +| `SM_DB_POOL_RECYCLE` | `1800` | Recycle connections after N seconds | + +## Internationalization + +| Variable | Default | Notes | +|---|---|---| +| `SM_I18N_DEFAULT_LOCALE` | `en` | Must be in `SM_I18N_SUPPORTED_LOCALES`. | +| `SM_I18N_SUPPORTED_LOCALES` | `en` | Comma-separated, e.g. `en,es,de`. | +| `SM_I18N_COOKIE_NAME` | `locale` | Cookie that stores the user's selected locale. | + +## Users module + +Prefix `SM_USERS_*`. These live in the env for bootstrap; everything else moved to the DB-backed settings store. + +| Variable | Default | Notes | +|---|---|---| +| `SM_USERS_BOOTSTRAP_EMAIL` | unset | Auto-creates an admin if set **and** `users_user` is empty. | +| `SM_USERS_BOOTSTRAP_PASSWORD` | unset | Paired with the email above. | +| `SM_USERS_ALLOW_SIGNUP` | `false` | If `true`, `/users/register` is public. | +| `SM_USERS_MAILER` | `console` | `console` (logs invite link to stdout) or `smtp`. | +| `SM_USERS_BASE_URL` | derived | The public URL used to build invite links. | +| `SM_USERS_SMTP_HOST` | — | Only when `SM_USERS_MAILER=smtp`. | +| `SM_USERS_SMTP_PORT` | `587` | | +| `SM_USERS_SMTP_USERNAME` | — | | +| `SM_USERS_SMTP_PASSWORD` | — | | +| `SM_USERS_SMTP_FROM` | — | | +| `SM_USERS_SMTP_TLS` | `true` | | + +## Background tasks (Celery) + +Prefix `SM_BG_TASKS_*`. The defaults in `docker-compose.yml` already set these so Celery can reach the in-container `redis` service before DB-backed settings are loaded. + +| Variable | Default | Notes | +|---|---|---| +| `SM_BG_TASKS_BROKER_URL` | `redis://localhost:6379/0` | Celery broker. | +| `SM_BG_TASKS_RESULT_BACKEND` | `redis://localhost:6379/1` | Celery result backend. | + +## DB-backed settings + +After upgrading from an older deployment, import existing `SM_*` values into the DB settings store once: + +```bash +uv run sm-settings import-from-env +``` + +This is idempotent — it only seeds keys that don't have a DB override yet. + +From then on, edit at `/settings/modules` (requires the `settings.manage` permission). Changes apply immediately; no restart needed. + +## Per-module settings convention + +When you write your own module, declare settings as a dataclass on `app.state.` inside `register_settings(app)`. The prefix for env vars must be `SM__*`. + +```python +# modules/orders/orders/settings.py +from dataclasses import dataclass +from pydantic_settings import BaseSettings + +class OrdersEnv(BaseSettings): + max_items_per_order: int = 100 + class Config: + env_prefix = "SM_ORDERS_" + +@dataclass +class OrdersState: + settings: OrdersEnv + +# modules/orders/orders/module.py +class OrdersModule(ModuleBase): + def register_settings(self, app: FastAPI) -> None: + app.state.orders = OrdersState(settings=OrdersEnv()) +``` + +If you override `register_settings` but don't write to `app.state.`, diagnostic `SM012` warns in dev. See [Settings & app.state](/framework/settings) for details. + +## Placeholder-secret check + +In production (`SM_ENVIRONMENT != development`), boot fails if `SM_SECRET_KEY` is still the default `change-me-in-production`. Override it before deploying. diff --git a/docs/guide/first-module.md b/docs/guide/first-module.md new file mode 100644 index 00000000..b7e19c52 --- /dev/null +++ b/docs/guide/first-module.md @@ -0,0 +1,291 @@ +# Your first module + +A stage-by-stage walk-through: from `make new-module` to a working Orders module with custom fields, validation, a menu entry, and a test. + +Assumes you've completed the [Quickstart](/guide/quickstart). + +## 1. Scaffold + +```bash +make new-module name=orders +``` + +This creates `modules/orders/` with a working CRUD implementation against a single-field `Order` table. Open it in your editor — you'll see the full file layout described in [project structure](/guide/project-structure). + +## 2. Define your domain model + +Edit `modules/orders/orders/models.py`: + +```python +from decimal import Decimal +from simple_module_db.base import create_module_base +from simple_module_db.mixins import AuditMixin, SoftDeleteMixin +from sqlmodel import Field + +Base = create_module_base("orders") + +class Order(Base, AuditMixin, SoftDeleteMixin, table=True): + __tablename__ = "orders_order" + + id: int | None = Field(default=None, primary_key=True) + customer_email: str = Field(max_length=200, index=True) + total: Decimal = Field(max_digits=10, decimal_places=2) + status: str = Field(default="pending", max_length=20) +``` + +- `AuditMixin` adds `created_at`, `updated_at`, `created_by`, `updated_by` — populated automatically from the request user. +- `SoftDeleteMixin` replaces `DELETE` with `is_deleted=true`. `SELECT` filters them out by default; pass `include_deleted=True` to bypass. +- `__tablename__` must be prefixed with the module name under SQLite. On Postgres, the per-module Base puts tables in an `orders` schema automatically, so the prefix is redundant but harmless. + +## 3. Update the DTOs + +Edit `modules/orders/orders/contracts/schemas.py`: + +```python +from decimal import Decimal +from datetime import datetime +from pydantic import ConfigDict +from sqlmodel import Field, SQLModel + +class OrderCreate(SQLModel): + customer_email: str = Field(min_length=1, max_length=200) + total: Decimal = Field(ge=0) + +class OrderUpdate(SQLModel): + status: str | None = Field(default=None, max_length=20) + +class OrderOut(SQLModel): + model_config = ConfigDict(from_attributes=True) + + id: int + customer_email: str + total: Decimal + status: str + created_at: datetime +``` + +DTOs are plain `SQLModel` subclasses — **not** `BaseModel` and **not** `table=True`. They're the public surface other modules import from `orders.contracts`. + +## 4. Generate a migration + +```bash +make migration msg="add orders tables" +``` + +Open `host/migrations/versions/XXXX_add_orders_tables.py` and eyeball it: + +- It should create the `orders` schema (Postgres) or the `orders_order` table (SQLite). +- It should set `branch_labels = ("orders",)` — this enables `alembic downgrade orders@base` to roll the module back to empty without touching other modules. + +Apply: + +```bash +make migrate +``` + +## 5. Wire up service + endpoints + +The scaffold already generated working CRUD. Trim it to match your domain or extend it: + +```python +# modules/orders/orders/service.py +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import select + +from orders.contracts.schemas import OrderCreate, OrderOut, OrderUpdate +from orders.models import Order + +class OrderService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def create(self, data: OrderCreate) -> OrderOut: + order = Order(**data.model_dump()) + self.session.add(order) + await self.session.flush() # get the DB-assigned id + return OrderOut.model_validate(order) + + async def list(self) -> list[OrderOut]: + rows = (await self.session.exec(select(Order))).all() + return [OrderOut.model_validate(r) for r in rows] + + async def update(self, order_id: int, data: OrderUpdate) -> OrderOut: + order = await self.session.get(Order, order_id) + if order is None: + raise KeyError(order_id) + for field, value in data.model_dump(exclude_none=True).items(): + setattr(order, field, value) + await self.session.flush() + return OrderOut.model_validate(order) +``` + +Note the absence of `self.session.commit()`. The per-request `get_db` dependency commits if (and only if) there were pending writes. See [Session lifecycle](/database/sessions). + +## 6. Register permissions and menu + +```python +# modules/orders/orders/module.py +from fastapi import FastAPI, APIRouter +from simple_module_core.module import ModuleBase, ModuleMeta +from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection +from simple_module_core.permissions import PermissionRegistry + +from orders.endpoints.api import router as api_router +from orders.endpoints.views import router as view_router + +class OrdersModule(ModuleBase): + meta = ModuleMeta( + name="Orders", + route_prefix="/api/orders", + view_prefix="/orders", + version="0.1.0", + ) + + def register_permissions(self, registry: PermissionRegistry) -> None: + registry.add_group("Orders", [ + "orders.view", "orders.create", "orders.edit", "orders.delete", + ]) + + def register_menu_items(self, registry: MenuRegistry) -> None: + registry.add( + MenuItem( + section=MenuSection.SIDEBAR, + key="orders", + label_key="orders.menu.orders", + href="/orders", + icon="package", + required_permission="orders.view", + order=20, + ) + ) + + def register_routes( + self, api: APIRouter, views: APIRouter + ) -> None: + api.include_router(api_router, prefix="/api/orders") + views.include_router(view_router, prefix="/orders") +``` + +- `label_key="orders.menu.orders"` — translated string. Add it to `orders/locales/en.json`. +- `required_permission="orders.view"` — menu item is hidden for users who don't have it. `InertiaLayoutDataMiddleware` filters menus per request. + +## 7. Enforce permissions on endpoints + +```python +# modules/orders/orders/endpoints/api.py +from fastapi import APIRouter, Depends +from simple_module_hosting.permissions import RequiresPermission + +from orders.contracts.schemas import OrderCreate, OrderOut +from orders.deps import OrderServiceDep + +router = APIRouter(tags=["orders"]) + +@router.get( + "", + dependencies=[Depends(RequiresPermission("orders.view"))], +) +async def list_orders(service: OrderServiceDep) -> list[OrderOut]: + return await service.list() + +@router.post( + "", + status_code=201, + dependencies=[Depends(RequiresPermission("orders.create"))], +) +async def create_order( + data: OrderCreate, service: OrderServiceDep +) -> OrderOut: + return await service.create(data) +``` + +`RequiresPermission` checks `request.state.principal.permissions` and returns 403 if the permission isn't granted. + +## 8. Build the React page + +`pages/Browse.tsx` was scaffolded — extend it: + +```tsx +import { useT } from "@simple-module-py/i18n-react"; +import { DataTable, PageHeader } from "@simple-module-py/ui"; +import type { OrderOut } from "../contracts"; + +export default function Browse({ orders }: { orders: OrderOut[] }) { + const { t } = useT(); + return ( + <> + + + + ); +} +``` + +Translations (`orders/locales/en.json`): + +```json +{ + "menu": { "orders": "Orders" }, + "browse": { "title": "Orders" }, + "fields": { + "customer": "Customer", + "total": "Total", + "status": "Status" + } +} +``` + +Keys flatten at boot: `orders.menu.orders`, `orders.browse.title`, etc. See [Internationalization](/framework/i18n). + +## 9. Write a test + +```python +# modules/orders/tests/test_api.py +import pytest + +@pytest.mark.asyncio +async def test_create_and_list_orders(authenticated_client): + r = await authenticated_client.post( + "/api/orders", + json={"customer_email": "buyer@example.com", "total": "42.00"}, + ) + assert r.status_code == 201 + created = r.json() + assert created["customer_email"] == "buyer@example.com" + + r = await authenticated_client.get("/api/orders") + assert r.status_code == 200 + assert any(o["id"] == created["id"] for o in r.json()) +``` + +The `authenticated_client` fixture from `conftest.py` seeds an admin user and carries a signed session cookie. The `db_session` fixture creates all module tables and stamps the Alembic head so the boot-time migration check passes. See [Fixtures](/testing/fixtures). + +Run: + +```bash +uv run pytest modules/orders/tests/ -v +``` + +## 10. Verify end-to-end + +```bash +make doctor # should report 0 errors +make lint # Ruff + ty + Biome + tsc + file-size cap +make dev # visit http://localhost:8000/orders +``` + +If `make doctor` flags an `SM003` or `SM004`, the Inertia key in `views.py` doesn't match the file you created in `pages/` — see [Pages & discovery](/frontend/pages). + +Next up: + +- [Models](/database/models) — SQLModel conventions. +- [Permissions](/framework/permissions) — how `RequiresPermission` resolves roles. +- [Events](/framework/events) — publishing `OrderPlaced` and subscribing from another module. diff --git a/docs/guide/installation.md b/docs/guide/installation.md new file mode 100644 index 00000000..b5a57828 --- /dev/null +++ b/docs/guide/installation.md @@ -0,0 +1,117 @@ +# Installation + +## Prerequisites + +| Tool | Why | How to check | +|---|---|---| +| **Python 3.12** | Runtime | `python --version` | +| **[uv](https://docs.astral.sh/uv/)** | Python package manager + venv | `uv --version` | +| **Node.js 20+** | Vite dev server, React build | `node --version` | +| **npm 10+** | JS workspace manager | `npm --version` | +| **Docker** (optional) | Postgres in a container (SQLite works without) | `docker --version` | +| **Make** | Task runner — all day-to-day commands flow through this | `make --version` | + +On macOS, `brew install uv node make` and Docker Desktop will cover it. On Linux, install uv with the install script and Node via `nvm` or your package manager. + +## Clone and install + +```bash +git clone https://github.com/antosubash/simple_module_python.git +cd simple_module_python +make install +``` + +`make install` runs: + +- `uv sync --all-packages` — installs every Python package in the workspace (framework, modules, host) into a single `.venv`. +- `npm install` — installs JS deps for `host/client_app`, `packages/*`, and every module that ships a `package.json`. + +Installation takes 1–2 minutes on a warm cache. + +## Env configuration + +```bash +cp .env.example .env +``` + +The defaults in `.env.example` work for local SQLite dev. The only variable you'll likely change is `SM_DATABASE_URL` if you're using Postgres: + +```bash +# SQLite (default) — zero setup +SM_DATABASE_URL=sqlite+aiosqlite:///./app.db + +# Postgres (requires make docker-up) +SM_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/simple_module +``` + +See [Configuration](/guide/configuration) for the full list of env vars. + +## Database setup + +### SQLite (no Docker) + +```bash +make migrate +``` + +Creates `app.db` in the repo root and stamps it at the Alembic head. + +### Postgres (via Docker) + +```bash +make docker-up # starts Postgres + Redis +make migrate +``` + +The compose file sets up a `postgres` service on port 5432 and a `redis` service on 6379 (used by the `background_tasks` module's Celery broker). + +## Sanity check + +```bash +make dev +``` + +In parallel, this starts: + +- `uvicorn host.main:app` on `:8000` (the FastAPI + Inertia app) +- `vite` on `:5173` (the frontend dev server with HMR) + +Hit `http://localhost:8000`. You should see the landing page. Stop both servers with `make kill`. + +## Create the first admin + +```bash +uv run sm-users create-admin --email admin@example.com --password changeme +``` + +Or set bootstrap env vars so the admin is auto-created on first boot: + +```bash +# in .env +SM_USERS_BOOTSTRAP_EMAIL=admin@example.com +SM_USERS_BOOTSTRAP_PASSWORD=changeme +``` + +Then `make migrate && make dev`. + +## Install Playwright (optional — for E2E) + +```bash +uv run playwright install chromium +``` + +E2E tests are off by default (the root `pyproject.toml` pins `-m 'not e2e'`). Run them explicitly with `make test-e2e` while a dev server is running. See [E2E testing](/e2e-testing). + +## Troubleshooting + +**`make dev` says a port is in use.** +Run `make kill` to free ports 8000 and 5173. + +**Alembic complains about revision mismatch.** +Your local DB is ahead of or behind the migration files. For a dev DB, `rm app.db && make migrate`. For Postgres, `make docker-down && make docker-up && make migrate`. + +**Entry points aren't discovered after editing a module's `pyproject.toml`.** +Re-run `uv sync --all-packages` (or just `make install`) — entry points are registered at install time, not at import time. + +**Diagnostics fail with `SM009`.** +You wrote `from import …` inside `framework/*`. Framework code must not reach into plugin modules — invert the dependency (register a callback from the module) or promote the shared concept into `framework/`. diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md new file mode 100644 index 00000000..38a1eb08 --- /dev/null +++ b/docs/guide/introduction.md @@ -0,0 +1,66 @@ +# Introduction + +**simple_module_python** is a modular-monolith framework for Python. You ship one FastAPI app, but the features live in separate Python packages called *modules* — each with its own database tables, HTTP endpoints, React pages, permissions, and tests. At boot, the framework discovers every installed module via Python entry points and composes them into a single running application. + +You get the distribution benefits of microservices — teams can own a module, ship it as a wheel, upgrade independently — without paying the runtime cost of service-to-service RPC, API-client codegen, and distributed-transaction gymnastics. + +## Stack + +| Layer | Choice | +|---|---| +| Language | Python 3.12 | +| API | FastAPI (Starlette underneath) | +| ORM | SQLModel (SQLAlchemy async + Pydantic) | +| Migrations | Alembic | +| Frontend bridge | Inertia.js | +| Frontend | React 19 + Tailwind CSS 4 + Vite | +| Auth | fastapi-users (local email+password, cookie sessions) | +| Tooling | uv (Python), npm (JS), Ruff, ty, Biome, pytest, Playwright | + +## When to reach for this + +Reach for simple_module_python when: + +- You are building a single product with multiple distinct domains (billing, users, catalog, CRM) that you want to keep isolated but not over-networked. +- You want server-rendered pages with React ergonomics, not a split SPA ↔ REST-API codebase. +- You want the option to distribute modules as packages later without rewriting them. +- You would reach for Django but find its admin and ORM too opinionated and its async story still half-baked. + +## When **not** to reach for this + +- You need a pure async REST API with no UI and no session state — a vanilla FastAPI project is lighter. +- You need multi-tenant isolation at the database level across separate DB hosts — this project uses row-level `MultiTenantMixin`, not tenant-per-cluster. +- Your team already has a working Django monolith and doesn't need module-level distribution. + +## Design pillars + +### 1. No host–module API boundary + +Modules are Python packages loaded into one process. They call each other directly (via imported services), not over HTTP. That means: + +- No OpenAPI generation between internal modules. +- No circuit-breakers or retry policies for internal calls. +- Refactors that cross modules are regular Python refactors — caught by the type checker and test suite at compile time. + +The public surface **between** modules is a module's `contracts/` directory: SQLModel DTOs and domain events. Modules depending on another import only from that module's top-level package and its `contracts` subpackage — never reach into its `service.py` internals. + +### 2. Convention-first, with diagnostics + +The framework enforces conventions — page naming, settings prefixes, migration branches, locale key structure — through a diagnostics pass (`make doctor`). Violations produce `SM0XX` codes; errors fail boot in production. This replaces runtime surprises with build-time guarantees. + +See [diagnostic codes](/reference/diagnostic-codes) for the full list. + +### 3. SQLModel everywhere + +SQLModel is the project-wide standard for *every* model — DB tables (`table=True`) **and** DTOs (plain `SQLModel` subclasses). We don't use plain Pydantic `BaseModel`, and we don't use SQLAlchemy's `DeclarativeBase` + `Mapped[...]` style. One type system, one set of gotchas, one mental model. + +### 4. Inertia over split SPAs + +React pages are served from Python endpoints via Inertia. The middleware attaches shared props (`auth`, `menus`, `i18n`) to every response, so components get the data they need without bespoke `/api/me` calls. Page discovery is file-system based — drop a `.tsx` file under `modules///pages/` and it's live. + +## Next steps + +- [Install](/guide/installation) the dev environment. +- [Quickstart](/guide/quickstart) — bootstrap and boot the sample app in 5 minutes. +- [Project structure](/guide/project-structure) — understand where code lives before touching anything. +- [Your first module](/guide/first-module) — end-to-end walk-through. diff --git a/docs/guide/project-structure.md b/docs/guide/project-structure.md new file mode 100644 index 00000000..9dbbdf6d --- /dev/null +++ b/docs/guide/project-structure.md @@ -0,0 +1,124 @@ +# Project structure + +```text +simple_module_python/ +├── framework/ # the framework itself (no knowledge of plugin modules) +│ ├── core/ # module system: discovery, events, diagnostics, ModuleBase +│ ├── db/ # create_module_base, mixins, session management, listeners +│ ├── hosting/ # create_app, middleware, settings, Inertia glue +│ └── testing/ # shared pytest fixtures distributed as a package +│ +├── modules/ # plugin modules — one Python package each +│ ├── auth/ # session cookie, CSRF defences +│ ├── background_tasks/ # Celery broker + worker integration +│ ├── dashboard/ # authenticated landing page +│ ├── datasets/ # CSV / dataset uploads +│ ├── feature_flags/ # admin UI for flag toggles +│ ├── file_storage/ # pluggable storage backends (local, S3) +│ ├── permissions/ # role/permission admin UI +│ ├── products/ # reference CRUD module (used in examples) +│ ├── settings/ # DB-backed module settings + admin UI +│ └── users/ # email+password auth, invites, bootstrap +│ +├── host/ # the runnable application +│ ├── main.py # FastAPI entry point — create_app + lifespan +│ ├── routes.py # host-level routes (landing page) +│ ├── client_app/ # Vite + React root +│ │ ├── src/ # app shell, shared layout +│ │ ├── pages/ # host-level Inertia pages (Landing, NotFound) +│ │ └── modules.generated.ts # auto-generated page map — DO NOT EDIT +│ ├── migrations/ # Alembic migrations for ALL modules +│ ├── locales/ # host-level translation namespaces +│ └── alembic.ini # Alembic config — lives at repo root +│ +├── packages/ # shared JS packages (npm workspaces) +│ └── ui/ # shadcn-based component library, layouts +│ +├── scripts/ +│ ├── new_module.py # module scaffolder — invoked by `make new-module` +│ └── check_file_size.py # 300-line cap enforcer +│ +├── tests/ +│ ├── e2e/ # Playwright tests (behind `-m e2e`) +│ └── … # framework-level pytest tests +│ +├── docs/ # this documentation site (VitePress) +│ ├── .vitepress/ # nav/sidebar config +│ ├── guide/ # getting-started guides +│ ├── framework/ # framework deep dives +│ ├── database/ # DB model & migration docs +│ ├── frontend/ # Inertia, pages, shared props +│ ├── testing/ # pytest fixtures, E2E +│ ├── reference/ # commands, env vars, diagnostics +│ ├── plans/ # dated design docs (most recent = intended state) +│ ├── superpowers/ # spec/plan pairs from design sessions +│ └── release-notes/ # per-release notes +│ +├── conftest.py # root pytest fixtures — app, db_session, client, … +├── Makefile # every day-to-day command lives here +├── pyproject.toml # workspace root (uv) +├── package.json # workspace root (npm) +├── docker-compose.yml # Postgres + Redis for dev +└── CLAUDE.md # assistant guidance — same conventions as these docs +``` + +## Anatomy of a module + +Everything under `modules//` follows the same shape: + +```text +modules/orders/ +├── pyproject.toml # entry point: simple_module = "orders.module:OrdersModule" +├── package.json # npm workspace member (for page TSX + Biome) +├── tsconfig.json # TypeScript project for the pages +└── orders/ + ├── __init__.py + ├── module.py # ModuleBase subclass with meta = ModuleMeta(...) + ├── models.py # SQLModel tables (optional — some modules are UI-only) + ├── contracts/ # SQLModel DTOs — the PUBLIC surface for other modules + │ └── schemas.py + ├── service.py # business logic — takes AsyncSession, returns DTOs + ├── deps.py # FastAPI dependencies (auth requirements, etc.) + ├── endpoints/ + │ ├── api.py # REST (JSON) endpoints + │ └── views.py # Inertia view endpoints + ├── pages/ # *.tsx — auto-discovered by Vite via modules.generated.ts + │ ├── Browse.tsx + │ ├── Create.tsx + │ └── Edit.tsx + ├── locales/ + │ └── en.json # translations, namespaced by module name + └── tests/ + ├── test_api.py + └── test_service.py +``` + +See the [module authoring guide](/module-authoring) for the full contract. + +## Where things get generated + +| File | Generated by | When | +|---|---|---| +| `host/client_app/modules.generated.ts` | `make gen-pages` (auto via `make dev`) | Every time you add/remove a module page | +| `host/client_app/modules.manifest.json` | `make gen-pages` | Same | +| `host/client_app/modules.generated.css` | `make gen-pages` | Same | +| `host/migrations/versions/XXXX_*.py` | `make migration msg="…"` | When you add/change SQLModel tables | +| `modules//**` | `make new-module name=` | Scaffolding a new module | + +Never hand-edit the `.generated.*` files — they are overwritten on the next `make gen-pages`. + +## 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/alembic/env.py`. Each module's first migration sets a `branch_labels` marker to enable `alembic downgrade @base`. +- **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. + +## Tools config that affects everyone + +| File | Controls | +|---|---| +| `pyproject.toml` (root) | Ruff rules, ty config, pytest markers (`asyncio_mode=auto`, `-m 'not e2e'`), SQLModel-related ty-false-positive suppressions | +| `biome.json` | JS/TS linting + formatting | +| `vitest.config.ts` | JS unit-test setup | +| `Makefile` | The interface. If it's not `make `, it's not a day-to-day operation. | +| `scripts/check_file_size.py` | 300-line cap on `.py`/`.ts`/`.tsx` (exempts vendored shadcn components) | diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md new file mode 100644 index 00000000..d9c483ce --- /dev/null +++ b/docs/guide/quickstart.md @@ -0,0 +1,110 @@ +# Quickstart + +Five minutes from `git clone` to a running app with a freshly scaffolded module. + +## 1. Install + +```bash +git clone https://github.com/antosubash/simple_module_python.git +cd simple_module_python +make install +cp .env.example .env +``` + +## 2. Migrate + +```bash +make migrate +``` + +Default `.env` uses SQLite — no Docker needed. If you want Postgres, run `make docker-up` first and edit `SM_DATABASE_URL` accordingly. + +## 3. Boot + +```bash +make dev +``` + +The API and Vite dev servers start side by side. Visit: + +- `http://localhost:8000` — landing page +- `http://localhost:8000/users/login` — sign-in screen +- `http://localhost:8000/products` — a fully-working example module (CRUD on a `products` table) +- `http://localhost:8000/settings/modules` — the admin settings UI (log in first) + +## 4. Create an admin + +In another terminal: + +```bash +uv run sm-users create-admin --email admin@example.com --password changeme +``` + +Sign in at `/users/login` and you land on the dashboard. + +## 5. Scaffold a new module + +```bash +make new-module name=orders +``` + +This generates `modules/orders/` with: + +- `pyproject.toml` — `[project.entry-points.simple_module]` → `orders.module:OrdersModule` +- `orders/module.py` — `ModuleBase` subclass with `meta = ModuleMeta(name="Orders", ...)` +- `orders/models.py` — `Order` SQLModel table with `AuditMixin` +- `orders/contracts/schemas.py` — `OrderCreate`, `OrderOut` DTOs +- `orders/service.py` — CRUD implementation +- `orders/endpoints/api.py` — REST endpoints at `/api/orders` +- `orders/endpoints/views.py` — Inertia endpoints at `/orders` +- `orders/pages/Browse.tsx`, `Create.tsx`, `Edit.tsx` — React pages +- `orders/locales/en.json` — translation namespace +- `modules/orders/tests/` — pytest test file + +The scaffolder registers the package and re-runs `uv sync --all-packages`, so the module is discoverable on next boot. + +## 6. Generate a migration + +```bash +make migration msg="add orders tables" +make migrate +``` + +Alembic's autogenerate picks up the new `orders` schema (Postgres) or the `orders_*` tables (SQLite) and writes `host/migrations/versions/XXXX_add_orders_tables.py`. The first migration of a new module includes a `branch_labels = ("orders",)` marker so you can downgrade the module in isolation later with `alembic downgrade orders@base`. + +## 7. Hit the module + +Restart `make dev` (modules are discovered at boot). Then: + +```bash +curl http://localhost:8000/api/orders +# → [] (200) +``` + +Visit `http://localhost:8000/orders` — you see the `Browse` page with an empty list and a "Create" button. The sidebar menu now includes an **Orders** entry (registered via `register_menu_items`). + +## 8. Run the tests + +```bash +make test +``` + +Runs Python + JS test suites. Single file: + +```bash +uv run pytest modules/orders/tests/test_api.py -v +``` + +## What just happened + +- **Discovery** — the `simple_module` entry point pointed Python's installer at `orders.module:OrdersModule`. `discover_modules()` loaded it, topologically sorted against every other installed module, and invoked `register_*` hooks in order. +- **Routes** — `register_routes(api_router, view_router)` attached the `orders` routers at `/api/orders` and `/orders`. +- **Menu** — `register_menu_items` pushed an entry onto `MenuRegistry`; the Inertia shared-props middleware serialized it into `menus.sidebar` for every authenticated request. +- **Frontend** — `modules.generated.ts` (rebuilt by `make gen-pages`) maps `"Orders/Browse"` to `modules/orders/orders/pages/Browse.tsx`. Vite resolves and HMR-watches that file. +- **Database** — `create_module_base("orders")` namespaced the `Order` table under a Postgres `orders` schema (or the `orders_order` table name under SQLite). + +Where to go next: + +- [Your first module](/guide/first-module) — stage-by-stage walk-through extending the scaffold to real domain logic. +- [Framework overview](/framework/overview) — what actually happens between `make dev` and the first HTTP request. +- [Project structure](/guide/project-structure) — the directory tour. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..36022237 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,45 @@ +--- +layout: home + +hero: + name: simple_module_python + text: Modular-monolith for Python + tagline: FastAPI + SQLModel + Inertia.js + React — plugin modules that compose at boot. No microservice tax, no API-client glue. + actions: + - theme: brand + text: Get started + link: /guide/introduction + - theme: alt + text: Quickstart + link: /guide/quickstart + - theme: alt + text: View on GitHub + link: https://github.com/antosubash/simple_module_python + +features: + - title: One app, many modules + details: Each module ships its own SQLModel tables, API endpoints, and React pages — but everything runs in one FastAPI process. Installed as Python packages, discovered via entry points. + - title: SQLModel end-to-end + details: A single type system for tables and DTOs. Per-module Base class gives you a Postgres schema (or table-name prefix on SQLite) with zero boilerplate. + - title: Inertia.js, not a split SPA + details: Server renders React pages with shared props (auth, menus, i18n) — no REST glue. Auto-discovered .tsx pages under modules/, hot-reloaded by Vite. + - title: Diagnostics that fail boot + details: Orphan pages, phantom renders, coupling violations, migration drift, locale inconsistencies — caught at dev time, enforced at production boot. + - title: Batteries-included + details: Permissions, roles, audit/soft-delete/multi-tenant mixins, event bus, i18n with CLDR plurals, feature flags, health checks — standard plumbing you don't have to rebuild. + - title: Scaffold in one command + details: make new-module name=orders generates the full package — ModuleMeta, models, service, endpoints, pages, tests — already wired into the app. +--- + +## What you'll find here + +This documentation is structured around what you're trying to do: + +- **[Guide](/guide/introduction)** — install, bootstrap, and build your first module. +- **[Framework](/framework/overview)** — the module system: discovery, lifecycle hooks, middleware, permissions, events, i18n. +- **[Database](/database/models)** — SQLModel conventions, per-module `Base`, mixins, session lifecycle, Alembic migrations. +- **[Frontend](/frontend/inertia)** — Inertia page keys, shared props, page discovery, client dependencies. +- **[Testing](/testing/overview)** — the fixtures in `conftest.py`, how to write unit tests against a real DB, and how to run E2E. +- **[Reference](/reference/make-commands)** — `make` targets, environment variables, diagnostic codes, deployment. + +The authoritative single-page docs (`framework-conventions.md`, `module-authoring.md`, `e2e-testing.md`, `release.md`) are also linked from each section's sidebar — they are the source of truth when conventions are ambiguous. diff --git a/docs/package-lock.json b/docs/package-lock.json new file mode 100644 index 00000000..cc5b9039 --- /dev/null +++ b/docs/package-lock.json @@ -0,0 +1,2514 @@ +{ + "name": "@simple-module-py/docs", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@simple-module-py/docs", + "version": "0.0.0", + "devDependencies": { + "vitepress": "^1.6.3", + "vue": "^3.5.13" + } + }, + "node_modules/@algolia/abtesting": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.17.0.tgz", + "integrity": "sha512-nuhHZdTiCtRzJEe9VSNzyqE9cOQMt01UWBzymFnjbgwrxxZpbGHQde6Oa/y9zyspTCjbUtb7Q5HQek1CLiLyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.51.0", + "@algolia/requester-browser-xhr": "5.51.0", + "@algolia/requester-fetch": "5.51.0", + "@algolia/requester-node-http": "5.51.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", + "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", + "@algolia/autocomplete-shared": "1.17.7" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", + "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-preset-algolia": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", + "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", + "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.51.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.51.0.tgz", + "integrity": "sha512-PKrKlIla1U2J7mFcIQn6N3pWP4oySmkxShnbbDsj/H7818gKbET5KsUwsVoNjWIxHKTJMCTcQ7ekAJ8Ea23NMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.51.0", + "@algolia/requester-browser-xhr": "5.51.0", + "@algolia/requester-fetch": "5.51.0", + "@algolia/requester-node-http": "5.51.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.51.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.51.0.tgz", + "integrity": "sha512-U+HCY1K16Km91pIRL1kN6bW6BbGFAF/WhkRSCx4wyl1aFpbrlhSFQs/dAwWbmyBiHWwVWhl7stWHQ1pum5EfMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.51.0", + "@algolia/requester-browser-xhr": "5.51.0", + "@algolia/requester-fetch": "5.51.0", + "@algolia/requester-node-http": "5.51.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.51.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.51.0.tgz", + "integrity": "sha512-YPJ3dEuZLCRp846Az94t6Z2gwSNRazP+SmBco7p6SCa4fYrtIE820PDXYZshbNrj2Z8Qfbmv7BQ1Lecl5L3G/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.51.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.51.0.tgz", + "integrity": "sha512-/gEwLlR7fQ7YjOW+ADRZ0NxLDtpTC61FSzlZ01Gdl1kTJfU0Rq3Y/TYqwxGxlQGcUiXtGzrpjxXWh3Y0TZD6NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.51.0", + "@algolia/requester-browser-xhr": "5.51.0", + "@algolia/requester-fetch": "5.51.0", + "@algolia/requester-node-http": "5.51.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.51.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.51.0.tgz", + "integrity": "sha512-nRwUN1Y2cKyOAFZyIBagkEfZSIhP05nWhT4Rjwl84lcjECssYggftrAODrZ4leakXxSGjhxs/AdaAFEIBqwVFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.51.0", + "@algolia/requester-browser-xhr": "5.51.0", + "@algolia/requester-fetch": "5.51.0", + "@algolia/requester-node-http": "5.51.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.51.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.51.0.tgz", + "integrity": "sha512-pybzYCG7VoQKppo+z5iZOKpW8XqtFxhsAIRgEaNboCnfypKukiBHJAwB+pmr7vMZXBsOHwklGYWwCG82e8qshA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.51.0", + "@algolia/requester-browser-xhr": "5.51.0", + "@algolia/requester-fetch": "5.51.0", + "@algolia/requester-node-http": "5.51.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.51.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.51.0.tgz", + "integrity": "sha512-DWVIlj6RqcvdhwP0gBU9OpOQPnHdcAk9jlT+z8rsNb2+liWv4eUlfQZ7saGBraFsnygEHD3PtdppIHvqwBAb5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.51.0", + "@algolia/requester-browser-xhr": "5.51.0", + "@algolia/requester-fetch": "5.51.0", + "@algolia/requester-node-http": "5.51.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.51.0.tgz", + "integrity": "sha512-bA25s12iUDJi/X8M7tWlPRT8GeOhls/yDbdoUqinz27lNqsOlcM1UrAxIKdIZ6lm3sXit+ewPIz1pS2x6rXu8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.51.0", + "@algolia/requester-browser-xhr": "5.51.0", + "@algolia/requester-fetch": "5.51.0", + "@algolia/requester-node-http": "5.51.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.51.0.tgz", + "integrity": "sha512-zj+RcE5e0NE0/ew6oEOTgOplPHry+w2oi7h0Y87lhdq4E0d7xLS31KVB8kKfCGkrG7AYtZvrcyvLOKS5d0no4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.51.0", + "@algolia/requester-browser-xhr": "5.51.0", + "@algolia/requester-fetch": "5.51.0", + "@algolia/requester-node-http": "5.51.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.51.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.51.0.tgz", + "integrity": "sha512-/HDgccfye1Rq3bPxaSCsvSEBHzSMmtpM9ZRGRtAuC62Cv+ql/76IWnxjGTDXtqIJ+/j7ZlFYAzq9fkp95wF2SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.51.0", + "@algolia/requester-browser-xhr": "5.51.0", + "@algolia/requester-fetch": "5.51.0", + "@algolia/requester-node-http": "5.51.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.51.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.51.0.tgz", + "integrity": "sha512-nJdW+WBwGlXWMJbxxB7/AJPvNq0lLJSudXmIQCJbmH8jsOXQhRpAtoCD4ceLyJKv3ze9JbQu4Gqu5JDLckuFcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.51.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.51.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.51.0.tgz", + "integrity": "sha512-bsBgRI/1h1mjS3eCyfGau78yGZVmiDLmT1aU6dMnk75/T0SgKqnSKNpQ53xKoDYVChGDcNnpHXWpoUSo8MH1+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.51.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.51.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.51.0.tgz", + "integrity": "sha512-zPrIDVPpmKWgrjmWOqpqrhqAhNjvVkjoj+mqw2NBPxEOuKNzP0H+Qz5NJLLTOepBVj1UFedFaF3AUgxLsB9ukQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.51.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@docsearch/css": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", + "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/js": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", + "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/react": "3.8.2", + "preact": "^10.0.0" + } + }, + "node_modules/@docsearch/react": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", + "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "1.17.7", + "@algolia/autocomplete-preset-algolia": "1.17.7", + "@docsearch/css": "3.8.2", + "algoliasearch": "^5.14.2" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 19.0.0", + "react": ">= 16.8.0 < 19.0.0", + "react-dom": ">= 16.8.0 < 19.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.79", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.79.tgz", + "integrity": "sha512-aNyO7Fd1qej9oQfIyohYFRv0lhQLaZ+6UkK1c1qwax0MDPUOZOdq65MlU500kow97pD/W+b2u1And3e25eE24Q==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", + "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.4" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", + "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^3.1.0" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", + "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", + "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", + "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", + "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/types": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", + "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz", + "integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.33", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz", + "integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz", + "integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.33", + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.10", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz", + "integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz", + "integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz", + "integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz", + "integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/runtime-core": "3.5.33", + "@vue/shared": "3.5.33", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz", + "integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "vue": "3.5.33" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/integrations": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", + "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vueuse/core": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", + "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/algoliasearch": { + "version": "5.51.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.51.0.tgz", + "integrity": "sha512-u3XS8HaTzt5YN90KPsOXMRjYJUMVD1dtr6yi4NXQluMbZ5IjQNBu1MEabdAxFhYtEuexqomPMSmRIhQJUd3QSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.17.0", + "@algolia/client-abtesting": "5.51.0", + "@algolia/client-analytics": "5.51.0", + "@algolia/client-common": "5.51.0", + "@algolia/client-insights": "5.51.0", + "@algolia/client-personalization": "5.51.0", + "@algolia/client-query-suggestions": "5.51.0", + "@algolia/client-search": "5.51.0", + "@algolia/ingestion": "1.51.0", + "@algolia/monitoring": "1.51.0", + "@algolia/recommend": "5.51.0", + "@algolia/requester-browser-xhr": "5.51.0", + "@algolia/requester-fetch": "5.51.0", + "@algolia/requester-node-http": "5.51.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/focus-trap": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tabbable": "^6.4.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "dev": true, + "license": "MIT" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oniguruma-to-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", + "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex-xs": "^1.0.0", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", + "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/search-insights": { + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/shiki": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", + "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/langs": "2.5.0", + "@shikijs/themes": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitepress": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", + "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/css": "3.8.2", + "@docsearch/js": "3.8.2", + "@iconify-json/simple-icons": "^1.2.21", + "@shikijs/core": "^2.1.0", + "@shikijs/transformers": "^2.1.0", + "@shikijs/types": "^2.1.0", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/devtools-api": "^7.7.0", + "@vue/shared": "^3.5.13", + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", + "focus-trap": "^7.6.4", + "mark.js": "8.11.1", + "minisearch": "^7.1.1", + "shiki": "^2.1.0", + "vite": "^5.4.14", + "vue": "^3.5.13" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz", + "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-sfc": "3.5.33", + "@vue/runtime-dom": "3.5.33", + "@vue/server-renderer": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 00000000..278edb8c --- /dev/null +++ b/docs/package.json @@ -0,0 +1,16 @@ +{ + "name": "@simple-module-py/docs", + "private": true, + "version": "0.0.0", + "description": "VitePress documentation site for simple_module_python", + "type": "module", + "scripts": { + "dev": "vitepress dev", + "build": "vitepress build", + "preview": "vitepress preview" + }, + "devDependencies": { + "vitepress": "^1.6.3", + "vue": "^3.5.13" + } +} diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md new file mode 100644 index 00000000..7fcc0d3b --- /dev/null +++ b/docs/reference/deployment.md @@ -0,0 +1,168 @@ +# Deployment + +A production deployment is one FastAPI process (plus optional Celery workers) behind a reverse proxy, backed by Postgres and Redis. Nothing exotic — standard 12-factor plumbing. + +## Production checklist + +Before serving traffic: + +- [ ] `SM_ENVIRONMENT` set to something other than `development`/`test`/`testing`. +- [ ] `SM_SECRET_KEY` is a strong random value (not the default). +- [ ] `SM_DATABASE_URL` points at Postgres (`postgresql+asyncpg://`), not SQLite. +- [ ] `alembic upgrade head` run against the production DB. +- [ ] `make doctor` passes (zero errors) on the built artifact. +- [ ] Admin bootstrap complete — an admin user exists and can log in. +- [ ] Reverse proxy forwards `X-Forwarded-Proto`/`X-Forwarded-For`; configured with `--proxy-headers`. +- [ ] HTTPS in front of the app (cookie is `Secure` when TLS is present). +- [ ] Log destination configured (`SM_LOG_FORMAT=json` + log shipping). + +## Build + +Typical Docker build: + +```dockerfile +FROM python:3.12-slim AS builder +WORKDIR /app +COPY pyproject.toml uv.lock ./ +RUN pip install uv && uv sync --frozen --all-packages --no-dev +COPY . . +RUN uv run --project host python -m compileall host modules framework packages + +# Build the frontend +FROM node:20-slim AS frontend +WORKDIR /app +COPY package.json package-lock.json ./ +COPY host/client_app host/client_app +COPY packages packages +COPY modules modules +RUN npm ci +RUN npm run build + +FROM python:3.12-slim +WORKDIR /app +COPY --from=builder /app /app +COPY --from=frontend /app/host/client_app/dist /app/host/client_app/dist +CMD ["uv", "run", "--project", "host", "uvicorn", "host.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"] +``` + +Tune worker count with `--workers N` for multi-CPU boxes, or run behind a process manager like Gunicorn with Uvicorn workers. + +## Running migrations on deploy + +Don't migrate from inside the web container's startup hook — that way lies races when scaling up. Run it as a **one-shot job** before rolling the web tier: + +```bash +# one-shot container +uv run --project host alembic upgrade head + +# then roll web tier +kubectl rollout restart deployment/web +``` + +The boot-time migration check (`SM010`) will fail cleanly if web starts against an unmigrated DB. + +## Process topology + +For small deployments, one web container is plenty. Scale by adding: + +- **Web replicas** — stateless; they share state via DB and Redis. Session cookies are signed, so any replica can serve any request. +- **Celery workers** — `SM_MODULES_ENABLED=background_tasks,` keeps workers lean. Run `uv run --project host celery -A background_tasks.celery_app worker -l info`. +- **Celery beat** (scheduler) — exactly one instance, separate deployment. + +## Observability + +### Logs + +`SM_LOG_FORMAT=json` emits structured logs. Every line has a `correlation_id` (set by `CorrelationIdMiddleware`). Shipping to a central store (Loki / CloudWatch / Datadog) lets you trace a request across web and worker. + +### Health checks + +- `/health` — Kubernetes liveness. Returns 200 if the process is alive; no dependency checks. +- `/admin/health` — aggregated health with every module's registered checks. Return 503 if any critical check fails. Gate behind `admin.health.view` if you don't want it public. + +### Metrics + +Not built in. The framework doesn't expose Prometheus metrics out of the box. If you need them, wrap `uvicorn` with `prometheus-fastapi-instrumentator` in your host bootstrap — it auto-scrapes endpoint latency/count. + +## Session cookie + +`SessionMiddleware` uses `SM_SECRET_KEY` for signing. `Secure` + `HttpOnly` + `SameSite=Lax` by default. Rotate the secret by: + +1. Deploying with a new `SM_SECRET_KEY`. +2. Expected behavior: existing sessions become invalid. Users sign back in. + +There's no built-in key-rollover mechanism — if you need zero-downtime session rotation, fork `SessionMiddleware` to accept a list of keys (first = active, rest = verify-only). + +## CSRF + +Relies on `SameSite=Lax`. Browsers don't attach the cookie to cross-site POST/PUT/DELETE, so forged submissions land unauthenticated and get rejected at the permission check. No token middleware, no per-form token. + +**Caveat:** `SameSite=Lax` does attach on top-level navigations. If you have state-changing GETs (you shouldn't — but), that's an attack surface. Keep side effects out of GET handlers. + +## Reverse proxy + +Minimum nginx: + +```nginx +upstream app { server app:8000; } + +server { + listen 443 ssl http2; + server_name your-domain.com; + + # TLS config here + + location / { + proxy_pass http://app; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +Start `uvicorn` with `--proxy-headers` so it trusts `X-Forwarded-For` for client IP logging. + +## Static assets + +The Vite build outputs to `host/client_app/dist/`. Serve these via the reverse proxy directly (not through Uvicorn) for better performance: + +```nginx +location /build/ { + alias /var/www/app/host/client_app/dist/; + access_log off; + expires 1y; + add_header Cache-Control "public, immutable"; +} +``` + +Vite emits filenames with content hashes, so long cache TTLs are safe. + +## Zero-downtime deploys + +1. Build new image. +2. Run `alembic upgrade head` as a one-shot job. +3. `kubectl rollout restart deployment/web` (rolling). +4. Watch logs for `SM010` (would mean step 2 failed silently). +5. Rollback path: `kubectl rollout undo`; old pods come back up against the new DB. **Make sure** your migrations are backward-compatible for at least one release cycle (expand/contract pattern — see below). + +### Expand / contract migrations + +- Add a new column **nullable** in release N. +- Release N+1: start writing to the new column, still read from both. +- Release N+2: stop reading the old column; make the new one NOT NULL. +- Release N+3: drop the old column. + +Each release is deployable on its own. Each rollback goes to a compatible previous release. + +## Disaster recovery + +- **DB backups** — Postgres point-in-time recovery via WAL archiving. Standard RDS / Cloud SQL options cover this. +- **Secrets** — `SM_SECRET_KEY` must be recoverable from your secrets manager. Rotating it invalidates all sessions but doesn't break anything else. +- **Sessions** — lost sessions mean users re-login; acceptable. +- **Uploads** — the `file_storage` module's `local` backend is ephemeral. For production, point it at S3 or a network filesystem and back that up independently. + +## Publishing releases + +See [docs/release.md](/release) for the full Python + JS publishing flow (OIDC Trusted Publishing on PyPI, `NPM_TOKEN` on npm, driven from GitHub Actions). diff --git a/docs/reference/diagnostic-codes.md b/docs/reference/diagnostic-codes.md new file mode 100644 index 00000000..b647bdb7 --- /dev/null +++ b/docs/reference/diagnostic-codes.md @@ -0,0 +1,69 @@ +# Diagnostic codes + +`make doctor` runs a set of static checks over installed modules. Same checks run at app boot; errors fail boot in production (`SM_ENVIRONMENT` ≠ `development`/`test`/`testing`). + +## Levels + +- **ERROR** — fails boot in production; exits non-zero from `make doctor`. +- **WARNING** — printed to stdout; does not fail boot. Fix before shipping. +- **INFO** — informational; safe to ignore but useful signal. + +## The codes + +| Code | Level | Trigger | Fix | +|---|---|---|---| +| `SM001` | ERROR | Module subclass has no `meta` attribute, or `meta` is not a `ModuleMeta`. | Declare `meta = ModuleMeta(name=..., version=..., ...)` on the class. | +| `SM003` | WARNING | `pages/.tsx` exists but no `inertia.render()` call in any module references the corresponding page key. | Remove the orphan file, or add the matching `inertia.render("/", ...)` in `views.py`. | +| `SM004` | WARNING | An `inertia.render("/")` call exists but no matching `.tsx` file is shipped. | Create `pages/.tsx`, or correct the render key. | +| `SM007` | INFO | Module overrides zero `register_*` hooks and has no `on_startup`/`on_shutdown`. | Delete the module if it's vestigial; otherwise ignore. | +| `SM008` | ERROR | Two modules declare the same `ModuleMeta.name`. Schema / prefix collision. | Rename one. Remember: name is used as Postgres schema, SQLite table prefix, Inertia namespace. | +| `SM009` | ERROR | A file under `framework/*` imports from any package under `modules/*`. | Invert the dependency: have the module register a callback into the framework (see `principal_serializer` pattern). | +| `SM010` | ERROR | DB `alembic_version` is behind the migration head. | Run `make migrate`. In dev this prints a warning; in production it's a hard failure before serving. | +| `SM011` | WARNING | A module's model declares a table that doesn't appear in any Alembic migration. | Run `make migration msg="..."`, review, `make migrate`. | +| `SM012` | WARNING | Module overrides `register_settings` but does not assign to `app.state.`. Dev-only. | Either remove the override or move your state assignment into it. | +| `SM013` | WARNING | Supported locale has no corresponding file in some module's `locales/`. | Add `modules///locales/.json`, or drop the locale from `SM_I18N_SUPPORTED_LOCALES`. | +| `SM014` | WARNING | Non-default locale is missing keys present in the default (untranslated). | Translate the missing keys. | +| `SM015` | WARNING | Non-default locale has keys not in the default (stale / orphan translation). | Remove the stale keys, or add them to the default file if they really belong. | +| `SM016` | ERROR | Locale JSON is invalid or contains non-string leaves. | Fix the JSON; keys must flatten to strings only. | +| `SM017` | WARNING | Module ships `.tsx` pages but has no `package.json` / `tsconfig.json`. Vite can't resolve type imports. | Run `make new-module` on a dummy name and copy the generated config, or scaffold by hand. | +| `SM018` | WARNING | An Inertia `router.post/patch/put/delete()` call in a page targets a JSON `/api/*` endpoint, which would return raw JSON and be rejected by Inertia. | Point the call at a view endpoint that returns `inertia.render(...)` or a redirect; or use plain `fetch()` if you really want a JSON response. | + +## Running diagnostics + +```bash +make doctor +``` + +Sample output: + +```text +running diagnostics … + +WARNING SM003 modules/orders/orders/pages/Unused.tsx: no inertia.render("Orders/Unused") call found +WARNING SM013 locale "es" missing file for module "orders" (expected modules/orders/orders/locales/es.json) +ERROR SM010 DB revision 3a1b2c3d4e5f is behind migration head 9f8e7d6c5b4a — run `make migrate` + +1 error, 2 warnings +``` + +Exits non-zero when any ERROR is present. Non-zero on warnings is opt-in with `-Werror` (there isn't a flag yet — but CI treats warnings as informational). + +## When diagnostics fire + +| Context | What runs | +|---|---| +| `make doctor` | Full suite. Exits non-zero on any ERROR. | +| App boot in development | Full suite, results logged. Doesn't abort. | +| App boot in production | Full suite. ERRORS abort boot before serving. | +| CI (PR check) | `make doctor` as part of the aggregate `pr-checks` job. | + +Treat `make doctor` as the "ready to ship" checklist. A green run + green `make lint && make test` is the floor for pushing to `main`. + +## Adding a new diagnostic + +If you think a rule deserves a code, open a design doc in `docs/plans/` first — existing codes are stable contracts (downstream tooling can grep for them). Use the next free number in the `SM0XX` range and: + +1. Add the check to `simple_module_core.diagnostics`. +2. Wire it into `build_diagnostics_pass()` so it runs from both `make doctor` and app boot. +3. Update this page with the row. +4. Add a test case under `tests/framework/core/diagnostics/`. diff --git a/docs/reference/env-vars.md b/docs/reference/env-vars.md new file mode 100644 index 00000000..e2360b64 --- /dev/null +++ b/docs/reference/env-vars.md @@ -0,0 +1,113 @@ +# Environment variables + +All env vars use the `SM_` prefix. Settings are loaded at boot — anything that must be **known before the DB is open** lives here. Runtime-tunable settings (SMTP creds, feature flags, storage backends) live in the DB-backed settings store and are edited from `/settings/modules`. + +This is the full reference. See [Configuration](/guide/configuration) for a narrative overview. + +## Framework + +| Variable | Default | Notes | +|---|---|---| +| `SM_DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | **Required in production.** Async URL: `postgresql+asyncpg://user:pw@host:5432/db` or `sqlite+aiosqlite:///./app.db`. | +| `SM_ENVIRONMENT` | `development` | Any value other than `development`, `test`, `testing` triggers strict discovery + placeholder-secret checks. | +| `SM_SECRET_KEY` | `change-me-in-production` | **Must** be overridden in production — session cookie signing key. | +| `SM_DEBUG` | `false` | Enables debug mode (tracebacks in HTTP responses). | +| `SM_LOG_LEVEL` | `INFO` | `DEBUG`/`INFO`/`WARNING`/`ERROR`. | +| `SM_LOG_FORMAT` | `plain` | `plain` for dev, `json` for structured logs in prod. | +| `SM_MULTI_TENANT` | `false` | Enables `TenantMiddleware` + `MultiTenantMixin` auto-filter. | +| `SM_TENANT_HEADER` | `X-Tenant-ID` | HTTP header that identifies the current tenant. | +| `SM_MODULES_ENABLED` | unset | Comma-separated allow-list to disable modules without uninstalling them. | +| `SM_VITE_DEV_URL` | `http://localhost:5050` | Dev only — where the Vite HMR client connects. | + +## DB connection pool + +| Variable | Default | Notes | +|---|---|---| +| `SM_DB_POOL_SIZE` | `5` | SQLAlchemy `pool_size`. | +| `SM_DB_MAX_OVERFLOW` | `10` | SQLAlchemy `max_overflow`. | +| `SM_DB_POOL_PRE_PING` | `true` | Test connections before use. | +| `SM_DB_POOL_RECYCLE` | `1800` | Recycle connections after N seconds (helps with LB idle drops). | + +## Internationalization + +| Variable | Default | Notes | +|---|---|---| +| `SM_I18N_DEFAULT_LOCALE` | `en` | Must be in `SM_I18N_SUPPORTED_LOCALES`. | +| `SM_I18N_SUPPORTED_LOCALES` | `en` | Comma-separated, e.g. `en,es,de`. | +| `SM_I18N_COOKIE_NAME` | `locale` | Cookie that stores the user's selected locale. | + +## Users module + +| Variable | Default | Notes | +|---|---|---| +| `SM_USERS_BOOTSTRAP_EMAIL` | unset | Auto-creates an admin if set **and** `users_user` is empty. | +| `SM_USERS_BOOTSTRAP_PASSWORD` | unset | Paired with the email above. | +| `SM_USERS_ALLOW_SIGNUP` | `false` | If `true`, `/users/register` is public. | +| `SM_USERS_MAILER` | `console` | `console` logs the invite link to stdout; `smtp` sends real emails. | +| `SM_USERS_BASE_URL` | derived | Public URL used to build invite links. | +| `SM_USERS_SMTP_HOST` | — | Required when mailer is `smtp`. | +| `SM_USERS_SMTP_PORT` | `587` | | +| `SM_USERS_SMTP_USERNAME` | — | | +| `SM_USERS_SMTP_PASSWORD` | — | | +| `SM_USERS_SMTP_FROM` | — | | +| `SM_USERS_SMTP_TLS` | `true` | | + +## Background tasks (Celery) + +Prefix `SM_BG_TASKS_*`. Set in `docker-compose.yml` for local dev. + +| Variable | Default | Notes | +|---|---|---| +| `SM_BG_TASKS_BROKER_URL` | `redis://localhost:6379/0` | Celery broker. | +| `SM_BG_TASKS_RESULT_BACKEND` | `redis://localhost:6379/1` | Celery result backend. | + +## File storage module + +| Variable | Default | Notes | +|---|---|---| +| `SM_FILE_STORAGE_BACKEND` | `local` | `local`, `s3`, or `memory`. | +| `SM_FILE_STORAGE_LOCAL_ROOT` | `./storage` | Local backend root directory. | +| `SM_FILE_STORAGE_S3_BUCKET` | — | S3 bucket name. | +| `SM_FILE_STORAGE_S3_REGION` | — | S3 region. | + +Most of the storage config moved to DB-backed settings; the env vars above are the ones needed at bootstrap. + +## Patterns + +### Per-module prefix + +Every module-owned env var uses `SM__*`. Keeps settings self-describing and avoids collisions. + +### Comma-separated lists + +Pydantic parses comma-separated strings into `list[str]` — `SM_I18N_SUPPORTED_LOCALES=en,es,de` becomes `["en", "es", "de"]`. + +### Booleans + +`true`, `1`, `yes`, `on` → `True`. Everything else → `False`. Pydantic is strict — `SM_DEBUG=True` works, `SM_DEBUG=TRUE` works, but watch for whitespace. + +### Placeholder-secret check + +In production (`SM_ENVIRONMENT != development`), boot fails if `SM_SECRET_KEY == "change-me-in-production"`. Override before deploying. + +### `.env` files + +The app loads `.env` via pydantic's `BaseSettings`. Order of precedence: + +1. Actual environment variables. +2. `.env` file in the current working directory. +3. Defaults from the `Settings` class. + +Keep secrets out of `.env.example` — check in only non-secret defaults. + +### Module-enabled allow-list + +```bash +SM_MODULES_ENABLED=users,orders,dashboard +``` + +Loads only the listed modules. Useful for: + +- **Tests** — `SM_MODULES_ENABLED=users,orders` for a minimal app. +- **Worker processes** — if you run a Celery worker as a separate container, it only needs `background_tasks` and whatever modules define tasks it processes. +- **Emergency disable** — hide a broken module without rebuilding the image. Remove it from the list, restart. diff --git a/docs/reference/make-commands.md b/docs/reference/make-commands.md new file mode 100644 index 00000000..5afc7218 --- /dev/null +++ b/docs/reference/make-commands.md @@ -0,0 +1,108 @@ +# Make commands + +Everything day-to-day flows through `make`. The `Makefile` at the repo root is the interface — if it isn't a `make `, it isn't a routine operation. + +## Setup & install + +| Command | What | +|---|---| +| `make install` | `uv sync --all-packages` + `npm install`. Safe to re-run — picks up new packages and entry points. | +| `make doctor` | Static analysis: orphan pages, coupling violations, migration drift, locale consistency. Same checks run at prod boot. Exits non-zero on errors. | + +## Dev + +| Command | What | +|---|---| +| `make dev` | `make docker-up` + regen module pages + API (`uvicorn` on `:8000`) + Vite (`:5173`) in parallel. | +| `make kill` | Free ports 8000 and 5173. Useful when `make dev` crashed and left orphans. | +| `make gen-pages` | Regenerate `host/client_app/modules.{manifest.json,generated.ts,generated.css}` from installed modules. Auto-runs before `make dev`. | +| `make docker-up` | Start the Postgres + Redis containers defined in `docker-compose.yml`. | +| `make docker-down` | Stop them. | + +## Test + +| Command | What | +|---|---| +| `make test` | `make test-py` then `make test-js`. E2E excluded. | +| `make test-py` | `uv run pytest` — the root config pins `-m 'not e2e'` and `asyncio_mode=auto`. | +| `make test-js` | `npx vitest run --passWithNoTests`. | +| `make test-e2e` | Playwright smoke suite. Requires `make dev` already running and `uv run playwright install chromium` done once. | + +Single test: `uv run pytest path/to/test_file.py::test_name`. Single Vitest file: `npx vitest run `. + +## Lint & typecheck + +`make lint` runs the whole battery serially; CI runs them as parallel jobs. The aggregate `pr-checks` job is the required status check on `main`. + +| Step | Tool | What it catches | +|---|---|---| +| Format | Ruff | Python format drift | +| Lint | Ruff | Python style / common bugs | +| Type | ty | Python type errors (SQLModel false positives are globally suppressed) | +| Lint/format | Biome | JS/TS lint + format in one pass | +| Type | tsc | Per-workspace `tsc --noEmit` | +| Size | `scripts/check_file_size.py` | 300-line cap on `.py`/`.ts`/`.tsx` (exempts vendored shadcn under `packages/ui/src/components/ui/**`) | + +Run a single step on its own: + +- `uv run ruff format --check .` +- `uv run ruff check .` +- `uv run ty check .` +- `npx biome check .` +- `npm run typecheck` + +## Migrations + +| Command | What | +|---|---| +| `make migrate` | `alembic upgrade head`. Idempotent. | +| `make migration msg="add orders tables"` | `alembic revision --autogenerate -m "..."`. Review the file before committing. | + +Downgrade (no dedicated make target): + +```bash +uv run alembic downgrade -1 # one step back +uv run alembic downgrade # to a specific revision +uv run alembic downgrade orders@base # uninstall one module +``` + +## Scaffolding + +| Command | What | +|---|---| +| `make new-module name=orders` | Generate `modules/orders/` with the full layout, register the entry point, re-run `uv sync`. | + +After scaffolding, edit `models.py`, run `make migration msg="add orders tables"`, review, `make migrate`, restart `make dev`. + +## Common compositions + +Pre-commit sanity: + +```bash +make lint && make test +``` + +Reset a stuck dev loop: + +```bash +make kill && rm app.db && make migrate && make dev +``` + +Full repro of a CI run locally: + +```bash +make install && make lint && make test && make doctor +``` + +Clean up and start fresh Postgres: + +```bash +make kill && make docker-down && docker volume rm simple_module_python_pgdata +make docker-up && make migrate && make dev +``` + +## Tips + +- `make -j` can parallelize independent targets, but the supplied targets already parallelize where useful (`dev`, CI jobs). +- `make -n ` prints the commands without running them — great for understanding what a composite target does. +- The Makefile is short and readable — open it when a target's behavior surprises you. diff --git a/docs/testing/fixtures.md b/docs/testing/fixtures.md new file mode 100644 index 00000000..7356ada0 --- /dev/null +++ b/docs/testing/fixtures.md @@ -0,0 +1,209 @@ +# Fixtures + +The root `conftest.py` provides app-level fixtures that every test directory inherits. Everything you need for a typical integration test — app, DB session, HTTP client, authenticated client — is already wired up. + +## Available fixtures + +| Fixture | Scope | Returns | Use when | +|---|---|---|---| +| `settings` | function | `Settings` | You need to read the in-memory test settings (SQLite, `multi_tenant=True`). | +| `db_state` | function | `DatabaseState` | Low-level access to engines / sessions outside a request. | +| `engine` | function | `AsyncEngine` | You need the raw async engine (rare). | +| `db_session` | function | `AsyncSession` | Unit-testing a service. Tables created, Alembic head stamped. | +| `app` | function | `FastAPI` | Endpoint tests that don't need an HTTP client. | +| `client` | function | `httpx.AsyncClient` | Anonymous HTTP calls to the app. | +| `authenticated_client` | function | `httpx.AsyncClient` | Admin-scoped HTTP calls — carries a signed session cookie. | + +All fixtures are **function-scoped** — each test gets its own. The SQLite database is in-memory; tearing down and re-creating is cheap. + +## `settings` + +A pre-built `Settings` object using the in-memory SQLite URL and `multi_tenant=True`. Most tests don't use it directly — the `app` fixture consumes it. + +```python +def test_settings_defaults(settings): + assert settings.multi_tenant is True + assert settings.database_url.startswith("sqlite") +``` + +## `db_session` + +The workhorse. Creates a fresh in-memory DB, runs `CREATE TABLE` for every module's tables (via `build_module_metadata()`), stamps `alembic_version` at head, and yields an `AsyncSession`. + +```python +from decimal import Decimal +from orders.models import Order + +@pytest.mark.asyncio +async def test_create_order(db_session): + order = Order(customer_email="a@b.c", total=Decimal("1")) + db_session.add(order) + await db_session.flush() + assert order.id is not None +``` + +Notes: + +- **Tables are created directly from model metadata** (not `alembic upgrade`). This keeps tests fast. Alembic migrations are exercised separately — see [Migrations § Testing](/database/migrations). +- Stamping `alembic_version` at head means the boot-time migration check in the `app` fixture passes. If you change this, expect `SM010` to fail every test. +- Each test gets a fresh DB — no transaction-rollback trickery. + +## `app` + +Calls `create_app(settings)` and starts the lifespan. All modules are discovered, middleware is installed, `register_*` hooks run. Returns the `FastAPI` instance with a started lifespan; the fixture teardown closes it. + +Useful for tests that exercise framework registrations: + +```python +@pytest.mark.asyncio +async def test_menu_registered(app): + menus = app.state.sm.menu_registry + assert any(m.key == "orders" for m in menus.items()) +``` + +## `client` + +An `httpx.AsyncClient` pointed at the `app` via `ASGITransport`. No auth cookie. + +```python +@pytest.mark.asyncio +async def test_landing_page(client): + r = await client.get("/") + assert r.status_code == 200 + assert "Welcome" in r.text +``` + +For endpoints that return JSON: + +```python +r = await client.get("/api/orders") +assert r.json() == [] +``` + +401 / 403 are expected for authenticated endpoints — use `authenticated_client` instead. + +## `authenticated_client` + +Same as `client`, but the fixture also: + +1. Seeds an admin user via `users.bootstrap.create_admin(...)`. +2. Forges a signed session cookie for that user. +3. Attaches the cookie to the client. + +```python +@pytest.mark.asyncio +async def test_create_order_as_admin(authenticated_client): + r = await authenticated_client.post( + "/api/orders", + json={"customer_email": "a@b.c", "total": "1.00"}, + ) + assert r.status_code == 201 +``` + +The admin has `*` permission (via `DEFAULT_ROLE_PERMISSIONS["admin"]`), so it bypasses every `RequiresPermission` check. For tests that require a less-privileged principal, create a separate user and sign in via the login endpoint: + +```python +@pytest.mark.asyncio +async def test_non_admin_denied(client, db_session): + from users.service import UserService + svc = UserService(db_session) + await svc.create(email="u@e.com", password="x", roles=["viewer"]) + await db_session.commit() + + login = await client.post( + "/users/login", data={"email": "u@e.com", "password": "x"} + ) + assert login.status_code in (200, 303) + + r = await client.post("/api/orders", json={...}) + assert r.status_code == 403 +``` + +## Fixture composition + +Fixtures compose the expected way — `client` depends on `app`, which depends on `db_session`, which depends on `db_state`, which depends on `settings`. Request only the fixture you need; the DAG pulls in the rest. + +Adding your own fixtures at module level: + +```python +# modules/orders/tests/conftest.py +import pytest +from orders.service import OrderService + +@pytest.fixture +def order_service(db_session): + return OrderService(db_session) +``` + +Now every test in `modules/orders/tests/` can take `order_service` as a parameter. + +## Overriding dependencies + +FastAPI dependency overrides work as usual: + +```python +@pytest.mark.asyncio +async def test_with_mock_mailer(app, authenticated_client): + from users.deps import _mailer + captured = [] + def fake_mailer(): + return type("Mailer", (), {"send": lambda *a: captured.append(a)})() + app.dependency_overrides[_mailer] = fake_mailer + + await authenticated_client.post( + "/users/admin/invite", data={"email": "x@y.z"} + ) + assert len(captured) == 1 +``` + +Overrides applied to `app` persist for the lifetime of the fixture — no need to clean up; the next test gets a fresh `app`. + +## Time-sensitive tests + +Use `freezegun`: + +```python +from freezegun import freeze_time + +@pytest.mark.asyncio +async def test_order_timestamps(db_session): + with freeze_time("2026-01-01T12:00:00Z"): + order = Order(customer_email="a@b.c", total=Decimal("1")) + db_session.add(order) + await db_session.flush() + assert order.created_at.isoformat().startswith("2026-01-01T12:00:00") +``` + +Don't rely on `datetime.now()` assertions with tolerance windows — they're flaky and slower to read. + +## Mocking external services + +For HTTP, SMTP, storage backends — mock at the boundary with `httpx_mock`, `aiosmtpd`, or a service-registered fake. **Don't** monkeypatch the module you're testing; its behavior is what's under test. + +Example with `httpx_mock`: + +```python +@pytest.mark.asyncio +async def test_webhook_delivery(httpx_mock, db_session): + httpx_mock.add_response(url="https://example.com/hook", status_code=200) + await deliver_webhook(db_session, "https://example.com/hook", {"id": 1}) + assert httpx_mock.get_request().content == b'{"id": 1}' +``` + +## Parametrization + +Standard pytest: + +```python +@pytest.mark.parametrize("status,expected", [ + ("pending", 200), + ("shipped", 200), + ("invalid", 422), +]) +@pytest.mark.asyncio +async def test_status_transitions(authenticated_client, status, expected): + r = await authenticated_client.patch( + "/api/orders/1", json={"status": status} + ) + assert r.status_code == expected +``` diff --git a/docs/testing/overview.md b/docs/testing/overview.md new file mode 100644 index 00000000..bf26fb05 --- /dev/null +++ b/docs/testing/overview.md @@ -0,0 +1,98 @@ +# Testing overview + +The framework expects three kinds of tests: + +- **Unit tests** — pure functions, small service methods against `db_session`. `uv run pytest `. +- **Integration tests** — endpoints via `client` / `authenticated_client`. Still `pytest`. +- **E2E tests** — Playwright-driven browser tests marked `e2e`, excluded from default runs. + +The default `pytest` invocation pins `-m 'not e2e'` (root `pyproject.toml`), so `make test` never touches the Playwright suite — run those explicitly with `make test-e2e`. + +## Quick reference + +| Command | What it does | +|---|---| +| `make test` | `test-py` then `test-js` (e2e excluded). | +| `make test-py` | Just pytest. | +| `make test-js` | Just Vitest. | +| `make test-e2e` | Playwright suite against `localhost:8000`. Requires `make dev` running. | +| `uv run pytest path/to/test_file.py::test_name` | Single test. | +| `uv run pytest -k "create_order"` | All tests matching a name pattern. | +| `uv run pytest --lf` | Last failed only. | +| `uv run pytest -x` | Stop at first failure. | +| `uv run pytest -v -s` | Verbose + capture off (see `print`). | + +## Async mode + +The root `pyproject.toml` sets `asyncio_mode = "auto"`. Async tests don't need `@pytest.mark.asyncio` — any `async def test_*` is picked up automatically. You still need it on fixtures that are async generators. + +## File layout + +```text +conftest.py # root fixtures: app, db_session, client, authenticated_client +tests/ +├── framework/ # tests against framework/ packages +│ ├── core/ +│ ├── db/ +│ └── hosting/ +├── modules/ # tests that live at the root, cross-module +└── e2e/ # Playwright tests (marked `e2e`) +modules//tests/ # per-module pytest tests +``` + +Any `conftest.py` under a subtree can add more fixtures; they cascade down but don't leak sideways. Keep module-specific fixtures in the module's `tests/conftest.py`. + +## What good tests look like + +- Exercise **behavior**, not implementation. A test that asserts `assert service._private_method_called_twice` is a red flag — rewrite to assert on the observable outcome. +- Use the **real** DB (SQLite in-memory via `db_session`). Mocking SQLAlchemy is almost always wrong. +- Avoid mocking your own code. Mock **external** boundaries (HTTP, SMTP, queue). For internal calls, instantiate the collaborator. +- Use **`authenticated_client`** for endpoint tests that need auth — don't reinvent the session cookie. + +## Coverage expectations + +There's no enforced coverage percentage, but: + +- Every new endpoint gets at least one happy-path test. +- Every new service method with branching logic gets branch coverage. +- Bug fixes land with a regression test. +- Migration files don't need tests — but if the data-migration logic is non-trivial, extract the logic into a module-level function and test that. + +## JS / TSX tests + +Vitest + Testing Library, configured in `vitest.config.ts` and `vitest.setup.ts`. Tests live next to the source: + +```text +modules/orders/orders/pages/__tests__/Browse.test.tsx +packages/ui/src/components/__tests__/Button.test.tsx +``` + +Run all: `npx vitest run`. Watch: `npx vitest`. Single file: `npx vitest run path/to/Browse.test.tsx`. + +Use `@testing-library/react`'s `render` and query helpers: + +```tsx +import { render, screen } from "@testing-library/react"; +import Browse from "../Browse"; + +it("shows the empty state", () => { + render(); + expect(screen.getByText(/no orders yet/i)).toBeInTheDocument(); +}); +``` + +For pages that depend on `useT` / `useAuth`, wrap in a mock provider from `packages/ui/src/test-utils.tsx`. + +## Flaky-test checklist + +Before marking a test flaky, check: + +- **Time-dependent?** Use `freezegun` or inject a clock. +- **Order-dependent?** Run `pytest -p no:randomly` to disable reordering and see if the issue is real ordering coupling. +- **Async-order-dependent?** `await` everything that returns a coroutine, including `session.flush()` and `session.refresh()`. +- **Shared state?** Check for `scope="module"` fixtures that should be `scope="function"`. + +## Where to go from here + +- [Fixtures](/testing/fixtures) — the shared fixtures in `conftest.py` and how to extend them. +- [E2E tests](/e2e-testing) — the Playwright suite.