d125317c00
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>
1479 lines
46 KiB
Markdown
1479 lines
46 KiB
Markdown
# 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
|
||
<!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`:
|
||
|
||
```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`:
|
||
|
||
```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:
|
||
|
||
```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 <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**
|
||
|
||
```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`.
|
||
```
|