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.