user-engine/src/user_engine/migrations.py

124 lines
3.7 KiB
Python

"""Migration manifest for durable user-engine store adapters."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import PurePosixPath
@dataclass(frozen=True)
class MigrationStep:
"""One ordered durable-store migration known to user-engine."""
version: str
name: str
description: str
record_types: tuple[str, ...]
sql_path: str | None = None
USER_ENGINE_STORE_RECORD_TYPES = (
"users",
"accounts",
"external_identities",
"tenant_accounts",
"memberships",
"applications",
"application_bindings",
"catalogs",
"family_invitations",
"registration_sessions",
"identity_factors",
"prepared_accounts",
"access_profiles",
"active_access_contexts",
"welcome_protocols",
"onboarding_journeys",
"profile_values",
"audit_records",
"outbox_events",
)
USER_ENGINE_RECORD_COUNT_KEYS = (
"users",
"accounts",
"tenant_accounts",
"memberships",
"applications",
"bindings",
"catalogs",
"family_invitations",
"registration_sessions",
"identity_factors",
"prepared_accounts",
"access_profiles",
"active_access_contexts",
"welcome_protocols",
"onboarding_journeys",
"profile_values",
"audit_records",
"pending_outbox_events",
)
USER_ENGINE_MIGRATIONS = (
MigrationStep(
version="0001_initial",
name="initial durable store",
description=(
"Create the schema-version ledger, logical record table, audit "
"log table, and pending outbox table used by user-engine store "
"adapters."
),
record_types=USER_ENGINE_STORE_RECORD_TYPES,
sql_path="migrations/postgres/0001_user_engine_store.sql",
),
)
LATEST_SCHEMA_VERSION = USER_ENGINE_MIGRATIONS[-1].version
def migration_manifest() -> tuple[MigrationStep, ...]:
"""Return the ordered durable-store migration manifest."""
return USER_ENGINE_MIGRATIONS
def validate_migration_manifest(
migrations: tuple[MigrationStep, ...] = USER_ENGINE_MIGRATIONS,
) -> tuple[str, ...]:
"""Return manifest validation errors without touching provider resources."""
errors: list[str] = []
if not migrations:
return ("migration manifest must not be empty",)
seen_versions: set[str] = set()
previous_version = ""
for migration in migrations:
if not migration.version:
errors.append("migration version must not be empty")
if migration.version in seen_versions:
errors.append(f"duplicate migration version {migration.version}")
if previous_version and migration.version <= previous_version:
errors.append(
f"migration {migration.version} must sort after {previous_version}"
)
if not migration.name:
errors.append(f"migration {migration.version} must have a name")
if not migration.record_types:
errors.append(
f"migration {migration.version} must declare logical record types"
)
if len(set(migration.record_types)) != len(migration.record_types):
errors.append(f"migration {migration.version} has duplicate record types")
if migration.sql_path is not None:
path = PurePosixPath(migration.sql_path)
if path.is_absolute() or ".." in path.parts:
errors.append(
f"migration {migration.version} sql_path must stay repo-relative"
)
if path.suffix != ".sql":
errors.append(
f"migration {migration.version} sql_path must reference SQL"
)
seen_versions.add(migration.version)
previous_version = migration.version
return tuple(errors)