Skip to content

Extend playground services

The public playground service layer supports custom launchers, configuration tooling, schema refresh integrations, and migration workflow tests. Textual screen and widget classes remain implementation details.

Configuration records and functions

Use these records to discover, parse, select, validate, and write playground configuration.

ormdantic.playground.config

Project configuration for the Ormdantic playground.

ConfigError

Bases: ValueError

Raised when playground configuration is invalid or incomplete.

ProjectConfig dataclass

ProjectConfig(target=None, migrations_dir=Path('migrations'), format='toml', watch=DEFAULT_WATCH, database_poll_seconds=5.0, debounce_milliseconds=300)

Project-wide playground settings.

target class-attribute instance-attribute

target = None

migrations_dir class-attribute instance-attribute

migrations_dir = Path('migrations')

format class-attribute instance-attribute

format = 'toml'

watch class-attribute instance-attribute

watch = DEFAULT_WATCH

database_poll_seconds class-attribute instance-attribute

database_poll_seconds = 5.0

debounce_milliseconds class-attribute instance-attribute

debounce_milliseconds = 300

EnvironmentConfig dataclass

EnvironmentConfig(name, url_env='DATABASE_URL', env_file=Path('.env'), safety='confirm', production=False)

Connection lookup and safety settings for a named environment.

name instance-attribute

name

url_env class-attribute instance-attribute

url_env = 'DATABASE_URL'

env_file class-attribute instance-attribute

env_file = Path('.env')

safety class-attribute instance-attribute

safety = 'confirm'

production class-attribute instance-attribute

production = False

PlaygroundConfig dataclass

PlaygroundConfig(project, environments)

Serializable playground configuration.

project instance-attribute

project

environments instance-attribute

environments

EffectiveConfig dataclass

EffectiveConfig(path, root, project, environment)

A selected environment with all filesystem paths resolved.

path instance-attribute

path

root instance-attribute

root

project instance-attribute

project

environment instance-attribute

environment

DatabaseUrlSource dataclass

DatabaseUrlSource(value, label)

A secret database URL and its non-secret source label.

value instance-attribute

value

label instance-attribute

label

discover_config

discover_config(start)

Find the nearest playground configuration at or above start.

Source code in ormdantic/playground/config.py
def discover_config(start: Path) -> Path | None:
    """Find the nearest playground configuration at or above ``start``."""
    current = start.resolve()
    if current.is_file():
        current = current.parent
    for directory in (current, *current.parents):
        candidate = directory / CONFIG_NAME
        if candidate.is_file():
            return candidate
    return None

parse_config

parse_config(path)

Parse a configuration while retaining its relative paths.

Source code in ormdantic/playground/config.py
def parse_config(path: Path) -> PlaygroundConfig:
    """Parse a configuration while retaining its relative paths."""
    try:
        source = path.read_text(encoding="utf-8")
    except FileNotFoundError:
        raise
    return parse_config_source(source, source_name=str(path))

parse_config_source

parse_config_source(source, *, source_name='ormdantic.toml')

Parse and validate an in-memory playground TOML document.

Source code in ormdantic/playground/config.py
def parse_config_source(
    source: str,
    *,
    source_name: str = "ormdantic.toml",
) -> PlaygroundConfig:
    """Parse and validate an in-memory playground TOML document."""
    try:
        payload = toml_loads(source)
    except Exception as exc:
        raise ConfigError(f"{source_name}: invalid TOML: {exc}") from exc
    _reject_unknown(payload, {"project", "environments"}, "")
    project_payload = _table(payload.get("project"), "project")
    environments_payload = _table(payload.get("environments"), "environments")
    if not environments_payload:
        raise ConfigError("environments: configure at least one named environment")
    project = _parse_project(project_payload)
    environments = {
        str(name): _parse_environment(
            str(name),
            _table(value, f"environments.{name}"),
        )
        for name, value in environments_payload.items()
    }
    return PlaygroundConfig(project=project, environments=environments)

load_config

load_config(path, *, environment=None, target=None, migrations_dir=None)

Load, validate, select, and resolve a playground configuration.

Source code in ormdantic/playground/config.py
def load_config(
    path: Path,
    *,
    environment: str | None = None,
    target: str | None = None,
    migrations_dir: Path | None = None,
) -> EffectiveConfig:
    """Load, validate, select, and resolve a playground configuration."""
    config_path = path.resolve()
    config = parse_config(config_path)
    environment_name = environment or "development"
    selected = config.environments.get(environment_name)
    if selected is None:
        raise ConfigError(
            f"environments.{environment_name}: named environment is not configured"
        )

    root = config_path.parent
    project = config.project
    effective_target = _optional_nonempty_string(
        target if target is not None else project.target,
        "project.target",
    )
    if effective_target is None:
        raise ConfigError("project.target: import target is required")
    migration_path = migrations_dir or project.migrations_dir
    resolved_project = replace(
        project,
        target=effective_target,
        migrations_dir=_resolve_path(root, migration_path),
    )
    resolved_environment = replace(
        selected,
        env_file=(
            _resolve_path(root, selected.env_file)
            if selected.env_file is not None
            else None
        ),
        safety="typed" if selected.production else selected.safety,
    )
    return EffectiveConfig(
        path=config_path,
        root=root,
        project=resolved_project,
        environment=resolved_environment,
    )

write_config

write_config(path, config, *, overwrite=False)

Atomically write a canonical playground TOML file.

Source code in ormdantic/playground/config.py
def write_config(
    path: Path,
    config: PlaygroundConfig,
    *,
    overwrite: bool = False,
) -> None:
    """Atomically write a canonical playground TOML file."""
    destination = path.resolve()
    if destination.exists() and not overwrite:
        raise FileExistsError(destination)
    source = _config_toml(config)
    _atomic_write(destination, source)

write_config_source

write_config_source(path, source)

Validate then atomically replace an existing playground TOML file.

Source code in ormdantic/playground/config.py
def write_config_source(path: Path, source: str) -> None:
    """Validate then atomically replace an existing playground TOML file."""
    destination = path.resolve()
    parse_config_source(source, source_name=str(destination))
    _atomic_write(destination, source)

resolve_database_url

resolve_database_url(environment)

Resolve a URL from the process environment and then its dotenv file.

Source code in ormdantic/playground/config.py
def resolve_database_url(environment: EnvironmentConfig) -> DatabaseUrlSource:
    """Resolve a URL from the process environment and then its dotenv file."""
    env_value = _clean_value(os.environ.get(environment.url_env))
    if env_value is not None:
        return DatabaseUrlSource(env_value, environment.url_env)
    dotenv_values = _read_env_file(environment.env_file)
    dotenv_value = _clean_value(dotenv_values.get(environment.url_env))
    if dotenv_value is not None:
        return DatabaseUrlSource(
            dotenv_value,
            f"{environment.env_file}:{environment.url_env}",
        )
    raise ConfigError(
        f"environments.{environment.name}.url_env: export {environment.url_env} "
        f"or define it in {environment.env_file or '.env'}"
    )

Immutable state records

State snapshots keep UI rendering and background services independent.

ormdantic.playground.state

Immutable state snapshots shared by playground services and views.

RefreshStatus

Bases: str, Enum

Lifecycle state for schema refreshes.

IDLE class-attribute instance-attribute

IDLE = 'idle'

RUNNING class-attribute instance-attribute

RUNNING = 'running'

HEALTHY class-attribute instance-attribute

HEALTHY = 'healthy'

PARTIAL class-attribute instance-attribute

PARTIAL = 'partial'

ERROR class-attribute instance-attribute

ERROR = 'error'

PAUSED class-attribute instance-attribute

PAUSED = 'paused'

SchemaState dataclass

SchemaState(model_snapshot=None, live_snapshot=None, diff=None, forward_sql=(), rollback_sql=(), refreshed_at=None, stale=False, diagnostics=())

The most recent model/live snapshots and derived drift.

model_snapshot class-attribute instance-attribute

model_snapshot = None

live_snapshot class-attribute instance-attribute

live_snapshot = None

diff class-attribute instance-attribute

diff = None

forward_sql class-attribute instance-attribute

forward_sql = ()

rollback_sql class-attribute instance-attribute

rollback_sql = ()

refreshed_at class-attribute instance-attribute

refreshed_at = None

stale class-attribute instance-attribute

stale = False

diagnostics class-attribute instance-attribute

diagnostics = ()

ready_for_generation property

ready_for_generation

Whether a fresh, complete, non-empty migration plan is available.

ArtifactSummary dataclass

ArtifactSummary(path, revision, status, format, destructive=False, unsafe=False, valid=True)

Render-safe metadata for one migration artifact.

path instance-attribute

path

revision instance-attribute

revision

status instance-attribute

status

format instance-attribute

format

destructive class-attribute instance-attribute

destructive = False

unsafe class-attribute instance-attribute

unsafe = False

valid class-attribute instance-attribute

valid = True

MigrationState dataclass

MigrationState(artifacts=(), history=(), current_revision=None, dirty=False, selected_path=None)

Migration files and durable history known to the playground.

artifacts class-attribute instance-attribute

artifacts = ()

history class-attribute instance-attribute

history = ()

current_revision class-attribute instance-attribute

current_revision = None

dirty class-attribute instance-attribute

dirty = False

selected_path class-attribute instance-attribute

selected_path = None

OperationState dataclass

OperationState(name=None, target=None, running=False, message=None)

The currently executing or most recently completed operation.

name class-attribute instance-attribute

name = None

target class-attribute instance-attribute

target = None

running class-attribute instance-attribute

running = False

message class-attribute instance-attribute

message = None

PlaygroundState dataclass

PlaygroundState(environment, connection_label=None, dialect=None, generation=0, status=IDLE, watcher_paused=False, schema=SchemaState(), migrations=MigrationState(), operation=OperationState(), diagnostics=())

Complete immutable application state.

environment instance-attribute

environment

connection_label class-attribute instance-attribute

connection_label = None

dialect class-attribute instance-attribute

dialect = None

generation class-attribute instance-attribute

generation = 0

status class-attribute instance-attribute

status = IDLE

watcher_paused class-attribute instance-attribute

watcher_paused = False

schema class-attribute instance-attribute

schema = field(default_factory=SchemaState)

migrations class-attribute instance-attribute

migrations = field(default_factory=MigrationState)

operation class-attribute instance-attribute

operation = field(default_factory=OperationState)

diagnostics class-attribute instance-attribute

diagnostics = ()

RefreshResult dataclass

RefreshResult(generation, status, schema, migrations=None, connection_label=None, dialect=None, diagnostics=())

One generation of refresh work ready for publication.

generation instance-attribute

generation

status instance-attribute

status

schema instance-attribute

schema

migrations class-attribute instance-attribute

migrations = None

connection_label class-attribute instance-attribute

connection_label = None

dialect class-attribute instance-attribute

dialect = None

diagnostics class-attribute instance-attribute

diagnostics = ()

accept_refresh

accept_refresh(state, result)

Publish a refresh unless a newer generation already won the race.

Source code in ormdantic/playground/state.py
def accept_refresh(
    state: PlaygroundState,
    result: RefreshResult,
) -> PlaygroundState:
    """Publish a refresh unless a newer generation already won the race."""
    if result.generation < state.generation:
        return state
    changes: dict[str, object] = {
        "generation": result.generation,
        "status": result.status,
        "schema": result.schema,
        "connection_label": result.connection_label,
        "dialect": result.dialect,
        "diagnostics": result.diagnostics,
    }
    if result.migrations is not None:
        changes["migrations"] = result.migrations
    return replace(state, **changes)

Diagnostics and redaction

Create diagnostics through Diagnostic.create so messages and structured details are redacted before publication.

ormdantic.playground.diagnostics

Structured, secret-safe diagnostics for the playground.

Severity

Bases: str, Enum

Diagnostic importance.

INFO class-attribute instance-attribute

INFO = 'info'

WARNING class-attribute instance-attribute

WARNING = 'warning'

ERROR class-attribute instance-attribute

ERROR = 'error'

Diagnostic dataclass

Diagnostic(severity, code, message, source=None, hint=None, details=dict())

One actionable and already-redacted diagnostic.

severity instance-attribute

severity

code instance-attribute

code

message instance-attribute

message

source class-attribute instance-attribute

source = None

hint class-attribute instance-attribute

hint = None

details class-attribute instance-attribute

details = field(default_factory=dict)

create classmethod

create(severity, code, message, *, source=None, hint=None, details=None)

Construct a diagnostic after removing secret values.

Source code in ormdantic/playground/diagnostics.py
@classmethod
def create(
    cls,
    severity: Severity,
    code: str,
    message: str,
    *,
    source: str | None = None,
    hint: str | None = None,
    details: Mapping[str, Any] | None = None,
) -> Diagnostic:
    """Construct a diagnostic after removing secret values."""
    return cls(
        severity=severity,
        code=code,
        message=redact_text(message),
        source=redact_text(source) if source is not None else None,
        hint=redact_text(hint) if hint is not None else None,
        details=redact_value(details or {}),
    )

redact_text

redact_text(value)

Redact credentials embedded in URLs or secret query parameters.

Source code in ormdantic/playground/diagnostics.py
def redact_text(value: str) -> str:
    """Redact credentials embedded in URLs or secret query parameters."""
    value = _CREDENTIAL_URL.sub(
        lambda match: f"{match.group('scheme')}{match.group('user')}:<redacted>@",
        value,
    )
    return _SECRET_PARAMETER.sub(
        lambda match: f"{match.group('prefix')}<redacted>",
        value,
    )

redact_value

redact_value(value, *, key=None)

Recursively redact values carried by diagnostics and logs.

Source code in ormdantic/playground/diagnostics.py
def redact_value(value: Any, *, key: str | None = None) -> Any:
    """Recursively redact values carried by diagnostics and logs."""
    if key is not None and key.casefold() in _SECRET_KEYS:
        return "<redacted>"
    if isinstance(value, str):
        return redact_text(value)
    if isinstance(value, Mapping):
        return {
            str(item_key): redact_value(item, key=str(item_key))
            for item_key, item in value.items()
        }
    if isinstance(value, Sequence) and not isinstance(value, bytes | bytearray | str):
        return tuple(redact_value(item) for item in value)
    return value

Inspection and refresh services

inspect_models isolates imports in a child process. RefreshService coordinates model inspection, live reflection, planning, and history without blocking the TUI event loop.

ormdantic.playground.inspection

Async client for isolated model inspection.

InspectionError dataclass

InspectionError(type, message)

Serializable worker error.

type instance-attribute

type

message instance-attribute

message

InspectionResult dataclass

InspectionResult(snapshot, diagnostics=(), error=None)

Snapshot or error returned from a model-inspection worker.

snapshot instance-attribute

snapshot

diagnostics class-attribute instance-attribute

diagnostics = ()

error class-attribute instance-attribute

error = None

inspect_models async

inspect_models(target, *, cwd, timeout=15.0, process_factory=None)

Inspect models in a subprocess without polluting the TUI process.

Source code in ormdantic/playground/inspection.py
async def inspect_models(
    target: str,
    *,
    cwd: Path,
    timeout: float = 15.0,
    process_factory: ProcessFactory | None = None,
) -> InspectionResult:
    """Inspect models in a subprocess without polluting the TUI process."""
    spawn = process_factory or cast(ProcessFactory, asyncio.create_subprocess_exec)
    process = await spawn(
        sys.executable,
        "-m",
        "ormdantic.playground.inspect_worker",
        target,
        cwd=str(cwd),
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    try:
        stdout, stderr = await asyncio.wait_for(process.communicate(), timeout)
    except asyncio.CancelledError:
        await asyncio.shield(_terminate_process(process))
        raise
    except asyncio.TimeoutError:
        await _terminate_process(process)
        message = f"Model inspection exceeded {timeout:g} seconds"
        return InspectionResult(
            snapshot=None,
            error=InspectionError("TimeoutError", message),
            diagnostics=(
                Diagnostic.create(
                    Severity.ERROR,
                    "model.timeout",
                    message,
                    hint="Fix slow import-time work or increase the inspection timeout.",
                ),
            ),
        )

    stdout_text = stdout.decode("utf-8", errors="replace").strip()
    stderr_text = stderr.decode("utf-8", errors="replace").strip()
    if process.returncode not in {0, None}:
        message = stderr_text or f"Inspection worker exited with {process.returncode}"
        return InspectionResult(
            snapshot=None,
            error=InspectionError("WorkerError", message),
            diagnostics=(
                Diagnostic.create(
                    Severity.ERROR,
                    "model.worker_failed",
                    message,
                    details={"returncode": process.returncode},
                ),
            ),
        )
    try:
        payload = json.loads(stdout_text)
        if not isinstance(payload, Mapping):
            raise TypeError("worker payload must be a JSON object")
        if payload.get("protocol") != PROTOCOL_VERSION:
            raise ValueError(f"unsupported worker protocol {payload.get('protocol')!r}")
        diagnostics = _diagnostics(payload.get("diagnostics", []))
        if payload.get("ok") is True:
            snapshot_payload = payload.get("snapshot")
            if not isinstance(snapshot_payload, Mapping):
                raise TypeError("successful worker payload has no snapshot")
            return InspectionResult(
                snapshot=SchemaSnapshot.from_dict(snapshot_payload),
                diagnostics=diagnostics,
            )
        error = _inspection_error(payload.get("error"))
        return InspectionResult(
            snapshot=None,
            diagnostics=(
                Diagnostic.create(
                    Severity.ERROR,
                    "model.import_failed",
                    f"{error.type}: {error.message}",
                    hint="Fix the target or its import error, then refresh.",
                ),
                *diagnostics,
            ),
            error=error,
        )
    except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
        message = f"Invalid model-inspection response: {exc}"
        if stderr_text:
            message = f"{message}. Worker stderr: {stderr_text}"
        return InspectionResult(
            snapshot=None,
            error=InspectionError(type(exc).__name__, str(exc)),
            diagnostics=(
                Diagnostic.create(
                    Severity.ERROR,
                    "model.protocol_error",
                    message,
                    hint="Reinstall matching Ormdantic package versions.",
                ),
            ),
        )

ormdantic.playground.services

Framework-neutral schema refresh services.

DatabaseRefresh dataclass

DatabaseRefresh(live_snapshot, history=(), current_revision=None, dirty=False, diagnostics=())

Live database data collected by a blocking worker.

live_snapshot instance-attribute

live_snapshot

history class-attribute instance-attribute

history = ()

current_revision class-attribute instance-attribute

current_revision = None

dirty class-attribute instance-attribute

dirty = False

diagnostics class-attribute instance-attribute

diagnostics = ()

RefreshService

RefreshService(*, inspector=inspect_models, url_resolver=resolve_database_url, reflector=None, planner=None, thread_runner=to_thread)

Coordinate isolated model imports with threaded database reflection.

Source code in ormdantic/playground/services.py
def __init__(
    self,
    *,
    inspector: Callable[..., Any] = inspect_models,
    url_resolver: Callable[..., DatabaseUrlSource] = resolve_database_url,
    reflector: Callable[[str], DatabaseRefresh] | None = None,
    planner: Callable[[str, SchemaSnapshot, SchemaSnapshot], MigrationPlan]
    | None = None,
    thread_runner: Callable[..., Any] = asyncio.to_thread,
) -> None:
    self._inspector = inspector
    self._url_resolver = url_resolver
    self._reflector = reflector or reflect_database
    self._planner = planner or plan_migration
    self._thread_runner = thread_runner

refresh async

refresh(config, *, generation, previous=None)

Build one refresh generation without blocking the UI loop.

Source code in ormdantic/playground/services.py
async def refresh(
    self,
    config: EffectiveConfig,
    *,
    generation: int,
    previous: SchemaState | None = None,
) -> RefreshResult:
    """Build one refresh generation without blocking the UI loop."""
    target = config.project.target
    if target is None:  # guarded by config loading; retained for typed callers
        raise ValueError("project target is required")
    model_task = asyncio.create_task(
        self._inspector(target, cwd=config.root),
        name=f"ormdantic-model-refresh-{generation}",
    )
    diagnostics: list[Diagnostic] = []
    resolved: DatabaseUrlSource | None = None
    database_task: asyncio.Task[DatabaseRefresh] | None = None
    try:
        resolved = self._url_resolver(config.environment)
    except Exception as exc:
        diagnostics.append(
            Diagnostic.create(
                Severity.ERROR,
                "database.url_missing",
                str(exc),
                hint="Configure the environment variable or dotenv file.",
            )
        )
    else:
        database_task = asyncio.create_task(
            self._thread_runner(self._reflector, resolved.value),
            name=f"ormdantic-database-refresh-{generation}",
        )

    model_result: InspectionResult | None = None
    database_result: DatabaseRefresh | None = None
    model_value = await _task_result(model_task)
    if isinstance(model_value, Exception):
        diagnostics.append(
            Diagnostic.create(
                Severity.ERROR,
                "model.inspection_failed",
                str(model_value),
                hint="Fix the model target and refresh.",
            )
        )
    else:
        model_result = model_value
        diagnostics.extend(model_result.diagnostics)

    if database_task is not None:
        database_value = await _task_result(database_task)
        if isinstance(database_value, Exception):
            diagnostics.append(
                Diagnostic.create(
                    Severity.ERROR,
                    "database.reflection_failed",
                    str(database_value),
                    hint="Check connectivity and database permissions.",
                )
            )
        else:
            database_result = database_value
            diagnostics.extend(database_result.diagnostics)

    fresh_model_snapshot = (
        model_result.snapshot if model_result is not None else None
    )
    fresh_live_snapshot = (
        database_result.live_snapshot if database_result is not None else None
    )
    plan: MigrationPlan | None = None
    if (
        fresh_model_snapshot is not None
        and fresh_live_snapshot is not None
        and resolved is not None
    ):
        try:
            plan = await self._thread_runner(
                self._planner,
                resolved.value,
                fresh_live_snapshot,
                fresh_model_snapshot,
            )
        except Exception as exc:
            diagnostics.append(
                Diagnostic.create(
                    Severity.ERROR,
                    "schema.plan_failed",
                    str(exc),
                    hint="Review dialect support for the reported schema change.",
                )
            )

    available = sum(
        item is not None for item in (fresh_model_snapshot, fresh_live_snapshot)
    )
    has_errors = any(item.severity is Severity.ERROR for item in diagnostics)
    if available == 2 and not has_errors:
        status = RefreshStatus.HEALTHY
    elif available:
        status = RefreshStatus.PARTIAL
    else:
        status = RefreshStatus.ERROR

    model_snapshot = fresh_model_snapshot or (
        previous.model_snapshot if previous is not None else None
    )
    live_snapshot = fresh_live_snapshot or (
        previous.live_snapshot if previous is not None else None
    )

    schema = SchemaState(
        model_snapshot=model_snapshot,
        live_snapshot=live_snapshot,
        diff=plan.diff
        if plan is not None
        else (previous.diff if previous else None),
        forward_sql=(
            tuple(operation.sql for operation in plan.operations)
            if plan
            else (previous.forward_sql if previous else ())
        ),
        rollback_sql=(
            tuple(operation.sql for operation in plan.rollback_operations)
            if plan
            else (previous.rollback_sql if previous else ())
        ),
        refreshed_at=datetime.now(UTC),
        stale=status is not RefreshStatus.HEALTHY,
        diagnostics=tuple(diagnostics),
    )
    migrations = MigrationState(
        history=database_result.history if database_result else (),
        current_revision=(
            database_result.current_revision if database_result else None
        ),
        dirty=database_result.dirty if database_result else False,
    )
    return RefreshResult(
        generation=generation,
        status=status,
        schema=schema,
        migrations=migrations,
        connection_label=resolved.label if resolved is not None else None,
        dialect=_dialect_name(resolved.value) if resolved is not None else None,
        diagnostics=tuple(diagnostics),
    )

reflect_database

reflect_database(url, *, database_factory=Ormdantic, history_exists=None)

Reflect a live schema and read existing migration history in a worker.

Source code in ormdantic/playground/services.py
def reflect_database(
    url: str,
    *,
    database_factory: Callable[[str], Any] = Ormdantic,
    history_exists: Callable[[SchemaSnapshot], bool] | None = None,
) -> DatabaseRefresh:
    """Reflect a live schema and read existing migration history in a worker."""
    database = database_factory(url)
    live_snapshot = database.migrations.live_snapshot()
    if history_exists is not None:
        has_history = history_exists(live_snapshot)
    else:
        has_history = bool(asyncio.run(database.migrations.history_table_exists()))
    if not has_history:
        return DatabaseRefresh(live_snapshot=live_snapshot)
    try:
        history = tuple(asyncio.run(database.migrations.history()))
    except Exception as exc:
        return DatabaseRefresh(
            live_snapshot=live_snapshot,
            diagnostics=(
                Diagnostic.create(
                    Severity.ERROR,
                    "database.history_failed",
                    str(exc),
                    hint="The schema is available; inspect migration history permissions.",
                ),
            ),
        )
    current = next(
        (
            entry.revision
            for entry in reversed(history)
            if entry.status == "applied" and not entry.dirty
        ),
        None,
    )
    return DatabaseRefresh(
        live_snapshot=live_snapshot,
        history=history,
        current_revision=current,
        dirty=any(entry.dirty for entry in history),
    )

plan_migration

plan_migration(url, before, after)

Generate drift SQL using the existing migration manager.

Source code in ormdantic/playground/services.py
def plan_migration(
    url: str,
    before: SchemaSnapshot,
    after: SchemaSnapshot,
) -> MigrationPlan:
    """Generate drift SQL using the existing migration manager."""
    return Ormdantic(url).migrations.generate_plan(before, after, dialect=url)

Watch events

SchemaWatcher polls file metadata and database cadence. It emits coalesced, numbered events.

ormdantic.playground.watcher

Dependency-free schema file and database polling.

WatchReason

Bases: str, Enum

Cause of a requested schema refresh.

INITIAL class-attribute instance-attribute

INITIAL = 'initial'

FILES class-attribute instance-attribute

FILES = 'files'

DATABASE class-attribute instance-attribute

DATABASE = 'database'

RESUMED class-attribute instance-attribute

RESUMED = 'resumed'

MANUAL class-attribute instance-attribute

MANUAL = 'manual'

WatchEvent dataclass

WatchEvent(generation, reasons, paths=())

One coalesced request for a new refresh generation.

generation instance-attribute

generation

reasons instance-attribute

reasons

paths class-attribute instance-attribute

paths = ()

SchemaWatcher

SchemaWatcher(*, root, patterns, database_poll_seconds, debounce_milliseconds, clock=monotonic)

Poll watched files and live-schema cadence without blocking the event loop.

Source code in ormdantic/playground/watcher.py
def __init__(
    self,
    *,
    root: Path,
    patterns: tuple[str, ...],
    database_poll_seconds: float,
    debounce_milliseconds: int,
    clock: Callable[[], float] = time.monotonic,
) -> None:
    self.root = root.resolve()
    self.patterns = patterns
    self.database_poll_seconds = database_poll_seconds
    self.debounce_seconds = debounce_milliseconds / 1000
    self._clock = clock
    self._fingerprints: dict[Path, Fingerprint] = {}
    self._initialized = False
    self._generation = 0
    self._paused = False
    self._resume_pending = False
    self._stopped = False
    self._pending_paths: set[Path] = set()
    self._pending_since: float | None = None
    self._next_database_poll = clock() + database_poll_seconds

root instance-attribute

root = resolve()

patterns instance-attribute

patterns = patterns

database_poll_seconds instance-attribute

database_poll_seconds = database_poll_seconds

debounce_seconds instance-attribute

debounce_seconds = debounce_milliseconds / 1000

paused property

paused

Whether automatic polling is paused.

stopped property

stopped

Whether the run loop has been asked to stop.

pause

pause()

Pause file and database polling.

Source code in ormdantic/playground/watcher.py
def pause(self) -> None:
    """Pause file and database polling."""
    self._paused = True

resume

resume()

Resume polling and request one fresh generation.

Source code in ormdantic/playground/watcher.py
def resume(self) -> None:
    """Resume polling and request one fresh generation."""
    if self._paused:
        self._paused = False
        self._resume_pending = True

stop async

stop()

Request clean run-loop shutdown.

Source code in ormdantic/playground/watcher.py
async def stop(self) -> None:
    """Request clean run-loop shutdown."""
    self._stopped = True

poll

poll()

Perform one non-blocking metadata poll.

Source code in ormdantic/playground/watcher.py
def poll(self) -> WatchEvent | None:
    """Perform one non-blocking metadata poll."""
    if self._stopped or self._paused:
        return None
    now = self._clock()
    current = self._scan()
    if not self._initialized:
        self._initialized = True
        self._fingerprints = current
        self._next_database_poll = now + self.database_poll_seconds
        return self._event(
            (WatchReason.INITIAL,),
            tuple(sorted(current)),
        )
    if self._resume_pending:
        self._resume_pending = False
        self._fingerprints = current
        self._pending_paths.clear()
        self._pending_since = None
        self._next_database_poll = now + self.database_poll_seconds
        return self._event((WatchReason.RESUMED,), ())

    changed = _changed_paths(self._fingerprints, current)
    self._fingerprints = current
    if changed:
        self._pending_paths.update(changed)
        if self._pending_since is None:
            self._pending_since = now

    reasons: list[WatchReason] = []
    if (
        self._pending_since is not None
        and now - self._pending_since >= self.debounce_seconds
    ):
        reasons.append(WatchReason.FILES)
    if now >= self._next_database_poll:
        reasons.append(WatchReason.DATABASE)
        while self._next_database_poll <= now:
            self._next_database_poll += self.database_poll_seconds
    if not reasons:
        return None

    paths = (
        tuple(sorted(self._pending_paths)) if WatchReason.FILES in reasons else ()
    )
    if WatchReason.FILES in reasons:
        self._pending_paths.clear()
        self._pending_since = None
    return self._event(tuple(reasons), paths)

run async

run(emit)

Poll until stopped and publish every coalesced event.

Source code in ormdantic/playground/watcher.py
async def run(
    self,
    emit: Callable[[WatchEvent], Awaitable[None]],
) -> None:
    """Poll until stopped and publish every coalesced event."""
    while not self._stopped:
        event = self.poll()
        if event is not None:
            await emit(event)
        if self._stopped:
            break
        await asyncio.sleep(self._sleep_interval())

Workspace and safety records

Workspace functions parse, edit, convert, draft, and atomically save migration artifacts. Safety functions evaluate immutable action requests against current preflight state.

ormdantic.playground.workspace

Migration artifact discovery, editing, drafts, and atomic persistence.

ArtifactDocument dataclass

ArtifactDocument(path, format, source, artifact, last_valid_artifact=None, diagnostics=(), dirty=False)

One migration file and its current editor state.

path instance-attribute

path

format instance-attribute

format

source instance-attribute

source

artifact instance-attribute

artifact

last_valid_artifact class-attribute instance-attribute

last_valid_artifact = None

diagnostics class-attribute instance-attribute

diagnostics = ()

dirty class-attribute instance-attribute

dirty = False

MigrationWorkspace dataclass

MigrationWorkspace(documents=(), selected_path=None)

Immutable set of migration documents and selection.

documents class-attribute instance-attribute

documents = ()

selected_path class-attribute instance-attribute

selected_path = None

load_workspace

load_workspace(directory)

Load every top-level TOML and JSON migration without hiding errors.

Source code in ormdantic/playground/workspace.py
def load_workspace(directory: Path) -> MigrationWorkspace:
    """Load every top-level TOML and JSON migration without hiding errors."""
    root = directory.resolve()
    if not root.is_dir():
        return MigrationWorkspace()
    paths = sorted(
        (
            path
            for path in root.iterdir()
            if path.is_file() and path.suffix.casefold() in {".toml", ".json"}
        ),
        key=lambda path: path.name,
    )
    documents = [_load_document(path) for path in paths]
    documents = _annotate_revision_graph(documents)
    return MigrationWorkspace(documents=tuple(documents))

select_document

select_document(workspace, path)

Select a known document without mutating the workspace.

Source code in ormdantic/playground/workspace.py
def select_document(
    workspace: MigrationWorkspace,
    path: Path,
) -> MigrationWorkspace:
    """Select a known document without mutating the workspace."""
    resolved = path.resolve()
    if not any(document.path == resolved for document in workspace.documents):
        raise ValueError(f"migration document is not in the workspace: {path}")
    return replace(workspace, selected_path=resolved)

update_source

update_source(document, source)

Parse an edited TOML source while retaining the last valid artifact.

Source code in ormdantic/playground/workspace.py
def update_source(document: ArtifactDocument, source: str) -> ArtifactDocument:
    """Parse an edited TOML source while retaining the last valid artifact."""
    if document.format == "json":
        raise ValueError("Convert to TOML before editing a JSON migration artifact")
    last_valid = document.artifact or document.last_valid_artifact
    try:
        artifact = _parse_edited_source(source, document.format)
    except Exception as exc:
        return replace(
            document,
            source=source,
            artifact=None,
            last_valid_artifact=last_valid,
            diagnostics=(
                Diagnostic.create(
                    Severity.ERROR,
                    "artifact.edit_invalid",
                    str(exc),
                    source=str(document.path),
                    hint="Fix the TOML error before saving or running this migration.",
                ),
            ),
            dirty=True,
        )
    return replace(
        document,
        source=source,
        artifact=artifact,
        last_valid_artifact=artifact,
        diagnostics=(),
        dirty=True,
    )

replace_operation_sql

replace_operation_sql(document, *, index, sql, rollback=False)

Replace one SQL operation and regenerate artifact checksum/source.

Source code in ormdantic/playground/workspace.py
def replace_operation_sql(
    document: ArtifactDocument,
    *,
    index: int,
    sql: str,
    rollback: bool = False,
) -> ArtifactDocument:
    """Replace one SQL operation and regenerate artifact checksum/source."""
    artifact = document.artifact
    if artifact is None:
        raise ValueError("Fix artifact validation errors before editing operation SQL")
    attribute = "rollback_operations" if rollback else "operations"
    operations = [
        operation_from_dict(operation_to_dict(operation))
        for operation in getattr(artifact, attribute)
    ]
    try:
        payload = operation_to_dict(operations[index])
    except IndexError as exc:
        raise IndexError(f"operation index out of range: {index}") from exc
    payload["sql"] = sql
    operations[index] = operation_from_dict(payload)
    updated = replace(artifact, **{attribute: operations}).with_checksum()
    source = updated.to_toml() if document.format == "toml" else updated.to_json()
    return replace(
        document,
        source=source,
        artifact=updated,
        last_valid_artifact=updated,
        diagnostics=(),
        dirty=True,
    )

convert_to_toml

convert_to_toml(document, destination, *, overwrite=False)

Save a valid JSON artifact as a separate canonical TOML document.

Source code in ormdantic/playground/workspace.py
def convert_to_toml(
    document: ArtifactDocument,
    destination: Path,
    *,
    overwrite: bool = False,
) -> ArtifactDocument:
    """Save a valid JSON artifact as a separate canonical TOML document."""
    if document.artifact is None:
        raise ValueError("Cannot convert an invalid migration artifact")
    artifact = document.artifact.with_checksum()
    converted = ArtifactDocument(
        path=destination.resolve(),
        format="toml",
        source=artifact.to_toml(),
        artifact=artifact,
        last_valid_artifact=artifact,
        dirty=True,
    )
    return save_document(converted, overwrite=overwrite)

save_document

save_document(document, *, destination=None, overwrite=False)

Atomically save a valid document with a current checksum.

Source code in ormdantic/playground/workspace.py
def save_document(
    document: ArtifactDocument,
    *,
    destination: Path | None = None,
    overwrite: bool = False,
) -> ArtifactDocument:
    """Atomically save a valid document with a current checksum."""
    artifact = document.artifact
    if artifact is None:
        raise ValueError("Fix artifact validation errors before saving")
    path = (destination or document.path).resolve()
    if path.exists() and path != document.path and not overwrite:
        raise FileExistsError(path)
    format_value: ArtifactFormat = (
        "json" if path.suffix.casefold() == ".json" else "toml"
    )
    artifact = artifact.with_checksum()
    source = artifact.to_json() if format_value == "json" else artifact.to_toml()
    _atomic_write(path, source)
    return ArtifactDocument(
        path=path,
        format=format_value,
        source=source,
        artifact=artifact,
        last_valid_artifact=artifact,
        dirty=False,
    )

draft_path

draft_path(root, document)

Return the project-local recovery path for a document.

Source code in ormdantic/playground/workspace.py
def draft_path(root: Path, document: ArtifactDocument) -> Path:
    """Return the project-local recovery path for a document."""
    artifact = document.artifact or document.last_valid_artifact
    identifier = artifact.revision if artifact is not None else document.path.stem
    safe_identifier = re.sub(r"[^A-Za-z0-9_.-]+", "_", identifier).strip(".")
    if not safe_identifier:
        safe_identifier = "untitled"
    return root.resolve() / ".ormdantic" / "drafts" / f"{safe_identifier}.toml"

write_draft

write_draft(root, document)

Atomically persist the current editor source for crash recovery.

Source code in ormdantic/playground/workspace.py
def write_draft(root: Path, document: ArtifactDocument) -> Path:
    """Atomically persist the current editor source for crash recovery."""
    path = draft_path(root, document)
    _atomic_write(path, document.source)
    return path

recover_draft

recover_draft(root, document)

Load the current recovery source back into the editor document.

Source code in ormdantic/playground/workspace.py
def recover_draft(root: Path, document: ArtifactDocument) -> ArtifactDocument:
    """Load the current recovery source back into the editor document."""
    path = draft_path(root, document)
    source = path.read_text(encoding="utf-8")
    if document.format == "json":
        document = replace(
            document, format="toml", path=document.path.with_suffix(".toml")
        )
    return update_source(document, source)

discard_draft

discard_draft(root, document)

Remove a recovery draft when the caller has confirmed the action.

Source code in ormdantic/playground/workspace.py
def discard_draft(root: Path, document: ArtifactDocument) -> None:
    """Remove a recovery draft when the caller has confirmed the action."""
    try:
        draft_path(root, document).unlink()
    except FileNotFoundError:
        return

ormdantic.playground.safety

Pure preflight and confirmation policy for playground actions.

Risk

Bases: str, Enum

Mutation risk presented to users and confirmation policy.

READ_ONLY class-attribute instance-attribute

READ_ONLY = 'read_only'

WRITE class-attribute instance-attribute

WRITE = 'write'

DESTRUCTIVE class-attribute instance-attribute

DESTRUCTIVE = 'destructive'

HISTORY_REWRITE class-attribute instance-attribute

HISTORY_REWRITE = 'history_rewrite'

ActionRequest dataclass

ActionRequest(action, environment, database_name, target, risk, sql=(), destructive_sql=(), artifact_checksum=None, reviewed_generation=None)

Immutable identity and reviewed SQL for one requested action.

action instance-attribute

action

environment instance-attribute

environment

database_name instance-attribute

database_name

target instance-attribute

target

risk instance-attribute

risk

sql class-attribute instance-attribute

sql = ()

destructive_sql class-attribute instance-attribute

destructive_sql = ()

artifact_checksum class-attribute instance-attribute

artifact_checksum = None

reviewed_generation class-attribute instance-attribute

reviewed_generation = None

PreflightContext dataclass

PreflightContext(connected, target_imported, dialect, artifact_dialect, history_readable, history_dirty, artifact_valid, checksum_valid, dependencies_valid, revision_state_valid, rollback_available, snapshot_current, operations_supported, operation_running, editor_valid, editor_dirty, sql_present, destructive_reviewed, artifact_checksum, generation)

Current state against which an action review is validated.

connected instance-attribute

connected

target_imported instance-attribute

target_imported

dialect instance-attribute

dialect

artifact_dialect instance-attribute

artifact_dialect

history_readable instance-attribute

history_readable

history_dirty instance-attribute

history_dirty

artifact_valid instance-attribute

artifact_valid

checksum_valid instance-attribute

checksum_valid

dependencies_valid instance-attribute

dependencies_valid

revision_state_valid instance-attribute

revision_state_valid

rollback_available instance-attribute

rollback_available

snapshot_current instance-attribute

snapshot_current

operations_supported instance-attribute

operations_supported

operation_running instance-attribute

operation_running

editor_valid instance-attribute

editor_valid

editor_dirty instance-attribute

editor_dirty

sql_present instance-attribute

sql_present

destructive_reviewed instance-attribute

destructive_reviewed

artifact_checksum instance-attribute

artifact_checksum

generation instance-attribute

generation

SafetyDecision dataclass

SafetyDecision(allowed, phrase, reasons=())

Result of current preflight and confirmation validation.

allowed instance-attribute

allowed

phrase instance-attribute

phrase

reasons class-attribute instance-attribute

reasons = ()

classify_plan

classify_plan(plan)

Classify migration SQL using authoritative plan metadata.

Source code in ormdantic/playground/safety.py
def classify_plan(plan: MigrationPlan) -> Risk:
    """Classify migration SQL using authoritative plan metadata."""
    if plan.has_destructive_operations:
        return Risk.DESTRUCTIVE
    if plan.operations:
        return Risk.WRITE
    return Risk.READ_ONLY

evaluate_action

evaluate_action(request, environment, context, *, confirmed=False, confirmation=None)

Evaluate preflight and the non-cacheable confirmation for one action.

Source code in ormdantic/playground/safety.py
def evaluate_action(
    request: ActionRequest,
    environment: EnvironmentConfig,
    context: PreflightContext,
    *,
    confirmed: bool = False,
    confirmation: str | None = None,
) -> SafetyDecision:
    """Evaluate preflight and the non-cacheable confirmation for one action."""
    reasons = list(_preflight_reasons(request, environment, context))
    phrase = _confirmation_phrase(request, environment)
    if request.risk is not Risk.READ_ONLY:
        if not confirmed:
            reasons.append(
                f"Confirm {request.action} for {request.environment}:{request.target}"
            )
        if phrase is not None and confirmation != phrase:
            reasons.append(f"Type the exact confirmation phrase: {phrase}")
    return SafetyDecision(
        allowed=not reasons,
        phrase=phrase,
        reasons=tuple(reasons),
    )

Controller and operations

PlaygroundController owns immutable state and exposes intents. MigrationOperations enforces approved request identity before calling the migration manager.

ormdantic.playground.controller

State and intent coordinator for the playground application.

ControllerActionOutcome dataclass

ControllerActionOutcome(decision, executed, result=None, error=None)

Safety and execution result returned to a view.

decision instance-attribute

decision

executed instance-attribute

executed

result class-attribute instance-attribute

result = None

error class-attribute instance-attribute

error = None

PlaygroundController

PlaygroundController(config, *, refresh_service=None, operations=None)

Own application state and make services available through safe intents.

Source code in ormdantic/playground/controller.py
def __init__(
    self,
    config: EffectiveConfig,
    *,
    refresh_service: Any | None = None,
    operations: Any | None = None,
) -> None:
    self.config = config
    self.refresh_service = refresh_service or RefreshService()
    self.operations = operations or MigrationOperations(config)
    self.workspace = load_workspace(config.project.migrations_dir)
    self.state = PlaygroundState(
        environment=config.environment.name,
        migrations=_migration_state(self.workspace),
    )
    self._refresh_lock = asyncio.Lock()
    self._subscribers: list[Callable[[PlaygroundState], None]] = []

config instance-attribute

config = config

refresh_service instance-attribute

refresh_service = refresh_service or RefreshService()

operations instance-attribute

operations = operations or MigrationOperations(config)

workspace instance-attribute

workspace = load_workspace(migrations_dir)

state instance-attribute

state = PlaygroundState(environment=name, migrations=_migration_state(workspace))

active_document property

active_document

Return the selected migration document, if any.

subscribe

subscribe(callback)

Publish future state snapshots and return an unsubscribe callback.

Source code in ormdantic/playground/controller.py
def subscribe(
    self,
    callback: Callable[[PlaygroundState], None],
) -> Callable[[], None]:
    """Publish future state snapshots and return an unsubscribe callback."""
    self._subscribers.append(callback)

    def unsubscribe() -> None:
        try:
            self._subscribers.remove(callback)
        except ValueError:
            return

    return unsubscribe

refresh async

refresh(*, generation=None)

Run and publish a generation-safe schema refresh.

Source code in ormdantic/playground/controller.py
async def refresh(self, *, generation: int | None = None) -> PlaygroundState:
    """Run and publish a generation-safe schema refresh."""
    async with self._refresh_lock:
        next_generation = max(self.state.generation + 1, generation or 0)
        self.state = replace(
            self.state,
            generation=next_generation,
            status=RefreshStatus.RUNNING,
        )
        self._publish()
        result = await self.refresh_service.refresh(
            self.config,
            generation=next_generation,
            previous=self.state.schema,
        )
        updated = accept_refresh(self.state, result)
        updated = replace(
            updated,
            migrations=_migration_state(self.workspace, updated.migrations),
        )
        self.state = updated
        self._publish()
        return self.state

reload_workspace

reload_workspace()

Reload migration files while preserving a still-valid selection.

Source code in ormdantic/playground/controller.py
def reload_workspace(self) -> MigrationWorkspace:
    """Reload migration files while preserving a still-valid selection."""
    selected = self.workspace.selected_path
    workspace = load_workspace(self.config.project.migrations_dir)
    if selected is not None and any(
        document.path == selected for document in workspace.documents
    ):
        workspace = select_document(workspace, selected)
    self.workspace = workspace
    self._sync_workspace_state()
    return workspace

select_artifact

select_artifact(path)

Select a migration for review and editing.

Source code in ormdantic/playground/controller.py
def select_artifact(self, path: Path) -> None:
    """Select a migration for review and editing."""
    if self.workspace.selected_path == path.resolve():
        return
    self.workspace = select_document(self.workspace, path)
    self._sync_workspace_state()

edit_active_source

edit_active_source(source)

Apply a TOML source edit to the selected document.

Source code in ormdantic/playground/controller.py
def edit_active_source(self, source: str) -> ArtifactDocument:
    """Apply a TOML source edit to the selected document."""
    active = self._require_active_document()
    updated = update_source(active, source)
    self._replace_document(updated)
    return updated

edit_active_sql

edit_active_sql(index, sql, *, rollback=False)

Apply a selected operation SQL edit.

Source code in ormdantic/playground/controller.py
def edit_active_sql(
    self,
    index: int,
    sql: str,
    *,
    rollback: bool = False,
) -> ArtifactDocument:
    """Apply a selected operation SQL edit."""
    active = self._require_active_document()
    updated = replace_operation_sql(
        active,
        index=index,
        sql=sql,
        rollback=rollback,
    )
    self._replace_document(updated)
    return updated

save_active

save_active(*, destination=None, overwrite=False)

Atomically persist the selected valid editor document.

Source code in ormdantic/playground/controller.py
def save_active(
    self,
    *,
    destination: Path | None = None,
    overwrite: bool = False,
) -> ArtifactDocument:
    """Atomically persist the selected valid editor document."""
    saved = save_document(
        self._require_active_document(),
        destination=destination,
        overwrite=overwrite,
    )
    self._replace_document(saved)
    if destination is not None:
        self.workspace = replace(self.workspace, selected_path=saved.path)
    self._sync_workspace_state()
    return saved

convert_active_to_toml

convert_active_to_toml(destination=None, *, overwrite=False)

Convert selected legacy JSON to a separate TOML artifact.

Source code in ormdantic/playground/controller.py
def convert_active_to_toml(
    self,
    destination: Path | None = None,
    *,
    overwrite: bool = False,
) -> ArtifactDocument:
    """Convert selected legacy JSON to a separate TOML artifact."""
    active = self._require_active_document()
    target = destination or active.path.with_suffix(".toml")
    converted = convert_to_toml(active, target, overwrite=overwrite)
    self.reload_workspace()
    self.workspace = select_document(self.workspace, converted.path)
    self._sync_workspace_state()
    return self._require_active_document()

write_active_draft

write_active_draft()

Persist selected editor source in project-local recovery storage.

Source code in ormdantic/playground/controller.py
def write_active_draft(self) -> Path:
    """Persist selected editor source in project-local recovery storage."""
    return write_draft(self.config.root, self._require_active_document())

recover_active_draft

recover_active_draft()

Restore selected editor source from its recovery draft.

Source code in ormdantic/playground/controller.py
def recover_active_draft(self) -> ArtifactDocument:
    """Restore selected editor source from its recovery draft."""
    recovered = recover_draft(
        self.config.root,
        self._require_active_document(),
    )
    self._replace_document(recovered)
    return recovered

discard_active_draft

discard_active_draft()

Discard selected recovery source after UI confirmation.

Source code in ormdantic/playground/controller.py
def discard_active_draft(self) -> None:
    """Discard selected recovery source after UI confirmation."""
    discard_draft(self.config.root, self._require_active_document())

build_action_request

build_action_request(action, *, database_name)

Bind an action review to current artifact content and generation.

Source code in ormdantic/playground/controller.py
def build_action_request(
    self,
    action: str,
    *,
    database_name: str,
) -> ActionRequest:
    """Bind an action review to current artifact content and generation."""
    document = self._require_active_document()
    artifact = document.artifact
    if artifact is None:
        raise ValueError("selected migration artifact is invalid")
    if action == "apply":
        operations = artifact.operations
        plan = MigrationPlan(operations=list(operations))
    elif action == "rollback":
        operations = artifact.rollback_operations
        plan = MigrationPlan(
            operations=list(operations),
            diff=artifact.diff,
            warnings=artifact.warnings,
            safety=artifact.safety,
        )
    else:
        raise ValueError(f"unsupported artifact action: {action}")
    risk = classify_plan(plan)
    return ActionRequest(
        action=action,
        environment=self.config.environment.name,
        database_name=database_name,
        target=artifact.revision,
        risk=risk,
        sql=tuple(operation.sql for operation in operations),
        destructive_sql=tuple(
            operation.sql for operation in operations if operation.destructive
        ),
        artifact_checksum=artifact.checksum,
        reviewed_generation=self.state.generation,
    )

generate_migration async

generate_migration(revision, description=None)

Generate a canonical TOML artifact from the current fresh drift.

Source code in ormdantic/playground/controller.py
async def generate_migration(
    self,
    revision: str,
    description: str | None = None,
) -> Path | None:
    """Generate a canonical TOML artifact from the current fresh drift."""
    before = self.state.schema.live_snapshot
    after = self.state.schema.model_snapshot
    if before is None or after is None or self.state.schema.stale:
        raise ValueError("refresh model and live schemas before generating")
    if not self.state.schema.forward_sql:
        raise ValueError("there is no schema drift to generate")
    self.state = replace(
        self.state,
        operation=OperationState(
            name="generate",
            target=revision,
            running=True,
            message=f"Generating {revision}",
        ),
    )
    self._publish()
    try:
        path = await self.operations.generate(
            revision,
            description,
            before=before,
            after=after,
            depends_on=(
                (self.state.migrations.current_revision,)
                if self.state.migrations.current_revision
                else ()
            ),
        )
    except Exception as exc:
        self._publish_operation_failure("generate", revision, exc)
        raise
    if path is not None:
        self.reload_workspace()
        self.select_artifact(path)
    self.state = replace(
        self.state,
        operation=OperationState(
            name="generate",
            target=revision,
            message=(
                f"Generated {revision}"
                if path is not None
                else "No migration generated"
            ),
        ),
    )
    self._publish()
    return path

build_repair_request

build_repair_request(revision, *, database_name)

Bind a dirty-history repair to the selected row and generation.

Source code in ormdantic/playground/controller.py
def build_repair_request(
    self,
    revision: str,
    *,
    database_name: str,
) -> ActionRequest:
    """Bind a dirty-history repair to the selected row and generation."""
    entry = next(
        (
            item
            for item in self.state.migrations.history
            if item.revision == revision
        ),
        None,
    )
    if entry is None:
        raise ValueError(f"history revision is not available: {revision}")
    if not entry.dirty:
        raise ValueError(f"history revision is not dirty: {revision}")
    return ActionRequest(
        action="repair",
        environment=self.config.environment.name,
        database_name=database_name,
        target=revision,
        risk=Risk.HISTORY_REWRITE,
        sql=(f"clear dirty flag; preserve status {entry.status}",),
        reviewed_generation=self.state.generation,
    )

execute_repair async

execute_repair(request, context, *, status, confirmed, confirmation)

Repair one dirty history row after an exact typed review.

Source code in ormdantic/playground/controller.py
async def execute_repair(
    self,
    request: ActionRequest,
    context: PreflightContext,
    *,
    status: str,
    confirmed: bool,
    confirmation: str | None,
) -> ControllerActionOutcome:
    """Repair one dirty history row after an exact typed review."""
    decision = evaluate_action(
        request,
        self.config.environment,
        context,
        confirmed=confirmed,
        confirmation=confirmation,
    )
    if not decision.allowed:
        return ControllerActionOutcome(decision=decision, executed=False)
    self.state = replace(
        self.state,
        operation=OperationState(
            name="repair",
            target=request.target,
            running=True,
            message=f"Repairing {request.target}",
        ),
    )
    self._publish()
    try:
        result = await self.operations.repair(
            request.target,
            status,
            request,
            decision,
        )
    except Exception as exc:
        diagnostic = self._publish_operation_failure(
            "repair",
            request.target,
            exc,
        )
        return ControllerActionOutcome(
            decision=decision,
            executed=True,
            error=diagnostic,
        )
    await self.refresh()
    self.state = replace(
        self.state,
        operation=OperationState(
            name="repair",
            target=request.target,
            message=f"Repaired {request.target}",
        ),
    )
    self._publish()
    return ControllerActionOutcome(decision, True, result)

build_squash_request

build_squash_request(paths, revision, *, database_name)

Bind a local squash preview to exact pending artifact checksums.

Source code in ormdantic/playground/controller.py
def build_squash_request(
    self,
    paths: tuple[Path, ...],
    revision: str,
    *,
    database_name: str,
) -> ActionRequest:
    """Bind a local squash preview to exact pending artifact checksums."""
    documents = self._squash_documents(paths)
    artifacts = cast(
        tuple[MigrationArtifact, ...],
        tuple(document.artifact for document in documents),
    )
    sql = tuple(
        operation.sql for artifact in artifacts for operation in artifact.operations
    )
    return ActionRequest(
        action="squash",
        environment=self.config.environment.name,
        database_name=database_name,
        target=revision,
        risk=Risk.HISTORY_REWRITE,
        sql=sql,
        destructive_sql=tuple(
            operation.sql
            for artifact in artifacts
            for operation in artifact.operations
            if operation.destructive
        ),
        artifact_checksum=_workspace_checksum(documents),
        reviewed_generation=self.state.generation,
    )

execute_squash async

execute_squash(request, context, paths, *, confirmed, confirmation)

Write a squashed artifact only if the reviewed inputs are unchanged.

Source code in ormdantic/playground/controller.py
async def execute_squash(
    self,
    request: ActionRequest,
    context: PreflightContext,
    paths: tuple[Path, ...],
    *,
    confirmed: bool,
    confirmation: str | None,
) -> ControllerActionOutcome:
    """Write a squashed artifact only if the reviewed inputs are unchanged."""
    decision = evaluate_action(
        request,
        self.config.environment,
        context,
        confirmed=confirmed,
        confirmation=confirmation,
    )
    if not decision.allowed:
        return ControllerActionOutcome(decision=decision, executed=False)
    documents = self._squash_documents(paths)
    if request.artifact_checksum != _workspace_checksum(documents):
        decision = replace(
            decision,
            allowed=False,
            reasons=(*decision.reasons, "squash inputs changed after review"),
        )
        return ControllerActionOutcome(decision=decision, executed=False)
    self.state = replace(
        self.state,
        operation=OperationState(
            name="squash",
            target=request.target,
            running=True,
            message=f"Squashing into {request.target}",
        ),
    )
    self._publish()
    try:
        path = await self.operations.squash(
            paths,
            request.target,
            request,
            decision,
        )
    except Exception as exc:
        diagnostic = self._publish_operation_failure(
            "squash",
            request.target,
            exc,
        )
        return ControllerActionOutcome(
            decision=decision,
            executed=True,
            error=diagnostic,
        )
    self.reload_workspace()
    self.select_artifact(path)
    self.state = replace(
        self.state,
        operation=OperationState(
            name="squash",
            target=request.target,
            message=f"Created squash {request.target}",
        ),
    )
    self._publish()
    return ControllerActionOutcome(decision, True, path)

execute_action async

execute_action(request, context, *, confirmed=False, confirmation=None)

Evaluate, execute, refresh, and only then report completion.

Source code in ormdantic/playground/controller.py
async def execute_action(
    self,
    request: ActionRequest,
    context: PreflightContext,
    *,
    confirmed: bool = False,
    confirmation: str | None = None,
) -> ControllerActionOutcome:
    """Evaluate, execute, refresh, and only then report completion."""
    decision = evaluate_action(
        request,
        self.config.environment,
        context,
        confirmed=confirmed,
        confirmation=confirmation,
    )
    if not decision.allowed:
        return ControllerActionOutcome(decision=decision, executed=False)
    document = self._require_active_document()
    self.state = replace(
        self.state,
        operation=OperationState(
            name=request.action,
            target=request.target,
            running=True,
            message=f"Running {request.action} for {request.target}",
        ),
    )
    self._publish()
    try:
        if request.action == "apply":
            result = await self.operations.apply(document, request, decision)
        elif request.action == "rollback":
            result = await self.operations.rollback(document, request, decision)
        else:
            raise ValueError(f"unsupported controller action: {request.action}")
    except Exception as exc:
        diagnostic = self._publish_operation_failure(
            request.action,
            request.target,
            exc,
        )
        return ControllerActionOutcome(
            decision=decision,
            executed=True,
            error=diagnostic,
        )

    self.reload_workspace()
    await self.refresh()
    verb = "Applied" if request.action == "apply" else "Rolled back"
    self.state = replace(
        self.state,
        operation=OperationState(
            name=request.action,
            target=request.target,
            running=False,
            message=f"{verb} {request.target}",
        ),
    )
    self._publish()
    return ControllerActionOutcome(
        decision=decision,
        executed=True,
        result=result,
    )

ormdantic.playground.operations

Authorized migration operations shared by the playground UI.

MigrationOperations

MigrationOperations(config, *, url_resolver=resolve_database_url, database_factory=Ormdantic, thread_runner=to_thread, artifact_generator=None)

Run migration APIs in worker threads after exact safety authorization.

Source code in ormdantic/playground/operations.py
def __init__(
    self,
    config: EffectiveConfig,
    *,
    url_resolver: Callable[..., DatabaseUrlSource] = resolve_database_url,
    database_factory: Callable[[str], Any] = Ormdantic,
    thread_runner: Callable[..., Any] = asyncio.to_thread,
    artifact_generator: Callable[..., MigrationArtifact | None] | None = None,
) -> None:
    self.config = config
    self._url_resolver = url_resolver
    self._database_factory = database_factory
    self._thread_runner = thread_runner
    self._artifact_generator = artifact_generator or _generate_artifact

config instance-attribute

config = config

generate async

generate(revision, description, *, before, after, depends_on=())

Generate and atomically save a canonical TOML artifact.

Source code in ormdantic/playground/operations.py
async def generate(
    self,
    revision: str,
    description: str | None,
    *,
    before: SchemaSnapshot,
    after: SchemaSnapshot,
    depends_on: Sequence[str] = (),
) -> Path | None:
    """Generate and atomically save a canonical TOML artifact."""
    _validate_revision(revision)
    resolved = self._url_resolver(self.config.environment)
    generate = partial(
        self._artifact_generator,
        revision,
        before,
        after,
        dialect=resolved.value,
        description=description,
        depends_on=tuple(depends_on),
    )
    artifact = await self._thread_runner(generate)
    if artifact is None:
        return None
    destination = self.config.project.migrations_dir / f"{revision}.toml"
    document = ArtifactDocument(
        path=destination,
        format="toml",
        source=artifact.to_toml(),
        artifact=artifact,
        last_valid_artifact=artifact,
        dirty=True,
    )
    return save_document(document).path

apply async

apply(document, request, decision)

Apply the exact artifact covered by an allowed safety decision.

Source code in ormdantic/playground/operations.py
async def apply(
    self,
    document: ArtifactDocument,
    request: ActionRequest,
    decision: SafetyDecision,
) -> bool:
    """Apply the exact artifact covered by an allowed safety decision."""
    artifact = _authorized_artifact(document, request, decision, "apply")
    risk = classify_plan(artifact.to_plan())
    _validate_risk(request.risk, risk)
    resolved = self._url_resolver(self.config.environment)
    return await self._thread_runner(
        _apply_sync,
        self._database_factory,
        resolved.value,
        artifact,
        risk is Risk.DESTRUCTIVE,
    )

rollback async

rollback(document, request, decision)

Roll back the exact artifact covered by an allowed decision.

Source code in ormdantic/playground/operations.py
async def rollback(
    self,
    document: ArtifactDocument,
    request: ActionRequest,
    decision: SafetyDecision,
) -> bool:
    """Roll back the exact artifact covered by an allowed decision."""
    artifact = _authorized_artifact(document, request, decision, "rollback")
    rollback_plan = MigrationPlan(
        operations=list(artifact.rollback_operations),
        diff=artifact.diff,
        warnings=artifact.warnings,
        safety=artifact.safety,
    )
    risk = classify_plan(rollback_plan)
    _validate_risk(request.risk, risk)
    resolved = self._url_resolver(self.config.environment)
    return await self._thread_runner(
        _rollback_sync,
        self._database_factory,
        resolved.value,
        artifact,
        risk is Risk.DESTRUCTIVE,
    )

repair async

repair(revision, status, request, decision)

Repair selected history metadata after authorization.

Source code in ormdantic/playground/operations.py
async def repair(
    self,
    revision: str,
    status: str,
    request: ActionRequest,
    decision: SafetyDecision,
) -> int:
    """Repair selected history metadata after authorization."""
    _authorized_request(request, decision, "repair", revision)
    resolved = self._url_resolver(self.config.environment)
    return await self._thread_runner(
        _repair_sync,
        self._database_factory,
        resolved.value,
        revision,
        status,
    )

squash async

squash(paths, revision, request, decision)

Squash a reviewed contiguous selection into a new TOML artifact.

Source code in ormdantic/playground/operations.py
async def squash(
    self,
    paths: Sequence[Path],
    revision: str,
    request: ActionRequest,
    decision: SafetyDecision,
) -> Path:
    """Squash a reviewed contiguous selection into a new TOML artifact."""
    _validate_revision(revision)
    _authorized_request(request, decision, "squash", revision)
    artifacts = tuple(MigrationArtifact.read(path) for path in paths)
    resolved = self._url_resolver(self.config.environment)
    squashed = await self._thread_runner(
        _squash_sync,
        self._database_factory,
        resolved.value,
        revision,
        artifacts,
    )
    destination = self.config.project.migrations_dir / f"{revision}.toml"
    document = ArtifactDocument(
        path=destination,
        format="toml",
        source=squashed.to_toml(),
        artifact=squashed,
        last_valid_artifact=squashed,
        dirty=True,
    )
    return save_document(document).path

history async

history()

Read durable history in the blocking worker boundary.

Source code in ormdantic/playground/operations.py
async def history(self) -> tuple[MigrationHistoryEntry, ...]:
    """Read durable history in the blocking worker boundary."""
    resolved = self._url_resolver(self.config.environment)
    return await self._thread_runner(
        _history_sync,
        self._database_factory,
        resolved.value,
    )