SLICE-1: walking skeleton (SD-0001 §7.2) — scaffold, dev Postgres, migration runner, /healthz #5
@@ -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