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:
@@ -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,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,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,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
|
||||
Reference in New Issue
Block a user