feat(db): sqlite library foundation + migration runner

This commit is contained in:
DumiFlex
2026-04-26 15:26:59 +03:00
parent c5d11c03ec
commit e4aabe6313
9 changed files with 234 additions and 0 deletions
+16
View File
@@ -12,6 +12,8 @@ import importlib.util
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
@@ -23,4 +25,18 @@ if importlib.util.find_spec("comfy_api") is None:
sys.modules.setdefault("comfy_api.latest", _stub)
sys.modules.setdefault("comfy_api.latest._io", _stub.io._io)
from engine.db.connection import get_connection # noqa: E402
from engine.db.migrations import migrate # noqa: E402
collect_ignore: list[str] = []
@pytest.fixture
def wp_db(tmp_path):
"""Fresh, migrated SQLite DB. Yields a sqlite3.Connection."""
conn = get_connection(tmp_path / "wp.db")
migrate(conn)
try:
yield conn
finally:
conn.close()
+4
View File
@@ -0,0 +1,4 @@
"""SQLite-backed library DB."""
from engine.db.connection import get_connection, resolve_db_path
__all__ = ["get_connection", "resolve_db_path"]
+33
View File
@@ -0,0 +1,33 @@
"""SQLite connection factory.
DB path resolution order:
1. ``WP_DB_PATH`` env var (explicit override)
2. ``$COMFYUI_USER_DIR/wildcard-pipeline.db``
3. Fallback: ``~/.comfyui/wildcard-pipeline.db``
"""
from __future__ import annotations
import os
import sqlite3
from pathlib import Path
def resolve_db_path() -> Path:
override = os.environ.get("WP_DB_PATH")
if override:
return Path(override)
user_dir = os.environ.get("COMFYUI_USER_DIR")
if user_dir:
return Path(user_dir) / "wildcard-pipeline.db"
return Path.home() / ".comfyui" / "wildcard-pipeline.db"
def get_connection(path: Path | None = None) -> sqlite3.Connection:
"""Open a SQLite connection with WAL mode and FK enforcement."""
db_path = path or resolve_db_path()
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path), check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("PRAGMA foreign_keys=ON;")
return conn
+58
View File
@@ -0,0 +1,58 @@
"""Versioned, forward-only migration runner.
SQL files live in ``engine/db/migrations_sql/<NNN>_<name>.sql``. Filenames
must start with a 3-digit zero-padded version number; the runner applies
them in numeric order.
"""
from __future__ import annotations
import datetime as _dt
import re
import sqlite3
from pathlib import Path
_SQL_DIR = Path(__file__).parent / "migrations_sql"
_VERSION_RE = re.compile(r"^(\d{3})_.+\.sql$")
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 migrate(conn: sqlite3.Connection) -> None:
"""Apply all pending migrations. Safe to call repeatedly."""
applied = current_version(conn)
for version, sql_file in _discover():
if version <= applied:
continue
sql = sql_file.read_text(encoding="utf-8")
with conn:
conn.executescript(sql)
conn.execute(
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
(version, _dt.datetime.utcnow().isoformat(timespec="seconds") + "Z"),
)
+30
View File
@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS module_categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
color TEXT,
icon TEXT,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS modules (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
category_id TEXT REFERENCES module_categories(id) ON DELETE SET NULL,
tags TEXT NOT NULL DEFAULT '[]',
is_favorite INTEGER NOT NULL DEFAULT 0,
payload TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_modules_type ON modules(type);
CREATE INDEX IF NOT EXISTS idx_modules_category ON modules(category_id);
CREATE INDEX IF NOT EXISTS idx_modules_updated_at ON modules(updated_at DESC);
View File
View File
+42
View File
@@ -0,0 +1,42 @@
"""Tests for SQLite connection factory."""
import sqlite3
from engine.db.connection import get_connection, resolve_db_path
def test_resolve_db_path_uses_override(tmp_path, monkeypatch):
monkeypatch.setenv("WP_DB_PATH", str(tmp_path / "custom.db"))
assert resolve_db_path() == tmp_path / "custom.db"
def test_resolve_db_path_default_under_user_dir(tmp_path, monkeypatch):
monkeypatch.delenv("WP_DB_PATH", raising=False)
monkeypatch.setenv("COMFYUI_USER_DIR", str(tmp_path))
assert resolve_db_path() == tmp_path / "wildcard-pipeline.db"
def test_get_connection_returns_sqlite3_connection(tmp_path):
conn = get_connection(tmp_path / "test.db")
assert isinstance(conn, sqlite3.Connection)
conn.close()
def test_get_connection_enables_wal_mode(tmp_path):
conn = get_connection(tmp_path / "wal.db")
mode = conn.execute("PRAGMA journal_mode;").fetchone()[0]
assert mode == "wal"
conn.close()
def test_get_connection_enforces_foreign_keys(tmp_path):
conn = get_connection(tmp_path / "fk.db")
fk = conn.execute("PRAGMA foreign_keys;").fetchone()[0]
assert fk == 1
conn.close()
def test_get_connection_creates_parent_dir(tmp_path):
target = tmp_path / "nested" / "deeper" / "wp.db"
conn = get_connection(target)
assert target.parent.exists()
conn.close()
+51
View File
@@ -0,0 +1,51 @@
"""Tests for the versioned migration runner."""
from engine.db.connection import get_connection
from engine.db.migrations import current_version, migrate
def test_initial_version_is_zero(tmp_path):
conn = get_connection(tmp_path / "v.db")
assert current_version(conn) == 0
conn.close()
def test_migrate_creates_modules_and_categories_and_migrations(tmp_path):
conn = get_connection(tmp_path / "m.db")
migrate(conn)
tables = {
row[0]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table';"
).fetchall()
}
assert "modules" in tables
assert "module_categories" in tables
assert "migrations" in tables
conn.close()
def test_migrate_records_version(tmp_path):
conn = get_connection(tmp_path / "v2.db")
migrate(conn)
assert current_version(conn) == 1
conn.close()
def test_migrate_is_idempotent(tmp_path):
conn = get_connection(tmp_path / "i.db")
migrate(conn)
migrate(conn)
assert current_version(conn) == 1
conn.close()
def test_modules_table_has_expected_columns(tmp_path):
conn = get_connection(tmp_path / "c.db")
migrate(conn)
cols = {row[1] for row in conn.execute("PRAGMA table_info(modules);")}
assert cols == {
"id", "type", "name", "description", "category_id",
"tags", "is_favorite", "payload", "version",
"created_at", "updated_at",
}
conn.close()