feat(slice-1): platform/db migration runner + migration 0001 (INV-7, §6.3 schema)

Forward-only, fail-stop, advisory-lock-guarded psycopg runner; migration 0001 lays
the account/auth_code/storefront/membership schema. Tests prove migrate-from-empty
and idempotent re-migrate (INV-1 partial) and the INV-4 many-capable shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 08:37:09 -07:00
parent ab4317e105
commit 9462423642
4 changed files with 225 additions and 0 deletions
+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]
+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)
);
+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()
+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