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/
|
dist/
|
||||||
.env
|
.env
|
||||||
*.local
|
*.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
|
## 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.
|
||||||
|
|||||||
@@ -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