From 94624236427f308cde9fb38359429dfee2c045df Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 08:37:09 -0700 Subject: [PATCH] =?UTF-8?q?feat(slice-1):=20platform/db=20migration=20runn?= =?UTF-8?q?er=20+=20migration=200001=20(INV-7,=20=C2=A76.3=20schema)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/platform/db.py | 87 ++++++++++++++++++++++++++++++++ backend/migrations/0001_init.sql | 48 ++++++++++++++++++ backend/tests/conftest.py | 47 +++++++++++++++++ backend/tests/test_migrations.py | 43 ++++++++++++++++ 4 files changed, 225 insertions(+) create mode 100644 backend/app/platform/db.py create mode 100644 backend/migrations/0001_init.sql create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_migrations.py diff --git a/backend/app/platform/db.py b/backend/app/platform/db.py new file mode 100644 index 0000000..2399927 --- /dev/null +++ b/backend/app/platform/db.py @@ -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] diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql new file mode 100644 index 0000000..ca363d7 --- /dev/null +++ b/backend/migrations/0001_init.sql @@ -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) +); diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..dcf010b --- /dev/null +++ b/backend/tests/conftest.py @@ -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() diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py new file mode 100644 index 0000000..81e9945 --- /dev/null +++ b/backend/tests/test_migrations.py @@ -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