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`. +```