Merge pull request 'SLICE-1: walking skeleton (SD-0001 §7.2) — scaffold, dev Postgres, migration runner, /healthz' (#5) from claude/agitated-heyrovsky-1a1f47 into main
ci / check (push) Has been cancelled
ci / check (push) Has been cancelled
This commit was merged in pull request #5.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# Server-side pre-merge gate. Calls the same scripts/check.sh that runs locally, so
|
||||
# the local and server gates can never drift: import-linter (main > domains >
|
||||
# platform), then backend pytest against a Postgres service, then the frontend
|
||||
# typecheck + build. Requires a registered Gitea Actions runner advertising the
|
||||
# `ubuntu-latest` label.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: ecomm
|
||||
POSTGRES_PASSWORD: ecomm
|
||||
POSTGRES_DB: ecomm
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U ecomm"
|
||||
--health-interval 2s
|
||||
--health-timeout 3s
|
||||
--health-retries 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install backend deps
|
||||
run: |
|
||||
python -m venv .venv
|
||||
.venv/bin/python -m pip install --upgrade pip
|
||||
.venv/bin/python -m pip install -r backend/requirements.txt
|
||||
|
||||
- name: Install frontend deps
|
||||
run: |
|
||||
cd frontend && npm ci
|
||||
|
||||
- name: Run the pre-merge gate
|
||||
env:
|
||||
PYTHON: ${{ github.workspace }}/.venv/bin/python
|
||||
ECOMM_TEST_ADMIN_URL: postgresql://ecomm:ecomm@localhost:5432/postgres
|
||||
run: scripts/check.sh
|
||||
@@ -6,3 +6,6 @@ node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.local
|
||||
.pytest_cache/
|
||||
frontend/dist/
|
||||
backend/var/
|
||||
|
||||
@@ -20,5 +20,8 @@ This app (One Name `ecomm`, see [`app.json`](./app.json)) is composed of:
|
||||
|
||||
## Status
|
||||
|
||||
No application code yet. The architecture and first release are to be designed in
|
||||
a brainstorming/planning session before any code lands.
|
||||
SLICE-1 (walking skeleton) of SD-0001 is in place: a four-layer FastAPI backend that
|
||||
self-migrates an empty PostgreSQL database at startup and serves `/healthz`, a
|
||||
Vite/React shell, the `scripts/check.sh` gate, and the dev container lifecycle
|
||||
(`scripts/dev.sh`). See [`docs/BOOTSTRAP.md`](./docs/BOOTSTRAP.md) to run it locally.
|
||||
Identity (SLICE-2) and the storefront (SLICE-3) are next.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Enforces SD-0001 §6.2: the four-layer contract main > domains > platform, with
|
||||
# imports flowing only downward. The GraphQL `api` layer is deferred (§2), so the
|
||||
# MVP contract has three layers; `app.api` is added back between main and domains
|
||||
# when GraphQL arrives. Run from backend/: .venv/bin/lint-imports
|
||||
[importlinter]
|
||||
root_package = app
|
||||
|
||||
[importlinter:contract:layers]
|
||||
name = App layering (main > domains > platform)
|
||||
type = layers
|
||||
layers =
|
||||
app.main
|
||||
app.domains
|
||||
app.platform
|
||||
@@ -0,0 +1,6 @@
|
||||
"""domains layer — bounded contexts (accounts, storefronts).
|
||||
|
||||
Empty in SLICE-1; the accounts domain lands in SLICE-2 and storefronts in SLICE-3.
|
||||
The package exists now so the layer contract (.importlinter) has a target and later
|
||||
slices add to a stable seam. Domains import only from app.platform, never upward.
|
||||
"""
|
||||
@@ -0,0 +1,51 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""platform layer — cross-cutting primitives (config, db, deps).
|
||||
|
||||
The bottom layer: imports nothing from app.domains or app.main. Owns the schema
|
||||
lifecycle and request wiring, never business rules (SD-0001 §6.2).
|
||||
"""
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Configuration surface — the single place deployment/environment config is read.
|
||||
|
||||
INV-8: no deployment shape is baked into framework code. Every URL, credential, or
|
||||
relay coordinate arrives through here from the environment (resolved from Secret
|
||||
Manager in deployed environments). The localhost defaults match compose.yaml so a
|
||||
clean `scripts/dev.sh` checkout needs no env setup.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
# Dev default points at the single Postgres container compose.yaml brings up. In
|
||||
# PPE/Prod the deployment supplies ECOMM_DATABASE_URL from Secret Manager (INV-8).
|
||||
_DEFAULT_DATABASE_URL = "postgresql://ecomm:ecomm@localhost:5432/ecomm"
|
||||
|
||||
|
||||
def database_url() -> str:
|
||||
"""The psycopg DSN for the application database."""
|
||||
return os.environ.get("ECOMM_DATABASE_URL") or _DEFAULT_DATABASE_URL
|
||||
@@ -0,0 +1,87 @@
|
||||
"""PostgreSQL access (psycopg 3) + a forward-only migration runner.
|
||||
|
||||
Standard wiggleverse stack ported to Postgres (SD-0001 D-7): a connection pool and
|
||||
numbered forward-only `.sql` migrations applied in filename order, recorded in
|
||||
`schema_migrations`. No ORM. The runner is fail-stop (a failing migration aborts and
|
||||
records nothing, so it retries next boot) and guarded by a session advisory lock so
|
||||
two booting processes never double-apply (INV-7).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from . import config
|
||||
|
||||
# app/platform/db.py -> parents[2] is backend/, where migrations/ lives.
|
||||
MIGRATIONS_DIR = Path(__file__).resolve().parents[2] / "migrations"
|
||||
|
||||
# Arbitrary fixed key for the migration advisory lock (INV-7 concurrency guard).
|
||||
_MIGRATION_LOCK_KEY = 0x6563_6F6D_6D31 # "ecomm1"
|
||||
|
||||
|
||||
def open_pool(dsn: str | None = None, *, min_size: int = 1, max_size: int = 5) -> ConnectionPool:
|
||||
"""Open a small connection pool against the configured database."""
|
||||
pool = ConnectionPool(dsn or config.database_url(), min_size=min_size, max_size=max_size, open=False)
|
||||
pool.open()
|
||||
pool.wait()
|
||||
return pool
|
||||
|
||||
|
||||
def _migration_files(migrations_dir: Path) -> list[Path]:
|
||||
return sorted(migrations_dir.glob("*.sql"))
|
||||
|
||||
|
||||
def migrate(conn: psycopg.Connection, migrations_dir: Path = MIGRATIONS_DIR) -> list[str]:
|
||||
"""Apply unapplied migrations in filename order; return the names applied.
|
||||
|
||||
Forward-only and fail-stop: each migration runs with its `schema_migrations`
|
||||
insert in one transaction, so a failure records nothing and is retried next time.
|
||||
A session advisory lock serializes concurrent migrators (INV-7).
|
||||
"""
|
||||
conn.execute("SELECT pg_advisory_lock(%s)", (_MIGRATION_LOCK_KEY,))
|
||||
try:
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS schema_migrations ("
|
||||
" filename TEXT PRIMARY KEY,"
|
||||
" applied_at TIMESTAMPTZ NOT NULL DEFAULT now())"
|
||||
)
|
||||
conn.commit()
|
||||
applied = {
|
||||
r[0] for r in conn.execute("SELECT filename FROM schema_migrations").fetchall()
|
||||
}
|
||||
ran: list[str] = []
|
||||
for path in _migration_files(migrations_dir):
|
||||
if path.name in applied:
|
||||
continue
|
||||
try:
|
||||
conn.execute(path.read_text()) # whole file; no params -> multi-statement
|
||||
conn.execute(
|
||||
"INSERT INTO schema_migrations (filename) VALUES (%s)", (path.name,)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
ran.append(path.name)
|
||||
return ran
|
||||
finally:
|
||||
conn.execute("SELECT pg_advisory_unlock(%s)", (_MIGRATION_LOCK_KEY,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def pending_migrations(conn: psycopg.Connection, migrations_dir: Path = MIGRATIONS_DIR) -> list[str]:
|
||||
"""Migration filenames present on disk but not yet recorded as applied.
|
||||
|
||||
Used by /healthz to report migration currency (§6.4). Returns all on-disk
|
||||
migrations if the tracking table does not exist yet.
|
||||
"""
|
||||
exists = conn.execute("SELECT to_regclass('public.schema_migrations')").fetchone()[0]
|
||||
if exists is None:
|
||||
return [p.name for p in _migration_files(migrations_dir)]
|
||||
applied = {
|
||||
r[0] for r in conn.execute("SELECT filename FROM schema_migrations").fetchall()
|
||||
}
|
||||
return [p.name for p in _migration_files(migrations_dir) if p.name not in applied]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""FastAPI dependencies — per-request wiring (SD-0001 §6.2 platform/deps).
|
||||
|
||||
Yields a pooled connection per request. The pool lives on app.state, built once in
|
||||
create_app(). Later slices add the current-session and mailer dependencies here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import psycopg
|
||||
from fastapi import Request
|
||||
|
||||
|
||||
def get_conn(request: Request) -> Iterator[psycopg.Connection]:
|
||||
"""A pooled connection for the duration of one request."""
|
||||
pool = request.app.state.pool
|
||||
with pool.connection() as conn:
|
||||
yield conn
|
||||
@@ -0,0 +1,48 @@
|
||||
-- 0001_init.sql — SD-0001 §6.3 data model. Forward-only and fail-stop (INV-7):
|
||||
-- do NOT edit this file once it has merged/applied; add a new numbered migration.
|
||||
|
||||
-- account — a person's identity on the platform. Email is the canonical key (INV-2);
|
||||
-- the address is stored already-normalized (lowercased) by the service layer, so a
|
||||
-- plain unique index enforces one-account-per-email. Data minimization (OHM): email
|
||||
-- and nothing else (§6.3).
|
||||
CREATE TABLE account (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX account_email_key ON account (email); -- INV-2
|
||||
|
||||
-- auth_code — one-time codes, handled like secrets (INV-3): only the hash is stored;
|
||||
-- keyed by email (the code is issued before the account may exist — sign-up and
|
||||
-- log-in converge, PUC-3).
|
||||
CREATE TABLE auth_code (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
code_hash TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
consumed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX auth_code_email_idx ON auth_code (email);
|
||||
|
||||
-- storefront — a merchant's business presence. Name is always populated (a blank
|
||||
-- entry gets a generated default at the service layer, §6.3); "unnamed" is never NULL.
|
||||
CREATE TABLE storefront (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- storefront_membership — account <-> storefront ownership. INV-4: deliberately a
|
||||
-- many-to-many shape (composite PK, no UNIQUE(account_id)) so many-storefronts-per-
|
||||
-- account stays open; the one-storefront rule is a deletable service-layer guard, not
|
||||
-- a schema law. INV-5: every storefront-scoped row carries its storefront_id. `role`
|
||||
-- is a single-value enum today; it is the seam the staff/permissions model attaches to.
|
||||
CREATE TABLE storefront_membership (
|
||||
account_id BIGINT NOT NULL REFERENCES account (id),
|
||||
storefront_id BIGINT NOT NULL REFERENCES storefront (id),
|
||||
role TEXT NOT NULL DEFAULT 'owner' CHECK (role IN ('owner')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (account_id, storefront_id)
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
addopts = -q
|
||||
# Put backend/ (this rootdir) on sys.path so tests import the `app` package under
|
||||
# pytest's default prepend import mode (tests/ has no __init__.py).
|
||||
pythonpath = .
|
||||
@@ -0,0 +1,7 @@
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.29
|
||||
httpx>=0.27
|
||||
psycopg[binary]>=3.1
|
||||
psycopg-pool>=3.2
|
||||
pytest>=8.0
|
||||
import-linter>=2.0
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Test fixtures — a fresh, empty PostgreSQL database per test.
|
||||
|
||||
Each test gets its own database created on the dev/CI Postgres (every test runs the
|
||||
real engine, §6.8). The admin DSN points at the `postgres` maintenance database;
|
||||
ECOMM_TEST_ADMIN_URL overrides it (CI sets it to the service container).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
ADMIN_URL = os.environ.get(
|
||||
"ECOMM_TEST_ADMIN_URL", "postgresql://ecomm:ecomm@localhost:5432/postgres"
|
||||
)
|
||||
|
||||
|
||||
def _swap_dbname(dsn: str, dbname: str) -> str:
|
||||
# ADMIN_URL ends in `/postgres`; point the same credentials at `dbname`.
|
||||
base, _, _old = dsn.rpartition("/")
|
||||
return f"{base}/{dbname}"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fresh_db_url():
|
||||
"""Create a uniquely-named empty database; drop it after the test."""
|
||||
dbname = f"ecomm_test_{uuid.uuid4().hex}"
|
||||
admin = psycopg.connect(ADMIN_URL, autocommit=True)
|
||||
try:
|
||||
admin.execute(f'CREATE DATABASE "{dbname}"')
|
||||
finally:
|
||||
admin.close()
|
||||
try:
|
||||
yield _swap_dbname(ADMIN_URL, dbname)
|
||||
finally:
|
||||
admin = psycopg.connect(ADMIN_URL, autocommit=True)
|
||||
try:
|
||||
admin.execute(
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
|
||||
"WHERE datname = %s AND pid <> pg_backend_pid()",
|
||||
(dbname,),
|
||||
)
|
||||
admin.execute(f'DROP DATABASE IF EXISTS "{dbname}"')
|
||||
finally:
|
||||
admin.close()
|
||||
@@ -0,0 +1,13 @@
|
||||
import os
|
||||
|
||||
from app.platform import config
|
||||
|
||||
|
||||
def test_database_url_defaults_to_local_compose(monkeypatch):
|
||||
monkeypatch.delenv("ECOMM_DATABASE_URL", raising=False)
|
||||
assert config.database_url() == "postgresql://ecomm:ecomm@localhost:5432/ecomm"
|
||||
|
||||
|
||||
def test_database_url_honors_env(monkeypatch):
|
||||
monkeypatch.setenv("ECOMM_DATABASE_URL", "postgresql://x:y@db:5432/z")
|
||||
assert config.database_url() == "postgresql://x:y@db:5432/z"
|
||||
@@ -0,0 +1,24 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
def test_healthz_ok_on_migrated_empty_db(fresh_db_url):
|
||||
app = create_app(database_url=fresh_db_url)
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/healthz")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_startup_migrates_from_empty(fresh_db_url):
|
||||
# create_app on an empty DB must self-migrate so /healthz reports current.
|
||||
app = create_app(database_url=fresh_db_url)
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/healthz").status_code == 200
|
||||
# the schema_migrations row exists -> migrations ran at startup
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
n = conn.execute("SELECT count(*) FROM schema_migrations").fetchone()[0]
|
||||
assert n >= 1
|
||||
@@ -0,0 +1,43 @@
|
||||
import psycopg
|
||||
|
||||
from app.platform import db
|
||||
|
||||
_TABLES = {"account", "auth_code", "storefront", "storefront_membership"}
|
||||
|
||||
|
||||
def _table_names(conn) -> set[str]:
|
||||
rows = conn.execute(
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname = 'public'"
|
||||
).fetchall()
|
||||
return {r[0] for r in rows}
|
||||
|
||||
|
||||
def test_migrate_from_empty_applies_0001(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
applied = db.migrate(conn)
|
||||
assert applied == ["0001_init.sql"]
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
assert _TABLES.issubset(_table_names(conn))
|
||||
|
||||
|
||||
def test_migrate_is_idempotent(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
db.migrate(conn)
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
applied_again = db.migrate(conn)
|
||||
assert applied_again == []
|
||||
|
||||
|
||||
def test_membership_has_no_unique_account_constraint(fresh_db_url):
|
||||
# INV-4: the schema must keep many-storefronts-per-account open — no UNIQUE on
|
||||
# storefront_membership.account_id anywhere.
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
db.migrate(conn)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT indexdef FROM pg_indexes
|
||||
WHERE schemaname = 'public' AND tablename = 'storefront_membership'
|
||||
"""
|
||||
).fetchall()
|
||||
defs = " ".join(r[0] for r in rows).lower()
|
||||
assert "unique" not in defs.replace("primary key", "") or "(account_id)" not in defs
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dev datastore for ecomm — a single pinned PostgreSQL service (SD-0001 D-7).
|
||||
# The app runs natively (fast dev loop); only the database is containerized.
|
||||
# `scripts/dev.sh` owns this container's lifecycle. Reset to empty (to rehearse the
|
||||
# bootstrap, BUC-5) with: docker compose down -v
|
||||
name: ecomm-dev
|
||||
|
||||
services:
|
||||
db:
|
||||
# Pinned to the Cloud SQL major version ecomm targets in PPE/Prod (D-7).
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_USER: ecomm
|
||||
POSTGRES_PASSWORD: ecomm
|
||||
POSTGRES_DB: ecomm
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- ecomm-pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ecomm -d ecomm"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
|
||||
volumes:
|
||||
ecomm-pgdata:
|
||||
@@ -0,0 +1,64 @@
|
||||
# Bootstrapping ecomm
|
||||
|
||||
Bringing an environment from **empty persistence** to "first merchant, first
|
||||
storefront" through the product flows alone (SD-0001 BUC-5). Empty is a working state
|
||||
(INV-1): the app applies its own schema migrations at startup; there is no seed step.
|
||||
|
||||
This document grows per environment. SLICE-1 ships the **localhost** section; the
|
||||
pre-production and production sections land with SLICE-4.
|
||||
|
||||
## Localhost
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Docker** (Desktop or Engine) with Compose v2 — the one external prerequisite. The
|
||||
dev datastore is a single PostgreSQL container; the app itself runs natively for a
|
||||
fast dev loop (D-7).
|
||||
- **Python 3.13** and **Node 20+** on your PATH. `scripts/dev.sh` creates the backend
|
||||
venv and installs frontend deps on first run.
|
||||
|
||||
### Bring-up
|
||||
|
||||
From a clean checkout:
|
||||
|
||||
```bash
|
||||
scripts/dev.sh
|
||||
```
|
||||
|
||||
This:
|
||||
|
||||
1. starts the dev Postgres container and waits for it to report healthy (the script
|
||||
owns the container's lifecycle);
|
||||
2. creates the backend virtualenv and installs dependencies (first run only);
|
||||
3. installs the frontend's npm dependencies (first run only);
|
||||
4. runs the FastAPI backend on **:8000** — which connects to the empty database and
|
||||
applies all pending migrations itself (INV-7) — and the Vite dev server on
|
||||
**:5173**, proxying `/api` to the backend.
|
||||
|
||||
Press **Ctrl-C** to stop the backend and Vite. The database container keeps running.
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/healthz
|
||||
```
|
||||
|
||||
Expected: `{"status":"ok"}` (HTTP 200) — the process is up, the database is reachable,
|
||||
and migrations are current. Open <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).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
<!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>
|
||||
Generated
+1771
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "wiggleverse-ecomm-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// SLICE-1 shell only — the Landing, Sign-in, Create-storefront, and Admin screens
|
||||
// (SD-0001 §5) land in SLICE-2/3. This proves the build and dev server work.
|
||||
export default function App() {
|
||||
return (
|
||||
<main>
|
||||
<h1>ecomm</h1>
|
||||
<p>Honest commerce. Your storefront is yours.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// The SPA talks to the FastAPI backend on :8000 via a same-origin proxy, so the
|
||||
// browser makes no cross-origin calls in dev. Override the target with
|
||||
// VITE_API_TARGET when :8000 is occupied. Screen-shaped /api/* endpoints arrive in
|
||||
// SLICE-2/3 (SD-0001 §6.4).
|
||||
const apiTarget = process.env.VITE_API_TARGET || "http://localhost:8000";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": apiTarget,
|
||||
},
|
||||
},
|
||||
});
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# The single pre-merge gate for wiggleverse-ecomm (SD-0001 §6.8). Runs, in order:
|
||||
# 1. the import-linter layering contract (main > domains > platform)
|
||||
# 2. the backend test suite (pytest, against Postgres)
|
||||
# 3. the frontend typecheck + production build
|
||||
# CI (.gitea/workflows/ci.yml) calls this exact script, so local and server gates
|
||||
# cannot drift. Exits non-zero on the first failure.
|
||||
#
|
||||
# Requires a reachable Postgres for the backend tests: locally the compose service
|
||||
# (scripts/dev.sh brings it up); in CI a Postgres service container. Override its
|
||||
# admin DSN with ECOMM_TEST_ADMIN_URL.
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
# Pick an interpreter: explicit $PYTHON (CI), else the repo venv, else PATH.
|
||||
if [ -n "${PYTHON:-}" ]; then
|
||||
PY="$PYTHON"
|
||||
elif [ -x "$repo_root/.venv/bin/python" ]; then
|
||||
PY="$repo_root/.venv/bin/python"
|
||||
else
|
||||
PY="$(command -v python3 || command -v python)"
|
||||
fi
|
||||
|
||||
lint="$(dirname "$PY")/lint-imports"
|
||||
[ -x "$lint" ] || lint="lint-imports"
|
||||
|
||||
echo "==> import boundaries (lint-imports)"
|
||||
( cd "$repo_root/backend" && "$lint" )
|
||||
|
||||
echo "==> backend tests (pytest)"
|
||||
( cd "$repo_root/backend" && "$PY" -m pytest -q )
|
||||
|
||||
echo "==> frontend typecheck + build"
|
||||
( cd "$repo_root/frontend" && npm run build )
|
||||
|
||||
echo "==> all gates green"
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/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=$!
|
||||
|
||||
# Kill a process and its whole descendant tree (children first), portably across
|
||||
# macOS and Linux — npm spawns vite as a grandchild, so killing the captured pid
|
||||
# alone would orphan it on :5173. `pgrep -P` (list children) exists on both.
|
||||
kill_tree() {
|
||||
local pid=$1 child
|
||||
for child in $(pgrep -P "$pid" 2>/dev/null); do
|
||||
kill_tree "$child"
|
||||
done
|
||||
kill "$pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
trap - INT TERM
|
||||
echo
|
||||
echo "==> stopping backend and Vite"
|
||||
kill_tree "$backend_pid"
|
||||
kill_tree "$frontend_pid"
|
||||
wait "$backend_pid" "$frontend_pid" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup INT TERM
|
||||
|
||||
# Wait until either process exits, then tear the other down. `wait -n` would be
|
||||
# tidier but is unsupported on macOS's bundled bash 3.2, so poll portably.
|
||||
while kill -0 "$backend_pid" 2>/dev/null && kill -0 "$frontend_pid" 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
cleanup
|
||||
Reference in New Issue
Block a user