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

This commit was merged in pull request #5.
This commit is contained in:
2026-06-10 15:50:42 +00:00
30 changed files with 3982 additions and 2 deletions
+57
View File
@@ -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
+3
View File
@@ -6,3 +6,6 @@ node_modules/
dist/ dist/
.env .env
*.local *.local
.pytest_cache/
frontend/dist/
backend/var/
+5 -2
View File
@@ -20,5 +20,8 @@ This app (One Name `ecomm`, see [`app.json`](./app.json)) is composed of:
## Status ## Status
No application code yet. The architecture and first release are to be designed in SLICE-1 (walking skeleton) of SD-0001 is in place: a four-layer FastAPI backend that
a brainstorming/planning session before any code lands. 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.
+14
View File
@@ -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
View File
+6
View File
@@ -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.
"""
+51
View File
@@ -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()
+5
View File
@@ -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).
"""
+19
View File
@@ -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
+87
View File
@@ -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]
+18
View File
@@ -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
+48
View File
@@ -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)
);
+7
View File
@@ -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 = .
+7
View File
@@ -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
+47
View File
@@ -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()
+13
View File
@@ -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"
+24
View File
@@ -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
+43
View File
@@ -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
+26
View File
@@ -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:
+64
View File
@@ -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
+12
View File
@@ -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>
+1771
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
+10
View File
@@ -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>
);
}
+9
View File
@@ -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>,
);
+17
View File
@@ -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"]
}
+18
View File
@@ -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,
},
},
});
+37
View File
@@ -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
View File
@@ -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