Files
DumiFlex-ComfyUI-Wildcard-P…/engine/db/migrations.py
T
DumiFlex 6921606570 feat(context): module picker + lock/internal + card indicators (#8)
Phase 5.5.4 (SPA library picker for ContextWidget) plus QA-driven polish.

* New ModulePickerModal with search, kind tabs, single + multi-pick, and
  compact filter UX (favorites/hide-added inline; category + tag chips
  behind popover with active-count badge).
* ModuleEditModal kind-aware body — wildcard option editor with
  enable/disable, weight overrides, mode picker (random/subset/pinned),
  sub-category multi-select.
* Lock + internal per-instance overrides ported and adapted: locked_seed
  derives a per-wildcard rng via sha256(seed:binding); internal hides
  bindings from the public PIPELINE_CONTEXT payload.
* Card indicators: modified dot (option-pool overrides), missing-from-
  library dot (polled hash check), in-card lock + internal toggles.
* RNG-faithful assembler preview via new /wp/api/preview/resolve; header
  shows "PREVIEW · 42". UNRESOLVED chips clickable to strip $var.
* Bulk collapse/expand toggle, focus toast w/ pulsing border overlay
  that tracks node bounds, friendly @name substitution in modal.
* Backend: new /wp/api/modules/import-from-workflow endpoint preserves
  uuid; engine emits per-module effective seeds via WP_Context UI
  payload for authoritative lock-toggle defaults.
2026-04-30 00:53:13 +03:00

103 lines
3.6 KiB
Python

"""Versioned, forward-only migration runner.
Migration files live in ``engine/db/migrations_sql/<NNN>_<name>.<ext>``
where ``<ext>`` is ``.sql`` (executed verbatim) or ``.py`` (imported and
its top-level ``up(conn)`` callable invoked). Filenames must start with
a 3-digit zero-padded version number; the runner applies them in
numeric order.
Python migrations are useful when a transformation needs to read row
payloads, mutate JSON, or otherwise do work SQL alone can't express
cleanly. The ``.py`` file is imported via ``importlib.util`` so it
can sit next to the ``.sql`` files without polluting the package's
import graph.
"""
from __future__ import annotations
import importlib.util
import re
import sqlite3
from collections.abc import Callable
from pathlib import Path
from engine._utils import now_iso
_SQL_DIR = Path(__file__).parent / "migrations_sql"
_VERSION_RE = re.compile(r"^(\d{3})_.+\.(sql|py)$")
def _migrations_table_exists(conn: sqlite3.Connection) -> bool:
row = conn.execute(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name='migrations';"
).fetchone()
return row is not None
def current_version(conn: sqlite3.Connection) -> int:
if not _migrations_table_exists(conn):
return 0
row = conn.execute("SELECT MAX(version) FROM migrations;").fetchone()
return int(row[0]) if row and row[0] is not None else 0
def _discover() -> list[tuple[int, Path]]:
pairs: list[tuple[int, Path]] = []
for entry in _SQL_DIR.iterdir():
if not entry.is_file():
continue
m = _VERSION_RE.match(entry.name)
if not m:
continue
pairs.append((int(m.group(1)), entry))
pairs.sort(key=lambda p: p[0])
return pairs
def _load_py_migration(path: Path) -> Callable[[sqlite3.Connection], object]:
"""Import a `.py` migration file and return its `up` callable.
The module is loaded via ``importlib.util.spec_from_file_location``
so it doesn't have to be importable through a normal package path —
callers can drop a script into ``migrations_sql/`` without adding
it to ``__init__.py``. Return value of `up` is ignored.
"""
spec = importlib.util.spec_from_file_location(f"_mig_{path.stem}", path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load migration module at {path}")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
up = getattr(mod, "up", None)
if not callable(up):
raise RuntimeError(
f"Migration {path.name} must export a top-level `up(conn)` function."
)
return up # type: ignore[no-any-return]
def migrate(conn: sqlite3.Connection) -> None:
"""Apply all pending migrations. Safe to call repeatedly."""
applied = current_version(conn)
for version, mig_file in _discover():
if version <= applied:
continue
if mig_file.suffix == ".py":
up = _load_py_migration(mig_file)
with conn:
up(conn)
conn.execute(
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
(version, now_iso()),
)
else:
sql = mig_file.read_text(encoding="utf-8")
with conn:
for stmt in sql.split(";"):
stmt = stmt.strip()
if stmt:
conn.execute(stmt)
conn.execute(
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
(version, now_iso()),
)