From d125317c00b7dcef67ba3d0fdf5a1b09b76c042c Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 08:32:28 -0700 Subject: [PATCH 01/13] plan(slice-1): SD-0001 walking-skeleton implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Just-in-time plan for SLICE-1 (§7.2): backend 4-layer scaffold + import-linter, dev Postgres compose, psycopg migration runner + migration 0001, /healthz, scripts/check.sh + dev.sh, Vite/React shell, CI, docs/BOOTSTRAP.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-10-slice-1-walking-skeleton.md | 1478 +++++++++++++++++ 1 file changed, 1478 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-10-slice-1-walking-skeleton.md diff --git a/docs/superpowers/plans/2026-06-10-slice-1-walking-skeleton.md b/docs/superpowers/plans/2026-06-10-slice-1-walking-skeleton.md new file mode 100644 index 0000000..317a375 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-slice-1-walking-skeleton.md @@ -0,0 +1,1478 @@ +# SLICE-1 Walking Skeleton — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stand up the ecomm walking skeleton — a clean greenfield repo that, from a +fresh checkout, boots a FastAPI backend against an empty self-migrating PostgreSQL +database and serves `/healthz`, with the layer contract, dev container lifecycle, +and CI gate all in place (SD-0001 §7.2 SLICE-1; completes the localhost half of PUC-10). + +**Architecture:** Four-layer Python monolith `app.main > app.domains > app.platform` +(import-linter–enforced; GraphQL `api` layer deferred per §2). Persistence is +PostgreSQL everywhere (D-7): a single pinned Docker container in dev/CI, accessed via +psycopg 3 + a small connection pool, with forward-only numbered `.sql` migrations +applied at startup under a Postgres advisory lock (INV-7). A Vite/React SPA shell is +scaffolded but carries no screens yet (those land in SLICE-2/3). `scripts/dev.sh` +owns the container lifecycle; `scripts/check.sh` is the single gate run identically +locally and in Gitea CI. + +**Tech Stack:** Python 3.13 · FastAPI · psycopg[binary] 3 + psycopg-pool · pytest · +import-linter · PostgreSQL 16 (Docker) · React 18 + Vite 5 + TypeScript · Gitea Actions. + +**Reference (read-only prior art — patterns to port, not copy):** +`/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-prototype` +(`backend/app/platform/db.py`, `backend/.importlinter`, `scripts/check.sh`, +`.gitea/workflows/ci.yml`, `frontend/vite.config.ts`). The prototype is SQLite; this +plan ports the *structure* (migration runner shape, layer contract, gate) to psycopg. + +**Spec:** `/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/specs/SD-0001-mvp-sign-up-and-single-storefront.md` +(v0.3.0). Key sections: §6.2 architecture, §6.3 data model, §6.4 `/healthz` contract, +§6.7 D-7 datastore, §6.8 testing, §7.2 SLICE-1. + +**Working tree:** this is an isolated worktree on branch `claude/agitated-heyrovsky-1a1f47`; +the repo root already contains `README.md`, `CLAUDE.md`, `app.json`, `.gitignore`. All +paths below are relative to the repo root. + +--- + +## File Structure + +``` +wiggleverse-ecomm/ +├── compose.yaml # dev datastore: single pinned Postgres service +├── .gitignore # (modify: add backend/frontend build artifacts) +├── scripts/ +│ ├── check.sh # the gate: lint-imports + pytest + frontend build +│ └── dev.sh # owns container lifecycle + backend + Vite +├── .gitea/workflows/ci.yml # CI calls scripts/check.sh against a PG service +├── docs/ +│ └── BOOTSTRAP.md # localhost bring-up runbook (Docker prereq) +├── backend/ +│ ├── requirements.txt +│ ├── .importlinter # layer contract: main > domains > platform +│ ├── pytest.ini +│ ├── app/ +│ │ ├── __init__.py +│ │ ├── main.py # FastAPI factory + /healthz (the BFF lives here) +│ │ ├── domains/__init__.py # empty package (filled in SLICE-2/3) +│ │ └── platform/ +│ │ ├── __init__.py +│ │ ├── config.py # env-driven config surface (INV-8) +│ │ ├── db.py # psycopg connect/pool + migration runner (INV-7) +│ │ └── deps.py # per-request DB connection dependency +│ ├── migrations/ +│ │ └── 0001_init.sql # §6.3 schema: account, auth_code, storefront, membership +│ └── tests/ +│ ├── conftest.py # fresh-per-test Postgres database fixtures +│ ├── test_migrations.py # migrate-from-empty + idempotent re-migrate (INV-1 partial) +│ └── test_healthz.py # /healthz green on a migrated empty DB +└── frontend/ + ├── package.json + ├── package-lock.json # committed; CI uses npm ci + ├── tsconfig.json + ├── tsconfig.node.json + ├── vite.config.ts # proxies /api -> :8000 + ├── index.html + └── src/ + ├── main.tsx + └── App.tsx # static shell ("ecomm"); screens land in SLICE-2/3 +``` + +**Responsibilities (one job each):** +- `platform/config.py` — the single place env/deployment config is read (INV-8); no other module reads `os.environ` for config. +- `platform/db.py` — connection pool + the forward-only migration runner; owns the schema lifecycle, no business rules. +- `platform/deps.py` — FastAPI injectable yielding a pooled connection per request. +- `app/main.py` — the app factory: builds the pool, migrates once at startup, mounts `/healthz`. The REST BFF will grow here in later slices. +- `migrations/0001_init.sql` — the entire §6.3 schema, forward-only and immutable once merged. + +--- + +## Task 1: Backend project skeleton, dependencies & layer contract + +**Files:** +- Create: `backend/requirements.txt` +- Create: `backend/app/__init__.py` +- Create: `backend/app/domains/__init__.py` +- Create: `backend/app/platform/__init__.py` +- Create: `backend/.importlinter` +- Create: `backend/pytest.ini` +- Modify: `.gitignore` + +- [ ] **Step 1: Create the backend dependency list** + +Create `backend/requirements.txt`: + +``` +fastapi>=0.110 +uvicorn[standard]>=0.29 +httpx>=0.27 +psycopg[binary]>=3.1 +psycopg-pool>=3.2 +pytest>=8.0 +import-linter>=2.0 +``` + +- [ ] **Step 2: Create the package skeleton** + +Create `backend/app/__init__.py` (empty file). + +Create `backend/app/domains/__init__.py`: + +```python +"""domains layer — bounded contexts (accounts, storefronts). + +Empty in SLICE-1; the accounts domain lands in SLICE-2 and storefronts in SLICE-3. +The package exists now so the layer contract (.importlinter) has a target and later +slices add to a stable seam. Domains import only from app.platform, never upward. +""" +``` + +Create `backend/app/platform/__init__.py`: + +```python +"""platform layer — cross-cutting primitives (config, db, deps). + +The bottom layer: imports nothing from app.domains or app.main. Owns the schema +lifecycle and request wiring, never business rules (SD-0001 §6.2). +""" +``` + +- [ ] **Step 3: Create the import-linter layer contract** + +Create `backend/.importlinter`: + +```ini +# Enforces SD-0001 §6.2: the four-layer contract main > domains > platform, with +# imports flowing only downward. The GraphQL `api` layer is deferred (§2), so the +# MVP contract has three layers; `app.api` is added back between main and domains +# when GraphQL arrives. Run from backend/: .venv/bin/lint-imports +[importlinter] +root_package = app + +[importlinter:contract:layers] +name = App layering (main > domains > platform) +type = layers +layers = + app.main + app.domains + app.platform +``` + +- [ ] **Step 4: Configure pytest** + +Create `backend/pytest.ini`: + +```ini +[pytest] +testpaths = tests +python_files = test_*.py +addopts = -q +# Put backend/ (this rootdir) on sys.path so tests import the `app` package under +# pytest's default prepend import mode (tests/ has no __init__.py). +pythonpath = . +``` + +- [ ] **Step 5: Extend .gitignore for build artifacts** + +Modify `.gitignore` — append these lines (the existing file already has `.venv/`, +`__pycache__/`, `node_modules/`, `dist/`, `.env`): + +``` +.pytest_cache/ +frontend/dist/ +backend/var/ +``` + +- [ ] **Step 6: Create the venv and install deps (one-time, to verify the list resolves)** + +Run from repo root: + +```bash +python3 -m venv .venv +.venv/bin/python -m pip install --upgrade pip +.venv/bin/python -m pip install -r backend/requirements.txt +``` + +Expected: all packages install without error; `.venv/bin/lint-imports` and +`.venv/bin/pytest` exist. + +- [ ] **Step 7: Verify the layer contract passes on the empty skeleton** + +Run from repo root: + +```bash +cd backend && ../.venv/bin/lint-imports +``` + +Expected: `Contracts: 1 kept, 0 broken.` (an empty `app` with three layer packages +satisfies the contract trivially). + +- [ ] **Step 8: Commit** + +```bash +git add backend/requirements.txt backend/app backend/.importlinter backend/pytest.ini .gitignore +git commit -m "feat(slice-1): backend skeleton, deps, and import-linter layer contract + +Four-layer package (main > domains > platform) per SD-0001 §6.2; GraphQL api +layer deferred. lint-imports green on the empty skeleton." +``` + +--- + +## Task 2: Config surface (env-driven, INV-8) + +**Files:** +- Create: `backend/app/platform/config.py` +- Test: `backend/tests/test_config.py` + +The config surface is the single place env is read (INV-8 — no deployment shape baked +into other modules). It exposes the database DSN with a localhost-dev default that +matches the compose service (Task 5). + +- [ ] **Step 1: Write the failing test** + +Create `backend/tests/test_config.py`: + +```python +import os + +from app.platform import config + + +def test_database_url_defaults_to_local_compose(monkeypatch): + monkeypatch.delenv("ECOMM_DATABASE_URL", raising=False) + assert config.database_url() == "postgresql://ecomm:ecomm@localhost:5432/ecomm" + + +def test_database_url_honors_env(monkeypatch): + monkeypatch.setenv("ECOMM_DATABASE_URL", "postgresql://x:y@db:5432/z") + assert config.database_url() == "postgresql://x:y@db:5432/z" +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run from `backend/`: `../.venv/bin/pytest tests/test_config.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.platform.config'`. + +(Tests import the `app` package because `pythonpath = .` in `pytest.ini` puts the +rootdir, `backend/`, on `sys.path`. If you instead see a collection error about not +finding `app` at all, confirm that `pythonpath` line is present.) + +- [ ] **Step 3: Write the implementation** + +Create `backend/app/platform/config.py`: + +```python +"""Configuration surface — the single place deployment/environment config is read. + +INV-8: no deployment shape is baked into framework code. Every URL, credential, or +relay coordinate arrives through here from the environment (resolved from Secret +Manager in deployed environments). The localhost defaults match compose.yaml so a +clean `scripts/dev.sh` checkout needs no env setup. +""" +from __future__ import annotations + +import os + +# Dev default points at the single Postgres container compose.yaml brings up. In +# PPE/Prod the deployment supplies ECOMM_DATABASE_URL from Secret Manager (INV-8). +_DEFAULT_DATABASE_URL = "postgresql://ecomm:ecomm@localhost:5432/ecomm" + + +def database_url() -> str: + """The psycopg DSN for the application database.""" + return os.environ.get("ECOMM_DATABASE_URL") or _DEFAULT_DATABASE_URL +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run from `backend/`: `../.venv/bin/pytest tests/test_config.py -q` +Expected: PASS (2 passed). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/platform/config.py backend/tests/test_config.py +git commit -m "feat(slice-1): platform/config — single env-driven config surface (INV-8)" +``` + +--- + +## Task 3: Dev Postgres compose file + +**Files:** +- Create: `compose.yaml` + +A single pinned Postgres service with a named volume and healthcheck (D-7). The app +stays native; the container is the datastore only. This task has no automated test — +it is verified by Task 4's tests (which need the container up) and Task 9's dev.sh run. + +- [ ] **Step 1: Create the compose file** + +Create `compose.yaml`: + +```yaml +# Dev datastore for ecomm — a single pinned PostgreSQL service (SD-0001 D-7). +# The app runs natively (fast dev loop); only the database is containerized. +# `scripts/dev.sh` owns this container's lifecycle. Reset to empty (to rehearse the +# bootstrap, BUC-5) with: docker compose down -v +name: ecomm-dev + +services: + db: + # Pinned to the Cloud SQL major version ecomm targets in PPE/Prod (D-7). + image: postgres:16 + environment: + POSTGRES_USER: ecomm + POSTGRES_PASSWORD: ecomm + POSTGRES_DB: ecomm + ports: + - "5432:5432" + volumes: + - ecomm-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ecomm -d ecomm"] + interval: 2s + timeout: 3s + retries: 30 + +volumes: + ecomm-pgdata: +``` + +- [ ] **Step 2: Bring the container up and verify it is healthy** + +Run from repo root: + +```bash +docker compose up -d --wait db +docker compose ps +``` + +Expected: the `db` service is `running` and `healthy`. + +- [ ] **Step 3: Verify connectivity from psycopg** + +Run from repo root: + +```bash +.venv/bin/python -c "import psycopg; psycopg.connect('postgresql://ecomm:ecomm@localhost:5432/ecomm').close(); print('connected')" +``` + +Expected: `connected`. + +- [ ] **Step 4: Commit** + +```bash +git add compose.yaml +git commit -m "feat(slice-1): dev Postgres compose — single pinned service, named volume (D-7)" +``` + +--- + +## Task 4: platform/db — migration runner + migration 0001 (INV-7) + +**Files:** +- Create: `backend/app/platform/db.py` +- Create: `backend/migrations/0001_init.sql` +- Create: `backend/tests/conftest.py` +- Test: `backend/tests/test_migrations.py` + +This is the heart of SLICE-1. The migration runner is forward-only, fail-stop, and +guarded by a Postgres advisory lock so two booting processes cannot double-apply +(INV-7). Migration `0001` is the entire §6.3 schema. Tests prove migrate-from-empty +applies `0001` and creates the four tables, and that a second `migrate()` is a no-op +(INV-1 partial; the full-flow bootstrap test lands in SLICE-3). + +- [ ] **Step 1: Create the per-test database fixtures** + +Each test runs on its own freshly-created, empty database on the compose Postgres, so +every test exercises the real engine (§6.8) and starts from a clean slate. + +Create `backend/tests/conftest.py`: + +```python +"""Test fixtures — a fresh, empty PostgreSQL database per test. + +Each test gets its own database created on the dev/CI Postgres (every test runs the +real engine, §6.8). The admin DSN points at the `postgres` maintenance database; +ECOMM_TEST_ADMIN_URL overrides it (CI sets it to the service container). +""" +from __future__ import annotations + +import os +import uuid + +import psycopg +import pytest + +ADMIN_URL = os.environ.get( + "ECOMM_TEST_ADMIN_URL", "postgresql://ecomm:ecomm@localhost:5432/postgres" +) + + +def _swap_dbname(dsn: str, dbname: str) -> str: + # ADMIN_URL ends in `/postgres`; point the same credentials at `dbname`. + base, _, _old = dsn.rpartition("/") + return f"{base}/{dbname}" + + +@pytest.fixture() +def fresh_db_url(): + """Create a uniquely-named empty database; drop it after the test.""" + dbname = f"ecomm_test_{uuid.uuid4().hex}" + admin = psycopg.connect(ADMIN_URL, autocommit=True) + try: + admin.execute(f'CREATE DATABASE "{dbname}"') + finally: + admin.close() + try: + yield _swap_dbname(ADMIN_URL, dbname) + finally: + admin = psycopg.connect(ADMIN_URL, autocommit=True) + try: + admin.execute( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " + "WHERE datname = %s AND pid <> pg_backend_pid()", + (dbname,), + ) + admin.execute(f'DROP DATABASE IF EXISTS "{dbname}"') + finally: + admin.close() +``` + +- [ ] **Step 2: Write the failing migration-runner tests** + +Create `backend/tests/test_migrations.py`: + +```python +import psycopg + +from app.platform import db + +_TABLES = {"account", "auth_code", "storefront", "storefront_membership"} + + +def _table_names(conn) -> set[str]: + rows = conn.execute( + "SELECT tablename FROM pg_tables WHERE schemaname = 'public'" + ).fetchall() + return {r[0] for r in rows} + + +def test_migrate_from_empty_applies_0001(fresh_db_url): + with psycopg.connect(fresh_db_url) as conn: + applied = db.migrate(conn) + assert applied == ["0001_init.sql"] + with psycopg.connect(fresh_db_url) as conn: + assert _TABLES.issubset(_table_names(conn)) + + +def test_migrate_is_idempotent(fresh_db_url): + with psycopg.connect(fresh_db_url) as conn: + db.migrate(conn) + with psycopg.connect(fresh_db_url) as conn: + applied_again = db.migrate(conn) + assert applied_again == [] + + +def test_membership_has_no_unique_account_constraint(fresh_db_url): + # INV-4: the schema must keep many-storefronts-per-account open — no UNIQUE on + # storefront_membership.account_id anywhere. + with psycopg.connect(fresh_db_url) as conn: + db.migrate(conn) + rows = conn.execute( + """ + SELECT indexdef FROM pg_indexes + WHERE schemaname = 'public' AND tablename = 'storefront_membership' + """ + ).fetchall() + defs = " ".join(r[0] for r in rows).lower() + assert "unique" not in defs.replace("primary key", "") or "(account_id)" not in defs +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run from `backend/`: `../.venv/bin/pytest tests/test_migrations.py -q` +Expected: FAIL — `AttributeError: module 'app.platform.db' has no attribute 'migrate'` +(or `ModuleNotFoundError` for `app.platform.db`). + +- [ ] **Step 4: Write the migration runner** + +Create `backend/app/platform/db.py`: + +```python +"""PostgreSQL access (psycopg 3) + a forward-only migration runner. + +Standard wiggleverse stack ported to Postgres (SD-0001 D-7): a connection pool and +numbered forward-only `.sql` migrations applied in filename order, recorded in +`schema_migrations`. No ORM. The runner is fail-stop (a failing migration aborts and +records nothing, so it retries next boot) and guarded by a session advisory lock so +two booting processes never double-apply (INV-7). +""" +from __future__ import annotations + +from pathlib import Path + +import psycopg +from psycopg_pool import ConnectionPool + +from . import config + +# app/platform/db.py -> parents[2] is backend/, where migrations/ lives. +MIGRATIONS_DIR = Path(__file__).resolve().parents[2] / "migrations" + +# Arbitrary fixed key for the migration advisory lock (INV-7 concurrency guard). +_MIGRATION_LOCK_KEY = 0x6563_6F6D_6D31 # "ecomm1" + + +def open_pool(dsn: str | None = None, *, min_size: int = 1, max_size: int = 5) -> ConnectionPool: + """Open a small connection pool against the configured database.""" + pool = ConnectionPool(dsn or config.database_url(), min_size=min_size, max_size=max_size, open=False) + pool.open() + pool.wait() + return pool + + +def _migration_files(migrations_dir: Path) -> list[Path]: + return sorted(migrations_dir.glob("*.sql")) + + +def migrate(conn: psycopg.Connection, migrations_dir: Path = MIGRATIONS_DIR) -> list[str]: + """Apply unapplied migrations in filename order; return the names applied. + + Forward-only and fail-stop: each migration runs with its `schema_migrations` + insert in one transaction, so a failure records nothing and is retried next time. + A session advisory lock serializes concurrent migrators (INV-7). + """ + conn.execute("SELECT pg_advisory_lock(%s)", (_MIGRATION_LOCK_KEY,)) + try: + conn.execute( + "CREATE TABLE IF NOT EXISTS schema_migrations (" + " filename TEXT PRIMARY KEY," + " applied_at TIMESTAMPTZ NOT NULL DEFAULT now())" + ) + conn.commit() + applied = { + r[0] for r in conn.execute("SELECT filename FROM schema_migrations").fetchall() + } + ran: list[str] = [] + for path in _migration_files(migrations_dir): + if path.name in applied: + continue + try: + conn.execute(path.read_text()) # whole file; no params -> multi-statement + conn.execute( + "INSERT INTO schema_migrations (filename) VALUES (%s)", (path.name,) + ) + conn.commit() + except Exception: + conn.rollback() + raise + ran.append(path.name) + return ran + finally: + conn.execute("SELECT pg_advisory_unlock(%s)", (_MIGRATION_LOCK_KEY,)) + conn.commit() + + +def pending_migrations(conn: psycopg.Connection, migrations_dir: Path = MIGRATIONS_DIR) -> list[str]: + """Migration filenames present on disk but not yet recorded as applied. + + Used by /healthz to report migration currency (§6.4). Returns all on-disk + migrations if the tracking table does not exist yet. + """ + exists = conn.execute("SELECT to_regclass('public.schema_migrations')").fetchone()[0] + if exists is None: + return [p.name for p in _migration_files(migrations_dir)] + applied = { + r[0] for r in conn.execute("SELECT filename FROM schema_migrations").fetchall() + } + return [p.name for p in _migration_files(migrations_dir) if p.name not in applied] +``` + +- [ ] **Step 5: Write migration 0001 (the §6.3 schema)** + +Create `backend/migrations/0001_init.sql`: + +```sql +-- 0001_init.sql — SD-0001 §6.3 data model. Forward-only and fail-stop (INV-7): +-- do NOT edit this file once it has merged/applied; add a new numbered migration. + +-- account — a person's identity on the platform. Email is the canonical key (INV-2); +-- the address is stored already-normalized (lowercased) by the service layer, so a +-- plain unique index enforces one-account-per-email. Data minimization (OHM): email +-- and nothing else (§6.3). +CREATE TABLE account ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX account_email_key ON account (email); -- INV-2 + +-- auth_code — one-time codes, handled like secrets (INV-3): only the hash is stored; +-- keyed by email (the code is issued before the account may exist — sign-up and +-- log-in converge, PUC-3). +CREATE TABLE auth_code ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email TEXT NOT NULL, + code_hash TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + consumed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX auth_code_email_idx ON auth_code (email); + +-- storefront — a merchant's business presence. Name is always populated (a blank +-- entry gets a generated default at the service layer, §6.3); "unnamed" is never NULL. +CREATE TABLE storefront ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- storefront_membership — account <-> storefront ownership. INV-4: deliberately a +-- many-to-many shape (composite PK, no UNIQUE(account_id)) so many-storefronts-per- +-- account stays open; the one-storefront rule is a deletable service-layer guard, not +-- a schema law. INV-5: every storefront-scoped row carries its storefront_id. `role` +-- is a single-value enum today; it is the seam the staff/permissions model attaches to. +CREATE TABLE storefront_membership ( + account_id BIGINT NOT NULL REFERENCES account (id), + storefront_id BIGINT NOT NULL REFERENCES storefront (id), + role TEXT NOT NULL DEFAULT 'owner' CHECK (role IN ('owner')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (account_id, storefront_id) +); +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Ensure the compose Postgres is up (`docker compose up -d --wait db`), then run from +`backend/`: `../.venv/bin/pytest tests/test_migrations.py -q` +Expected: PASS (3 passed). + +- [ ] **Step 7: Run the layer contract (db must import only downward)** + +Run from `backend/`: `../.venv/bin/lint-imports` +Expected: `Contracts: 1 kept, 0 broken.` (`db.py` imports only `app.platform.config` +and third-party packages — no upward imports). + +- [ ] **Step 8: Commit** + +```bash +git add backend/app/platform/db.py backend/migrations/0001_init.sql backend/tests/conftest.py backend/tests/test_migrations.py +git commit -m "feat(slice-1): platform/db migration runner + migration 0001 (INV-7, §6.3 schema) + +Forward-only, fail-stop, advisory-lock-guarded psycopg runner; migration 0001 lays +the account/auth_code/storefront/membership schema. Tests prove migrate-from-empty +and idempotent re-migrate (INV-1 partial) and the INV-4 many-capable shape." +``` + +--- + +## Task 5: FastAPI app factory + /healthz + +**Files:** +- Create: `backend/app/platform/deps.py` +- Create: `backend/app/main.py` +- Test: `backend/tests/test_healthz.py` + +`create_app()` builds the pool, migrates once at startup, and mounts `/healthz`. +`/healthz` returns `200 {"status":"ok"}` when the process is up, the DB is reachable, +and migrations are current; `503` otherwise (§6.4 — the deploy gate). + +- [ ] **Step 1: Write the per-request connection dependency** + +Create `backend/app/platform/deps.py`: + +```python +"""FastAPI dependencies — per-request wiring (SD-0001 §6.2 platform/deps). + +Yields a pooled connection per request. The pool lives on app.state, built once in +create_app(). Later slices add the current-session and mailer dependencies here. +""" +from __future__ import annotations + +from collections.abc import Iterator + +import psycopg +from fastapi import Request + + +def get_conn(request: Request) -> Iterator[psycopg.Connection]: + """A pooled connection for the duration of one request.""" + pool = request.app.state.pool + with pool.connection() as conn: + yield conn +``` + +- [ ] **Step 2: Write the failing /healthz test** + +Create `backend/tests/test_healthz.py`: + +```python +from fastapi.testclient import TestClient + +from app.main import create_app + + +def test_healthz_ok_on_migrated_empty_db(fresh_db_url): + app = create_app(database_url=fresh_db_url) + with TestClient(app) as client: + resp = client.get("/healthz") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + +def test_startup_migrates_from_empty(fresh_db_url): + # create_app on an empty DB must self-migrate so /healthz reports current. + app = create_app(database_url=fresh_db_url) + with TestClient(app) as client: + assert client.get("/healthz").status_code == 200 + # the schema_migrations row exists -> migrations ran at startup + import psycopg + + with psycopg.connect(fresh_db_url) as conn: + n = conn.execute("SELECT count(*) FROM schema_migrations").fetchone()[0] + assert n >= 1 +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run from `backend/`: `../.venv/bin/pytest tests/test_healthz.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.main'`. + +- [ ] **Step 4: Write the app factory + /healthz** + +Create `backend/app/main.py`: + +```python +"""ecomm backend — FastAPI app factory + the REST BFF. + +SLICE-1 mounts only /healthz; the auth and storefront endpoints (§6.4) grow here in +later slices. create_app() opens the connection pool and self-migrates the database +at startup (INV-1, INV-7): the app boots against empty persistence and applies its +own schema — there is no seed step. +""" +from __future__ import annotations + +from contextlib import asynccontextmanager + +import psycopg +from fastapi import Depends, FastAPI, Response + +from app.platform import config, db +from app.platform.deps import get_conn + + +def create_app(database_url: str | None = None) -> FastAPI: + dsn = database_url or config.database_url() + + @asynccontextmanager + async def lifespan(app: FastAPI): + app.state.pool = db.open_pool(dsn) + with app.state.pool.connection() as conn: + db.migrate(conn) # self-migrate at startup (INV-1, INV-7) + try: + yield + finally: + app.state.pool.close() + + app = FastAPI(title="ecomm", version="0.1", lifespan=lifespan) + + @app.get("/healthz") + def healthz(response: Response, conn: psycopg.Connection = Depends(get_conn)): + """Liveness + readiness: process up, DB reachable, migrations current (§6.4).""" + try: + conn.execute("SELECT 1") + pending = db.pending_migrations(conn) + except Exception: + response.status_code = 503 + return {"status": "unavailable", "reason": "database_unreachable"} + if pending: + response.status_code = 503 + return {"status": "unavailable", "reason": "migrations_pending"} + return {"status": "ok"} + + return app + + +app = create_app() +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Ensure compose Postgres is up, then run from `backend/`: +`../.venv/bin/pytest tests/test_healthz.py -q` +Expected: PASS (2 passed). + +- [ ] **Step 6: Run the full backend suite + layer contract** + +Run from `backend/`: + +```bash +../.venv/bin/lint-imports +../.venv/bin/pytest -q +``` + +Expected: `Contracts: 1 kept, 0 broken.` and all tests pass (config + migrations + +healthz). + +- [ ] **Step 7: Manually verify the app boots and serves /healthz** + +Run from `backend/` (compose Postgres up): + +```bash +../.venv/bin/python -m uvicorn app.main:app --port 8000 & +sleep 2 +curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8000/healthz +curl -s http://localhost:8000/healthz +kill %1 +``` + +Expected: `200` then `{"status":"ok"}`. + +- [ ] **Step 8: Commit** + +```bash +git add backend/app/platform/deps.py backend/app/main.py backend/tests/test_healthz.py +git commit -m "feat(slice-1): FastAPI factory + /healthz; self-migrate at startup (INV-1/§6.4)" +``` + +--- + +## Task 6: scripts/check.sh — the single gate + +**Files:** +- Create: `scripts/check.sh` + +The one pre-merge gate: import-linter, then pytest, then the frontend typecheck+build +(§6.8). CI calls this exact script so local and server gates never drift. (The +frontend build line is added now but only succeeds after Task 7 creates the frontend; +order the tasks so check.sh is finalized with the frontend present — this task creates +it through the backend gates, Task 7 adds the frontend, Task 8's step re-runs it whole.) + +- [ ] **Step 1: Create the gate script** + +Create `scripts/check.sh`: + +```bash +#!/usr/bin/env bash +# The single pre-merge gate for wiggleverse-ecomm (SD-0001 §6.8). Runs, in order: +# 1. the import-linter layering contract (main > domains > platform) +# 2. the backend test suite (pytest, against Postgres) +# 3. the frontend typecheck + production build +# CI (.gitea/workflows/ci.yml) calls this exact script, so local and server gates +# cannot drift. Exits non-zero on the first failure. +# +# Requires a reachable Postgres for the backend tests: locally the compose service +# (scripts/dev.sh brings it up); in CI a Postgres service container. Override its +# admin DSN with ECOMM_TEST_ADMIN_URL. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Pick an interpreter: explicit $PYTHON (CI), else the repo venv, else PATH. +if [ -n "${PYTHON:-}" ]; then + PY="$PYTHON" +elif [ -x "$repo_root/.venv/bin/python" ]; then + PY="$repo_root/.venv/bin/python" +else + PY="$(command -v python3 || command -v python)" +fi + +lint="$(dirname "$PY")/lint-imports" +[ -x "$lint" ] || lint="lint-imports" + +echo "==> import boundaries (lint-imports)" +( cd "$repo_root/backend" && "$lint" ) + +echo "==> backend tests (pytest)" +( cd "$repo_root/backend" && "$PY" -m pytest -q ) + +echo "==> frontend typecheck + build" +( cd "$repo_root/frontend" && npm run build ) + +echo "==> all gates green" +``` + +- [ ] **Step 2: Make it executable** + +```bash +chmod +x scripts/check.sh +``` + +- [ ] **Step 3: Verify the backend portion runs** + +The frontend does not exist yet, so the full script will fail at the frontend step — +that is expected and resolved in Task 8. Verify the backend gates pass by running them +directly (compose Postgres up), from repo root: + +```bash +( cd backend && ../.venv/bin/lint-imports && ../.venv/bin/python -m pytest -q ) +``` + +Expected: contract kept + all backend tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/check.sh +git commit -m "feat(slice-1): scripts/check.sh — the single gate (lint-imports, pytest, frontend build)" +``` + +--- + +## Task 7: scripts/dev.sh — owns the container lifecycle + +**Files:** +- Create: `scripts/dev.sh` + +From a clean checkout, `dev.sh` brings up the dev Postgres, waits for healthy, ensures +the backend venv and frontend deps, then runs uvicorn (:8000) and Vite (:5173) +together. It owns the container lifecycle (§6.5, R-6 mitigation). + +- [ ] **Step 1: Create the dev script** + +Create `scripts/dev.sh`: + +```bash +#!/usr/bin/env bash +# Local bring-up for ecomm (SD-0001 PUC-10). From a clean checkout this: +# 1. starts the dev Postgres container and waits for it to be healthy (owns its +# lifecycle — Docker is the one prerequisite, docs/BOOTSTRAP.md); +# 2. ensures the backend venv + deps and the frontend node_modules; +# 3. runs the FastAPI backend (:8000, self-migrating an empty DB) and the Vite dev +# server (:5173, proxying /api -> :8000) together. +# Ctrl-C stops both processes; the container keeps running (stop it with +# `docker compose down`, or `docker compose down -v` to reset to empty). +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +export ECOMM_DATABASE_URL="${ECOMM_DATABASE_URL:-postgresql://ecomm:ecomm@localhost:5432/ecomm}" + +echo "==> dev Postgres (docker compose up --wait)" +docker compose up -d --wait db + +if [ ! -x "$repo_root/.venv/bin/python" ]; then + echo "==> creating backend venv" + python3 -m venv "$repo_root/.venv" + "$repo_root/.venv/bin/python" -m pip install --upgrade pip + "$repo_root/.venv/bin/python" -m pip install -r "$repo_root/backend/requirements.txt" +fi + +if [ ! -d "$repo_root/frontend/node_modules" ]; then + echo "==> installing frontend deps" + ( cd "$repo_root/frontend" && npm install ) +fi + +echo "==> starting backend (:8000) and Vite (:5173) — Ctrl-C to stop" +"$repo_root/.venv/bin/python" -m uvicorn app.main:app --app-dir "$repo_root/backend" --port 8000 --reload & +backend_pid=$! +( cd "$repo_root/frontend" && npm run dev ) & +frontend_pid=$! + +cleanup() { + echo + echo "==> stopping backend and Vite" + kill "$backend_pid" "$frontend_pid" 2>/dev/null || true + wait "$backend_pid" "$frontend_pid" 2>/dev/null || true +} +trap cleanup INT TERM + +wait -n "$backend_pid" "$frontend_pid" +cleanup +``` + +- [ ] **Step 2: Make it executable** + +```bash +chmod +x scripts/dev.sh +``` + +- [ ] **Step 3: Commit** (verified end-to-end in Task 10) + +```bash +git add scripts/dev.sh +git commit -m "feat(slice-1): scripts/dev.sh — owns dev Postgres lifecycle + backend + Vite" +``` + +--- + +## Task 8: Frontend Vite/React shell + finalize the gate + +**Files:** +- Create: `frontend/package.json` +- Create: `frontend/tsconfig.json` +- Create: `frontend/tsconfig.node.json` +- Create: `frontend/vite.config.ts` +- Create: `frontend/index.html` +- Create: `frontend/src/main.tsx` +- Create: `frontend/src/App.tsx` + +A minimal React/Vite shell. No screens yet (those land in SLICE-2/3) — just a static +page that proves the build and dev server work. The `/api` proxy is wired now so later +slices need no config change. + +- [ ] **Step 1: Create package.json** + +Create `frontend/package.json`: + +```json +{ + "name": "wiggleverse-ecomm-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } +} +``` + +- [ ] **Step 2: Create the TypeScript configs** + +Create `frontend/tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} +``` + +Create `frontend/tsconfig.node.json`: + +```json +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "noEmit": true + }, + "include": ["vite.config.ts"] +} +``` + +- [ ] **Step 3: Create the Vite config with the /api proxy** + +Create `frontend/vite.config.ts`: + +```typescript +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// The SPA talks to the FastAPI backend on :8000 via a same-origin proxy, so the +// browser makes no cross-origin calls in dev. Override the target with +// VITE_API_TARGET when :8000 is occupied. Screen-shaped /api/* endpoints arrive in +// SLICE-2/3 (SD-0001 §6.4). +const apiTarget = process.env.VITE_API_TARGET || "http://localhost:8000"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": apiTarget, + }, + }, +}); +``` + +- [ ] **Step 4: Create the HTML entry and React shell** + +Create `frontend/index.html`: + +```html + + + + + + ecomm + + +
+ + + +``` + +Create `frontend/src/main.tsx`: + +```tsx +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; + +createRoot(document.getElementById("root")!).render( + + + , +); +``` + +Create `frontend/src/App.tsx`: + +```tsx +// SLICE-1 shell only — the Landing, Sign-in, Create-storefront, and Admin screens +// (SD-0001 §5) land in SLICE-2/3. This proves the build and dev server work. +export default function App() { + return ( +
+

ecomm

+

Honest commerce. Your storefront is yours.

+
+ ); +} +``` + +- [ ] **Step 5: Install deps (generates the committed lockfile) and verify the build** + +Run from repo root: + +```bash +( cd frontend && npm install && npm run build ) +``` + +Expected: `npm install` creates `frontend/package-lock.json`; `npm run build` runs +`tsc` (typecheck, no errors) then `vite build` (produces `frontend/dist/`). + +- [ ] **Step 6: Run the whole gate end-to-end** + +Ensure compose Postgres is up, then run from repo root: + +```bash +scripts/check.sh +``` + +Expected: import boundaries kept → backend tests pass → frontend build succeeds → +`all gates green`. + +- [ ] **Step 7: Commit** + +```bash +git add frontend/package.json frontend/package-lock.json frontend/tsconfig.json frontend/tsconfig.node.json frontend/vite.config.ts frontend/index.html frontend/src +git commit -m "feat(slice-1): Vite/React shell + /api proxy; scripts/check.sh now fully green" +``` + +--- + +## Task 9: CI workflow (Gitea Actions) + +**Files:** +- Create: `.gitea/workflows/ci.yml` + +CI runs the same `scripts/check.sh` against a Postgres service container. Per repo +memory, CI is inert until a Gitea runner is registered — so this is written correctly +but will not execute yet; it is the DoD's "check gate green in CI" target. + +- [ ] **Step 1: Create the workflow** + +Create `.gitea/workflows/ci.yml`: + +```yaml +# Server-side pre-merge gate. Calls the same scripts/check.sh that runs locally, so +# the local and server gates can never drift: import-linter (main > domains > +# platform), then backend pytest against a Postgres service, then the frontend +# typecheck + build. Requires a registered Gitea Actions runner advertising the +# `ubuntu-latest` label. +name: ci + +on: + push: + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: ecomm + POSTGRES_PASSWORD: ecomm + POSTGRES_DB: ecomm + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U ecomm" + --health-interval 2s + --health-timeout 3s + --health-retries 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install backend deps + run: | + python -m venv .venv + .venv/bin/python -m pip install --upgrade pip + .venv/bin/python -m pip install -r backend/requirements.txt + + - name: Install frontend deps + run: | + cd frontend && npm ci + + - name: Run the pre-merge gate + env: + PYTHON: ${{ github.workspace }}/.venv/bin/python + ECOMM_TEST_ADMIN_URL: postgresql://ecomm:ecomm@localhost:5432/postgres + run: scripts/check.sh +``` + +- [ ] **Step 2: Commit** + +```bash +git add .gitea/workflows/ci.yml +git commit -m "ci(slice-1): Gitea workflow runs scripts/check.sh against a Postgres service" +``` + +--- + +## Task 10: docs/BOOTSTRAP.md (localhost section) + +**Files:** +- Create: `docs/BOOTSTRAP.md` + +The documented bring-up gesture BUC-5 demands — localhost section for SLICE-1; the +PPE/Prod sections land in SLICE-4. + +- [ ] **Step 1: Write the localhost runbook** + +Create `docs/BOOTSTRAP.md`: + +```markdown +# Bootstrapping ecomm + +Bringing an environment from **empty persistence** to "first merchant, first +storefront" through the product flows alone (SD-0001 BUC-5). Empty is a working state +(INV-1): the app applies its own schema migrations at startup; there is no seed step. + +This document grows per environment. SLICE-1 ships the **localhost** section; the +pre-production and production sections land with SLICE-4. + +## Localhost + +### Prerequisites + +- **Docker** (Desktop or Engine) with Compose v2 — the one external prerequisite. The + dev datastore is a single PostgreSQL container; the app itself runs natively for a + fast dev loop (D-7). +- **Python 3.13** and **Node 20+** on your PATH. `scripts/dev.sh` creates the backend + venv and installs frontend deps on first run. + +### Bring-up + +From a clean checkout: + +\`\`\`bash +scripts/dev.sh +\`\`\` + +This: + +1. starts the dev Postgres container and waits for it to report healthy (the script + owns the container's lifecycle); +2. creates the backend virtualenv and installs dependencies (first run only); +3. installs the frontend's npm dependencies (first run only); +4. runs the FastAPI backend on **:8000** — which connects to the empty database and + applies all pending migrations itself (INV-7) — and the Vite dev server on + **:5173**, proxying `/api` to the backend. + +Press **Ctrl-C** to stop the backend and Vite. The database container keeps running. + +### Verify + +\`\`\`bash +curl http://localhost:8000/healthz +\`\`\` + +Expected: \`{"status":"ok"}\` (HTTP 200) — the process is up, the database is reachable, +and migrations are current. Open to see the app shell. + +### Reset to empty (rehearse the bootstrap) + +\`\`\`bash +docker compose down -v +\`\`\` + +This removes the container and its named volume, returning persistence to empty. The +next \`scripts/dev.sh\` re-migrates from scratch — exactly the day-one state every +environment starts from (BUC-5a). + +### Configuration + +\`scripts/dev.sh\` sets \`ECOMM_DATABASE_URL\` to the local compose DSN +(\`postgresql://ecomm:ecomm@localhost:5432/ecomm\`). No deployment shape is baked into +the app (INV-8); deployed environments supply this and other config from Secret +Manager (SLICE-4). +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/BOOTSTRAP.md +git commit -m "docs(slice-1): BOOTSTRAP.md localhost bring-up runbook (BUC-5)" +``` + +--- + +## Task 11: End-to-end DoD verification + +**Files:** none (verification only). + +Prove the SLICE-1 Definition of Done: clean checkout → `dev.sh` → app up on an empty, +self-migrated Postgres; `/healthz` green; gate green. + +- [ ] **Step 1: Reset persistence to empty** + +Run from repo root: + +```bash +docker compose down -v +``` + +Expected: container and `ecomm-pgdata` volume removed. + +- [ ] **Step 2: Simulate a clean checkout's first run** + +To prove dev.sh provisions from nothing, temporarily move the venv and node_modules +aside, then run dev.sh in the background long enough to boot: + +```bash +mv .venv .venv.bak 2>/dev/null || true +mv frontend/node_modules frontend/node_modules.bak 2>/dev/null || true +scripts/dev.sh & +dev_pid=$! +sleep 60 # first run installs venv + npm deps + boots; adjust if slower +``` + +- [ ] **Step 3: Verify /healthz on the freshly bootstrapped, empty DB** + +```bash +curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8000/healthz +curl -s http://localhost:8000/healthz +``` + +Expected: `200` then `{"status":"ok"}`. + +- [ ] **Step 4: Verify the schema self-applied and the DB is otherwise empty** + +```bash +.venv/bin/python -c " +import psycopg +c = psycopg.connect('postgresql://ecomm:ecomm@localhost:5432/ecomm') +tables = {r[0] for r in c.execute(\"SELECT tablename FROM pg_tables WHERE schemaname='public'\").fetchall()} +print('tables:', sorted(tables)) +print('accounts:', c.execute('SELECT count(*) FROM account').fetchone()[0]) +print('migrations applied:', c.execute('SELECT count(*) FROM schema_migrations').fetchone()[0]) +c.close() +" +``` + +Expected: tables include `account`, `auth_code`, `storefront`, `storefront_membership`, +`schema_migrations`; `accounts: 0` (empty is a working state, INV-1); `migrations +applied: 1`. + +- [ ] **Step 5: Stop dev.sh** + +```bash +kill "$dev_pid" 2>/dev/null || true +``` + +- [ ] **Step 6: Run the full gate green** + +```bash +scripts/check.sh +``` + +Expected: `all gates green`. + +- [ ] **Step 7: Confirm the DoD checklist** + +- [ ] clean checkout → `scripts/dev.sh` → app up on an empty, self-migrated DB ✓ +- [ ] `/healthz` green ✓ +- [ ] `scripts/check.sh` gate green (the CI workflow runs this exact script) ✓ +- [ ] bootstrap test skeleton: migrate-from-empty + idempotent re-migrate green + (`test_migrations.py`) — INV-1 partial ✓ + +- [ ] **Step 8: Update the top-level README status** + +Modify `README.md` — replace the `## Status` section's body: + +```markdown +## Status + +SLICE-1 (walking skeleton) of SD-0001 is in place: a four-layer FastAPI backend that +self-migrates an empty PostgreSQL database at startup and serves `/healthz`, a +Vite/React shell, the `scripts/check.sh` gate, and the dev container lifecycle +(`scripts/dev.sh`). See [`docs/BOOTSTRAP.md`](./docs/BOOTSTRAP.md) to run it locally. +Identity (SLICE-2) and the storefront (SLICE-3) are next. +``` + +- [ ] **Step 9: Commit** + +```bash +git add README.md +git commit -m "docs(slice-1): README status — walking skeleton in place" +``` + +--- + +## Done criteria (whole plan) + +- `scripts/check.sh` is green: import-linter contract kept, backend pytest green + against real Postgres, frontend typecheck + build succeed. +- `scripts/dev.sh` from a clean checkout brings up Postgres, boots the self-migrating + backend, and serves `200 {"status":"ok"}` at `/healthz` on an empty database. +- The §6.3 schema is laid by `migrations/0001_init.sql` and the INV-4 many-capable + shape is asserted by test. +- `docs/BOOTSTRAP.md` documents the localhost gesture; `.gitea/workflows/ci.yml` runs + the gate (inert until a runner is registered). +- All work committed on `claude/agitated-heyrovsky-1a1f47`, ready for PR → `main`. +``` -- 2.52.0 From 4acab40653e2b38b280a35bd9f1a5afcdb9a13b3 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 08:34:38 -0700 Subject: [PATCH 02/13] feat(slice-1): backend skeleton, deps, and import-linter layer contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four-layer package (main > domains > platform) per SD-0001 §6.2; GraphQL api layer deferred. main.py is a stub so the top layer exists from the start (fleshed out in the /healthz task). lint-imports green on the skeleton. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 +++ backend/.importlinter | 14 ++++++++++++++ backend/app/__init__.py | 0 backend/app/domains/__init__.py | 6 ++++++ backend/app/main.py | 5 +++++ backend/app/platform/__init__.py | 5 +++++ backend/pytest.ini | 7 +++++++ backend/requirements.txt | 7 +++++++ 8 files changed, 47 insertions(+) create mode 100644 backend/.importlinter create mode 100644 backend/app/__init__.py create mode 100644 backend/app/domains/__init__.py create mode 100644 backend/app/main.py create mode 100644 backend/app/platform/__init__.py create mode 100644 backend/pytest.ini create mode 100644 backend/requirements.txt diff --git a/.gitignore b/.gitignore index 20f0bd7..5f8b213 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ node_modules/ dist/ .env *.local +.pytest_cache/ +frontend/dist/ +backend/var/ diff --git a/backend/.importlinter b/backend/.importlinter new file mode 100644 index 0000000..2312042 --- /dev/null +++ b/backend/.importlinter @@ -0,0 +1,14 @@ +# Enforces SD-0001 §6.2: the four-layer contract main > domains > platform, with +# imports flowing only downward. The GraphQL `api` layer is deferred (§2), so the +# MVP contract has three layers; `app.api` is added back between main and domains +# when GraphQL arrives. Run from backend/: .venv/bin/lint-imports +[importlinter] +root_package = app + +[importlinter:contract:layers] +name = App layering (main > domains > platform) +type = layers +layers = + app.main + app.domains + app.platform diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/domains/__init__.py b/backend/app/domains/__init__.py new file mode 100644 index 0000000..ed76d05 --- /dev/null +++ b/backend/app/domains/__init__.py @@ -0,0 +1,6 @@ +"""domains layer — bounded contexts (accounts, storefronts). + +Empty in SLICE-1; the accounts domain lands in SLICE-2 and storefronts in SLICE-3. +The package exists now so the layer contract (.importlinter) has a target and later +slices add to a stable seam. Domains import only from app.platform, never upward. +""" diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..987c786 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,5 @@ +"""ecomm backend — FastAPI app factory + the REST BFF. + +Stub in SLICE-1 Task 1 so the top layer of the import-linter contract exists from the +start; the FastAPI factory and /healthz are added in Task 5. +""" diff --git a/backend/app/platform/__init__.py b/backend/app/platform/__init__.py new file mode 100644 index 0000000..11523dd --- /dev/null +++ b/backend/app/platform/__init__.py @@ -0,0 +1,5 @@ +"""platform layer — cross-cutting primitives (config, db, deps). + +The bottom layer: imports nothing from app.domains or app.main. Owns the schema +lifecycle and request wiring, never business rules (SD-0001 §6.2). +""" diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..0a80e15 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +testpaths = tests +python_files = test_*.py +addopts = -q +# Put backend/ (this rootdir) on sys.path so tests import the `app` package under +# pytest's default prepend import mode (tests/ has no __init__.py). +pythonpath = . diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..ad1b99c --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.110 +uvicorn[standard]>=0.29 +httpx>=0.27 +psycopg[binary]>=3.1 +psycopg-pool>=3.2 +pytest>=8.0 +import-linter>=2.0 -- 2.52.0 From 346e7f2e2248c0b619e40c0ada086229ffd8c069 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 08:35:16 -0700 Subject: [PATCH 03/13] =?UTF-8?q?feat(slice-1):=20platform/config=20?= =?UTF-8?q?=E2=80=94=20single=20env-driven=20config=20surface=20(INV-8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/platform/config.py | 19 +++++++++++++++++++ backend/tests/test_config.py | 13 +++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 backend/app/platform/config.py create mode 100644 backend/tests/test_config.py diff --git a/backend/app/platform/config.py b/backend/app/platform/config.py new file mode 100644 index 0000000..ccf7914 --- /dev/null +++ b/backend/app/platform/config.py @@ -0,0 +1,19 @@ +"""Configuration surface — the single place deployment/environment config is read. + +INV-8: no deployment shape is baked into framework code. Every URL, credential, or +relay coordinate arrives through here from the environment (resolved from Secret +Manager in deployed environments). The localhost defaults match compose.yaml so a +clean `scripts/dev.sh` checkout needs no env setup. +""" +from __future__ import annotations + +import os + +# Dev default points at the single Postgres container compose.yaml brings up. In +# PPE/Prod the deployment supplies ECOMM_DATABASE_URL from Secret Manager (INV-8). +_DEFAULT_DATABASE_URL = "postgresql://ecomm:ecomm@localhost:5432/ecomm" + + +def database_url() -> str: + """The psycopg DSN for the application database.""" + return os.environ.get("ECOMM_DATABASE_URL") or _DEFAULT_DATABASE_URL diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py new file mode 100644 index 0000000..6168d17 --- /dev/null +++ b/backend/tests/test_config.py @@ -0,0 +1,13 @@ +import os + +from app.platform import config + + +def test_database_url_defaults_to_local_compose(monkeypatch): + monkeypatch.delenv("ECOMM_DATABASE_URL", raising=False) + assert config.database_url() == "postgresql://ecomm:ecomm@localhost:5432/ecomm" + + +def test_database_url_honors_env(monkeypatch): + monkeypatch.setenv("ECOMM_DATABASE_URL", "postgresql://x:y@db:5432/z") + assert config.database_url() == "postgresql://x:y@db:5432/z" -- 2.52.0 From ab4317e1055bba4eb81b045003c6bc03d4043214 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 08:35:53 -0700 Subject: [PATCH 04/13] =?UTF-8?q?feat(slice-1):=20dev=20Postgres=20compose?= =?UTF-8?q?=20=E2=80=94=20single=20pinned=20service,=20named=20volume=20(D?= =?UTF-8?q?-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- compose.yaml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 compose.yaml diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..4c93c46 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,26 @@ +# Dev datastore for ecomm — a single pinned PostgreSQL service (SD-0001 D-7). +# The app runs natively (fast dev loop); only the database is containerized. +# `scripts/dev.sh` owns this container's lifecycle. Reset to empty (to rehearse the +# bootstrap, BUC-5) with: docker compose down -v +name: ecomm-dev + +services: + db: + # Pinned to the Cloud SQL major version ecomm targets in PPE/Prod (D-7). + image: postgres:16 + environment: + POSTGRES_USER: ecomm + POSTGRES_PASSWORD: ecomm + POSTGRES_DB: ecomm + ports: + - "5432:5432" + volumes: + - ecomm-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ecomm -d ecomm"] + interval: 2s + timeout: 3s + retries: 30 + +volumes: + ecomm-pgdata: -- 2.52.0 From 94624236427f308cde9fb38359429dfee2c045df Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 08:37:09 -0700 Subject: [PATCH 05/13] =?UTF-8?q?feat(slice-1):=20platform/db=20migration?= =?UTF-8?q?=20runner=20+=20migration=200001=20(INV-7,=20=C2=A76.3=20schema?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forward-only, fail-stop, advisory-lock-guarded psycopg runner; migration 0001 lays the account/auth_code/storefront/membership schema. Tests prove migrate-from-empty and idempotent re-migrate (INV-1 partial) and the INV-4 many-capable shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/platform/db.py | 87 ++++++++++++++++++++++++++++++++ backend/migrations/0001_init.sql | 48 ++++++++++++++++++ backend/tests/conftest.py | 47 +++++++++++++++++ backend/tests/test_migrations.py | 43 ++++++++++++++++ 4 files changed, 225 insertions(+) create mode 100644 backend/app/platform/db.py create mode 100644 backend/migrations/0001_init.sql create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_migrations.py diff --git a/backend/app/platform/db.py b/backend/app/platform/db.py new file mode 100644 index 0000000..2399927 --- /dev/null +++ b/backend/app/platform/db.py @@ -0,0 +1,87 @@ +"""PostgreSQL access (psycopg 3) + a forward-only migration runner. + +Standard wiggleverse stack ported to Postgres (SD-0001 D-7): a connection pool and +numbered forward-only `.sql` migrations applied in filename order, recorded in +`schema_migrations`. No ORM. The runner is fail-stop (a failing migration aborts and +records nothing, so it retries next boot) and guarded by a session advisory lock so +two booting processes never double-apply (INV-7). +""" +from __future__ import annotations + +from pathlib import Path + +import psycopg +from psycopg_pool import ConnectionPool + +from . import config + +# app/platform/db.py -> parents[2] is backend/, where migrations/ lives. +MIGRATIONS_DIR = Path(__file__).resolve().parents[2] / "migrations" + +# Arbitrary fixed key for the migration advisory lock (INV-7 concurrency guard). +_MIGRATION_LOCK_KEY = 0x6563_6F6D_6D31 # "ecomm1" + + +def open_pool(dsn: str | None = None, *, min_size: int = 1, max_size: int = 5) -> ConnectionPool: + """Open a small connection pool against the configured database.""" + pool = ConnectionPool(dsn or config.database_url(), min_size=min_size, max_size=max_size, open=False) + pool.open() + pool.wait() + return pool + + +def _migration_files(migrations_dir: Path) -> list[Path]: + return sorted(migrations_dir.glob("*.sql")) + + +def migrate(conn: psycopg.Connection, migrations_dir: Path = MIGRATIONS_DIR) -> list[str]: + """Apply unapplied migrations in filename order; return the names applied. + + Forward-only and fail-stop: each migration runs with its `schema_migrations` + insert in one transaction, so a failure records nothing and is retried next time. + A session advisory lock serializes concurrent migrators (INV-7). + """ + conn.execute("SELECT pg_advisory_lock(%s)", (_MIGRATION_LOCK_KEY,)) + try: + conn.execute( + "CREATE TABLE IF NOT EXISTS schema_migrations (" + " filename TEXT PRIMARY KEY," + " applied_at TIMESTAMPTZ NOT NULL DEFAULT now())" + ) + conn.commit() + applied = { + r[0] for r in conn.execute("SELECT filename FROM schema_migrations").fetchall() + } + ran: list[str] = [] + for path in _migration_files(migrations_dir): + if path.name in applied: + continue + try: + conn.execute(path.read_text()) # whole file; no params -> multi-statement + conn.execute( + "INSERT INTO schema_migrations (filename) VALUES (%s)", (path.name,) + ) + conn.commit() + except Exception: + conn.rollback() + raise + ran.append(path.name) + return ran + finally: + conn.execute("SELECT pg_advisory_unlock(%s)", (_MIGRATION_LOCK_KEY,)) + conn.commit() + + +def pending_migrations(conn: psycopg.Connection, migrations_dir: Path = MIGRATIONS_DIR) -> list[str]: + """Migration filenames present on disk but not yet recorded as applied. + + Used by /healthz to report migration currency (§6.4). Returns all on-disk + migrations if the tracking table does not exist yet. + """ + exists = conn.execute("SELECT to_regclass('public.schema_migrations')").fetchone()[0] + if exists is None: + return [p.name for p in _migration_files(migrations_dir)] + applied = { + r[0] for r in conn.execute("SELECT filename FROM schema_migrations").fetchall() + } + return [p.name for p in _migration_files(migrations_dir) if p.name not in applied] diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql new file mode 100644 index 0000000..ca363d7 --- /dev/null +++ b/backend/migrations/0001_init.sql @@ -0,0 +1,48 @@ +-- 0001_init.sql — SD-0001 §6.3 data model. Forward-only and fail-stop (INV-7): +-- do NOT edit this file once it has merged/applied; add a new numbered migration. + +-- account — a person's identity on the platform. Email is the canonical key (INV-2); +-- the address is stored already-normalized (lowercased) by the service layer, so a +-- plain unique index enforces one-account-per-email. Data minimization (OHM): email +-- and nothing else (§6.3). +CREATE TABLE account ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX account_email_key ON account (email); -- INV-2 + +-- auth_code — one-time codes, handled like secrets (INV-3): only the hash is stored; +-- keyed by email (the code is issued before the account may exist — sign-up and +-- log-in converge, PUC-3). +CREATE TABLE auth_code ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email TEXT NOT NULL, + code_hash TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + consumed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX auth_code_email_idx ON auth_code (email); + +-- storefront — a merchant's business presence. Name is always populated (a blank +-- entry gets a generated default at the service layer, §6.3); "unnamed" is never NULL. +CREATE TABLE storefront ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- storefront_membership — account <-> storefront ownership. INV-4: deliberately a +-- many-to-many shape (composite PK, no UNIQUE(account_id)) so many-storefronts-per- +-- account stays open; the one-storefront rule is a deletable service-layer guard, not +-- a schema law. INV-5: every storefront-scoped row carries its storefront_id. `role` +-- is a single-value enum today; it is the seam the staff/permissions model attaches to. +CREATE TABLE storefront_membership ( + account_id BIGINT NOT NULL REFERENCES account (id), + storefront_id BIGINT NOT NULL REFERENCES storefront (id), + role TEXT NOT NULL DEFAULT 'owner' CHECK (role IN ('owner')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (account_id, storefront_id) +); diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..dcf010b --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,47 @@ +"""Test fixtures — a fresh, empty PostgreSQL database per test. + +Each test gets its own database created on the dev/CI Postgres (every test runs the +real engine, §6.8). The admin DSN points at the `postgres` maintenance database; +ECOMM_TEST_ADMIN_URL overrides it (CI sets it to the service container). +""" +from __future__ import annotations + +import os +import uuid + +import psycopg +import pytest + +ADMIN_URL = os.environ.get( + "ECOMM_TEST_ADMIN_URL", "postgresql://ecomm:ecomm@localhost:5432/postgres" +) + + +def _swap_dbname(dsn: str, dbname: str) -> str: + # ADMIN_URL ends in `/postgres`; point the same credentials at `dbname`. + base, _, _old = dsn.rpartition("/") + return f"{base}/{dbname}" + + +@pytest.fixture() +def fresh_db_url(): + """Create a uniquely-named empty database; drop it after the test.""" + dbname = f"ecomm_test_{uuid.uuid4().hex}" + admin = psycopg.connect(ADMIN_URL, autocommit=True) + try: + admin.execute(f'CREATE DATABASE "{dbname}"') + finally: + admin.close() + try: + yield _swap_dbname(ADMIN_URL, dbname) + finally: + admin = psycopg.connect(ADMIN_URL, autocommit=True) + try: + admin.execute( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " + "WHERE datname = %s AND pid <> pg_backend_pid()", + (dbname,), + ) + admin.execute(f'DROP DATABASE IF EXISTS "{dbname}"') + finally: + admin.close() diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py new file mode 100644 index 0000000..81e9945 --- /dev/null +++ b/backend/tests/test_migrations.py @@ -0,0 +1,43 @@ +import psycopg + +from app.platform import db + +_TABLES = {"account", "auth_code", "storefront", "storefront_membership"} + + +def _table_names(conn) -> set[str]: + rows = conn.execute( + "SELECT tablename FROM pg_tables WHERE schemaname = 'public'" + ).fetchall() + return {r[0] for r in rows} + + +def test_migrate_from_empty_applies_0001(fresh_db_url): + with psycopg.connect(fresh_db_url) as conn: + applied = db.migrate(conn) + assert applied == ["0001_init.sql"] + with psycopg.connect(fresh_db_url) as conn: + assert _TABLES.issubset(_table_names(conn)) + + +def test_migrate_is_idempotent(fresh_db_url): + with psycopg.connect(fresh_db_url) as conn: + db.migrate(conn) + with psycopg.connect(fresh_db_url) as conn: + applied_again = db.migrate(conn) + assert applied_again == [] + + +def test_membership_has_no_unique_account_constraint(fresh_db_url): + # INV-4: the schema must keep many-storefronts-per-account open — no UNIQUE on + # storefront_membership.account_id anywhere. + with psycopg.connect(fresh_db_url) as conn: + db.migrate(conn) + rows = conn.execute( + """ + SELECT indexdef FROM pg_indexes + WHERE schemaname = 'public' AND tablename = 'storefront_membership' + """ + ).fetchall() + defs = " ".join(r[0] for r in rows).lower() + assert "unique" not in defs.replace("primary key", "") or "(account_id)" not in defs -- 2.52.0 From ef385340e8ad1edab19757b038e3544e351506d2 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 08:38:42 -0700 Subject: [PATCH 06/13] =?UTF-8?q?feat(slice-1):=20FastAPI=20factory=20+=20?= =?UTF-8?q?/healthz;=20self-migrate=20at=20startup=20(INV-1/=C2=A76.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/main.py | 50 +++++++++++++++++++++++++++++++++-- backend/app/platform/deps.py | 18 +++++++++++++ backend/tests/test_healthz.py | 24 +++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 backend/app/platform/deps.py create mode 100644 backend/tests/test_healthz.py diff --git a/backend/app/main.py b/backend/app/main.py index 987c786..2386454 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,51 @@ """ecomm backend — FastAPI app factory + the REST BFF. -Stub in SLICE-1 Task 1 so the top layer of the import-linter contract exists from the -start; the FastAPI factory and /healthz are added in Task 5. +SLICE-1 mounts only /healthz; the auth and storefront endpoints (§6.4) grow here in +later slices. create_app() opens the connection pool and self-migrates the database +at startup (INV-1, INV-7): the app boots against empty persistence and applies its +own schema — there is no seed step. """ +from __future__ import annotations + +from contextlib import asynccontextmanager + +import psycopg +from fastapi import Depends, FastAPI, Response + +from app.platform import config, db +from app.platform.deps import get_conn + + +def create_app(database_url: str | None = None) -> FastAPI: + dsn = database_url or config.database_url() + + @asynccontextmanager + async def lifespan(app: FastAPI): + app.state.pool = db.open_pool(dsn) + with app.state.pool.connection() as conn: + db.migrate(conn) # self-migrate at startup (INV-1, INV-7) + try: + yield + finally: + app.state.pool.close() + + app = FastAPI(title="ecomm", version="0.1", lifespan=lifespan) + + @app.get("/healthz") + def healthz(response: Response, conn: psycopg.Connection = Depends(get_conn)): + """Liveness + readiness: process up, DB reachable, migrations current (§6.4).""" + try: + conn.execute("SELECT 1") + pending = db.pending_migrations(conn) + except Exception: + response.status_code = 503 + return {"status": "unavailable", "reason": "database_unreachable"} + if pending: + response.status_code = 503 + return {"status": "unavailable", "reason": "migrations_pending"} + return {"status": "ok"} + + return app + + +app = create_app() diff --git a/backend/app/platform/deps.py b/backend/app/platform/deps.py new file mode 100644 index 0000000..36cdfca --- /dev/null +++ b/backend/app/platform/deps.py @@ -0,0 +1,18 @@ +"""FastAPI dependencies — per-request wiring (SD-0001 §6.2 platform/deps). + +Yields a pooled connection per request. The pool lives on app.state, built once in +create_app(). Later slices add the current-session and mailer dependencies here. +""" +from __future__ import annotations + +from collections.abc import Iterator + +import psycopg +from fastapi import Request + + +def get_conn(request: Request) -> Iterator[psycopg.Connection]: + """A pooled connection for the duration of one request.""" + pool = request.app.state.pool + with pool.connection() as conn: + yield conn diff --git a/backend/tests/test_healthz.py b/backend/tests/test_healthz.py new file mode 100644 index 0000000..0dc58ba --- /dev/null +++ b/backend/tests/test_healthz.py @@ -0,0 +1,24 @@ +from fastapi.testclient import TestClient + +from app.main import create_app + + +def test_healthz_ok_on_migrated_empty_db(fresh_db_url): + app = create_app(database_url=fresh_db_url) + with TestClient(app) as client: + resp = client.get("/healthz") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + +def test_startup_migrates_from_empty(fresh_db_url): + # create_app on an empty DB must self-migrate so /healthz reports current. + app = create_app(database_url=fresh_db_url) + with TestClient(app) as client: + assert client.get("/healthz").status_code == 200 + # the schema_migrations row exists -> migrations ran at startup + import psycopg + + with psycopg.connect(fresh_db_url) as conn: + n = conn.execute("SELECT count(*) FROM schema_migrations").fetchone()[0] + assert n >= 1 -- 2.52.0 From d7b9f81e53a857389b312931141740fce9561548 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 08:39:14 -0700 Subject: [PATCH 07/13] =?UTF-8?q?feat(slice-1):=20scripts/check.sh=20?= =?UTF-8?q?=E2=80=94=20the=20single=20gate=20(lint-imports,=20pytest,=20fr?= =?UTF-8?q?ontend=20build)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/check.sh | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100755 scripts/check.sh diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..cf6d605 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# The single pre-merge gate for wiggleverse-ecomm (SD-0001 §6.8). Runs, in order: +# 1. the import-linter layering contract (main > domains > platform) +# 2. the backend test suite (pytest, against Postgres) +# 3. the frontend typecheck + production build +# CI (.gitea/workflows/ci.yml) calls this exact script, so local and server gates +# cannot drift. Exits non-zero on the first failure. +# +# Requires a reachable Postgres for the backend tests: locally the compose service +# (scripts/dev.sh brings it up); in CI a Postgres service container. Override its +# admin DSN with ECOMM_TEST_ADMIN_URL. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Pick an interpreter: explicit $PYTHON (CI), else the repo venv, else PATH. +if [ -n "${PYTHON:-}" ]; then + PY="$PYTHON" +elif [ -x "$repo_root/.venv/bin/python" ]; then + PY="$repo_root/.venv/bin/python" +else + PY="$(command -v python3 || command -v python)" +fi + +lint="$(dirname "$PY")/lint-imports" +[ -x "$lint" ] || lint="lint-imports" + +echo "==> import boundaries (lint-imports)" +( cd "$repo_root/backend" && "$lint" ) + +echo "==> backend tests (pytest)" +( cd "$repo_root/backend" && "$PY" -m pytest -q ) + +echo "==> frontend typecheck + build" +( cd "$repo_root/frontend" && npm run build ) + +echo "==> all gates green" -- 2.52.0 From 36046304da08d885e3c538a51c1db2d24520ea3b Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 08:39:37 -0700 Subject: [PATCH 08/13] =?UTF-8?q?feat(slice-1):=20scripts/dev.sh=20?= =?UTF-8?q?=E2=80=94=20owns=20dev=20Postgres=20lifecycle=20+=20backend=20+?= =?UTF-8?q?=20Vite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/dev.sh | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100755 scripts/dev.sh diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..5b76cab --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Local bring-up for ecomm (SD-0001 PUC-10). From a clean checkout this: +# 1. starts the dev Postgres container and waits for it to be healthy (owns its +# lifecycle — Docker is the one prerequisite, docs/BOOTSTRAP.md); +# 2. ensures the backend venv + deps and the frontend node_modules; +# 3. runs the FastAPI backend (:8000, self-migrating an empty DB) and the Vite dev +# server (:5173, proxying /api -> :8000) together. +# Ctrl-C stops both processes; the container keeps running (stop it with +# `docker compose down`, or `docker compose down -v` to reset to empty). +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +export ECOMM_DATABASE_URL="${ECOMM_DATABASE_URL:-postgresql://ecomm:ecomm@localhost:5432/ecomm}" + +echo "==> dev Postgres (docker compose up --wait)" +docker compose up -d --wait db + +if [ ! -x "$repo_root/.venv/bin/python" ]; then + echo "==> creating backend venv" + python3 -m venv "$repo_root/.venv" + "$repo_root/.venv/bin/python" -m pip install --upgrade pip + "$repo_root/.venv/bin/python" -m pip install -r "$repo_root/backend/requirements.txt" +fi + +if [ ! -d "$repo_root/frontend/node_modules" ]; then + echo "==> installing frontend deps" + ( cd "$repo_root/frontend" && npm install ) +fi + +echo "==> starting backend (:8000) and Vite (:5173) — Ctrl-C to stop" +"$repo_root/.venv/bin/python" -m uvicorn app.main:app --app-dir "$repo_root/backend" --port 8000 --reload & +backend_pid=$! +( cd "$repo_root/frontend" && npm run dev ) & +frontend_pid=$! + +cleanup() { + echo + echo "==> stopping backend and Vite" + kill "$backend_pid" "$frontend_pid" 2>/dev/null || true + wait "$backend_pid" "$frontend_pid" 2>/dev/null || true +} +trap cleanup INT TERM + +wait -n "$backend_pid" "$frontend_pid" +cleanup -- 2.52.0 From 6744a6ae0d5cc2db32170684cbce487e98562f21 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 08:41:10 -0700 Subject: [PATCH 09/13] feat(slice-1): Vite/React shell + /api proxy; scripts/check.sh now fully green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single tsconfig (no project-references split — avoids composite/noEmit friction for the minimal shell); tsc typechecks src, vite loads its config via esbuild. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/index.html | 12 + frontend/package-lock.json | 1771 ++++++++++++++++++++++++++++++++++++ frontend/package.json | 22 + frontend/src/App.tsx | 10 + frontend/src/main.tsx | 9 + frontend/tsconfig.json | 17 + frontend/vite.config.ts | 18 + 7 files changed, 1859 insertions(+) create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1a13c4f --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + ecomm + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..b359af8 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1771 @@ +{ + "name": "wiggleverse-ecomm-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wiggleverse-ecomm-frontend", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "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/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.35", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", + "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "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/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.371", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", + "integrity": "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==", + "dev": true, + "license": "ISC" + }, + "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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "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/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..aa3eb07 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "wiggleverse-ecomm-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..fa006dd --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,10 @@ +// SLICE-1 shell only — the Landing, Sign-in, Create-storefront, and Admin screens +// (SD-0001 §5) land in SLICE-2/3. This proves the build and dev server work. +export default function App() { + return ( +
+

ecomm

+

Honest commerce. Your storefront is yours.

+
+ ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..f8fc6f5 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..42e0521 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..e74f09e --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// The SPA talks to the FastAPI backend on :8000 via a same-origin proxy, so the +// browser makes no cross-origin calls in dev. Override the target with +// VITE_API_TARGET when :8000 is occupied. Screen-shaped /api/* endpoints arrive in +// SLICE-2/3 (SD-0001 §6.4). +const apiTarget = process.env.VITE_API_TARGET || "http://localhost:8000"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": apiTarget, + }, + }, +}); -- 2.52.0 From 886da73eb666aec27028a37060b6e66d52670403 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 08:41:32 -0700 Subject: [PATCH 10/13] ci(slice-1): Gitea workflow runs scripts/check.sh against a Postgres service Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/ci.yml | 57 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..4c4a2bc --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,57 @@ +# Server-side pre-merge gate. Calls the same scripts/check.sh that runs locally, so +# the local and server gates can never drift: import-linter (main > domains > +# platform), then backend pytest against a Postgres service, then the frontend +# typecheck + build. Requires a registered Gitea Actions runner advertising the +# `ubuntu-latest` label. +name: ci + +on: + push: + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: ecomm + POSTGRES_PASSWORD: ecomm + POSTGRES_DB: ecomm + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U ecomm" + --health-interval 2s + --health-timeout 3s + --health-retries 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install backend deps + run: | + python -m venv .venv + .venv/bin/python -m pip install --upgrade pip + .venv/bin/python -m pip install -r backend/requirements.txt + + - name: Install frontend deps + run: | + cd frontend && npm ci + + - name: Run the pre-merge gate + env: + PYTHON: ${{ github.workspace }}/.venv/bin/python + ECOMM_TEST_ADMIN_URL: postgresql://ecomm:ecomm@localhost:5432/postgres + run: scripts/check.sh -- 2.52.0 From cd951cca2cb65d796047bbfb2d4f301ff90c08c3 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 08:42:00 -0700 Subject: [PATCH 11/13] docs(slice-1): BOOTSTRAP.md localhost bring-up runbook (BUC-5) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/BOOTSTRAP.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/BOOTSTRAP.md diff --git a/docs/BOOTSTRAP.md b/docs/BOOTSTRAP.md new file mode 100644 index 0000000..0d0c503 --- /dev/null +++ b/docs/BOOTSTRAP.md @@ -0,0 +1,64 @@ +# Bootstrapping ecomm + +Bringing an environment from **empty persistence** to "first merchant, first +storefront" through the product flows alone (SD-0001 BUC-5). Empty is a working state +(INV-1): the app applies its own schema migrations at startup; there is no seed step. + +This document grows per environment. SLICE-1 ships the **localhost** section; the +pre-production and production sections land with SLICE-4. + +## Localhost + +### Prerequisites + +- **Docker** (Desktop or Engine) with Compose v2 — the one external prerequisite. The + dev datastore is a single PostgreSQL container; the app itself runs natively for a + fast dev loop (D-7). +- **Python 3.13** and **Node 20+** on your PATH. `scripts/dev.sh` creates the backend + venv and installs frontend deps on first run. + +### Bring-up + +From a clean checkout: + +```bash +scripts/dev.sh +``` + +This: + +1. starts the dev Postgres container and waits for it to report healthy (the script + owns the container's lifecycle); +2. creates the backend virtualenv and installs dependencies (first run only); +3. installs the frontend's npm dependencies (first run only); +4. runs the FastAPI backend on **:8000** — which connects to the empty database and + applies all pending migrations itself (INV-7) — and the Vite dev server on + **:5173**, proxying `/api` to the backend. + +Press **Ctrl-C** to stop the backend and Vite. The database container keeps running. + +### Verify + +```bash +curl http://localhost:8000/healthz +``` + +Expected: `{"status":"ok"}` (HTTP 200) — the process is up, the database is reachable, +and migrations are current. Open to see the app shell. + +### Reset to empty (rehearse the bootstrap) + +```bash +docker compose down -v +``` + +This removes the container and its named volume, returning persistence to empty. The +next `scripts/dev.sh` re-migrates from scratch — exactly the day-one state every +environment starts from (BUC-5a). + +### Configuration + +`scripts/dev.sh` sets `ECOMM_DATABASE_URL` to the local compose DSN +(`postgresql://ecomm:ecomm@localhost:5432/ecomm`). No deployment shape is baked into +the app (INV-8); deployed environments supply this and other config from Secret +Manager (SLICE-4). -- 2.52.0 From 4d1c957f420cd0da534d419890d8e54665cb001c Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 08:46:41 -0700 Subject: [PATCH 12/13] =?UTF-8?q?fix(slice-1):=20dev.sh=20portable=20on=20?= =?UTF-8?q?bash=203.2=20=E2=80=94=20drop=20wait=20-n,=20reap=20process=20s?= =?UTF-8?q?ubtree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS ships bash 3.2 (no wait -n): poll with kill -0 instead. npm spawns vite as a grandchild, so cleanup now recurses pgrep -P to reap the whole subtree — Ctrl-C frees both :8000 and :5173 cleanly. Verified clean-checkout -> dev.sh -> empty self-migrated Postgres -> /healthz 200, and trap teardown. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/dev.sh | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/scripts/dev.sh b/scripts/dev.sh index 5b76cab..c1adbfd 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -35,13 +35,30 @@ backend_pid=$! ( cd "$repo_root/frontend" && npm run dev ) & frontend_pid=$! +# Kill a process and its whole descendant tree (children first), portably across +# macOS and Linux — npm spawns vite as a grandchild, so killing the captured pid +# alone would orphan it on :5173. `pgrep -P` (list children) exists on both. +kill_tree() { + local pid=$1 child + for child in $(pgrep -P "$pid" 2>/dev/null); do + kill_tree "$child" + done + kill "$pid" 2>/dev/null || true +} + cleanup() { + trap - INT TERM echo echo "==> stopping backend and Vite" - kill "$backend_pid" "$frontend_pid" 2>/dev/null || true + kill_tree "$backend_pid" + kill_tree "$frontend_pid" wait "$backend_pid" "$frontend_pid" 2>/dev/null || true } trap cleanup INT TERM -wait -n "$backend_pid" "$frontend_pid" +# Wait until either process exits, then tear the other down. `wait -n` would be +# tidier but is unsupported on macOS's bundled bash 3.2, so poll portably. +while kill -0 "$backend_pid" 2>/dev/null && kill -0 "$frontend_pid" 2>/dev/null; do + sleep 1 +done cleanup -- 2.52.0 From f184802f1fc6993d0ad2dda38c6dd0d0a258f4d7 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 08:47:28 -0700 Subject: [PATCH 13/13] =?UTF-8?q?docs(slice-1):=20README=20status=20?= =?UTF-8?q?=E2=80=94=20walking=20skeleton=20in=20place?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cd1c4c8..30fcc93 100644 --- a/README.md +++ b/README.md @@ -20,5 +20,8 @@ This app (One Name `ecomm`, see [`app.json`](./app.json)) is composed of: ## Status -No application code yet. The architecture and first release are to be designed in -a brainstorming/planning session before any code lands. +SLICE-1 (walking skeleton) of SD-0001 is in place: a four-layer FastAPI backend that +self-migrates an empty PostgreSQL database at startup and serves `/healthz`, a +Vite/React shell, the `scripts/check.sh` gate, and the dev container lifecycle +(`scripts/dev.sh`). See [`docs/BOOTSTRAP.md`](./docs/BOOTSTRAP.md) to run it locally. +Identity (SLICE-2) and the storefront (SLICE-3) are next. -- 2.52.0