Files
wiggleverse-ecomm/docs/superpowers/plans/2026-06-10-slice-1-walking-skeleton.md
ben.stull d125317c00 plan(slice-1): SD-0001 walking-skeleton implementation plan
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) <noreply@anthropic.com>
2026-06-10 08:32:28 -07:00

46 KiB
Raw Permalink Blame History

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-linterenforced; 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:

"""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:

"""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:

# 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:

[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:

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:

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
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:

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:

"""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
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:

# 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:

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:

.venv/bin/python -c "import psycopg; psycopg.connect('postgresql://ecomm:ecomm@localhost:5432/ecomm').close(); print('connected')"

Expected: connected.

  • Step 4: Commit
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:

"""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:

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:

"""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:

-- 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
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:

"""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:

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:

"""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/:

../.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):

../.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
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:

#!/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
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:

( cd backend && ../.venv/bin/lint-imports && ../.venv/bin/python -m pytest -q )

Expected: contract kept + all backend tests pass.

  • Step 4: Commit
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:

#!/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
chmod +x scripts/dev.sh
  • Step 3: Commit (verified end-to-end in Task 10)
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:

{
  "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:

{
  "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:

{
  "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:

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:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>ecomm</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Create frontend/src/main.tsx:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

Create frontend/src/App.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 (
    <main>
      <h1>ecomm</h1>
      <p>Honest commerce. Your storefront is yours.</p>
    </main>
  );
}
  • Step 5: Install deps (generates the committed lockfile) and verify the build

Run from repo root:

( 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:

scripts/check.sh

Expected: import boundaries kept → backend tests pass → frontend build succeeds → all gates green.

  • Step 7: Commit
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:

# 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
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:

# 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 <http://localhost:5173> 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
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:

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:

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
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
.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
kill "$dev_pid" 2>/dev/null || true
  • Step 6: Run the full gate green
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:

## 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
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.