Files
DumiFlex-ComfyUI-Wildcard-P…/engine/importer.py
T
DumiFlex 395af63b1a feat(engine): content_rating column on library rows (migration 015)
Adds `content_rating TEXT NOT NULL DEFAULT 'safe'` to modules + bundles.
Defaults all pre-015 rows to 'safe'. Repository create/update accept a
new content_rating kwarg with validation against {'safe', 'nsfw'}.
Importer _insert_module / _insert_bundle pass entity.content_rating
through; _update_module / _update_bundle use COALESCE(?, content_rating)
so an install path that omits the key preserves the existing flag.

Why: user wants two NSFW behaviors:
1. Edit page toggle to mark a module/bundle as NSFW locally.
2. Community posts with content_rating='nsfw' auto-stamp NSFW on
   the local row at install time.

This is the engine half of that work -- the schema, the data
plumbing, and validation. The SPA half (IdentityCard toggle + pill
render on ModuleListView + install.ts plumbing) lands next.

Tests cover: migration head bump to 15, column existence on both
tables, safe default backfill, accepts nsfw, rejects invalid, update
toggle round-trip, update without content_rating preserves prior.
2026-06-06 05:22:02 +03:00

899 lines
37 KiB
Python

"""Atomic import commit + snapshot-based undo.
Receives a classified commit payload from the SPA (Task 15 client-side
classifier produces it from a diff vs. live DB), and applies the three
operation buckets — adds, replaces, renames — inside a single SQLite
transaction. If any row write raises (e.g. PK collision), the entire
commit rolls back and no undo entry is created.
For every successful commit we persist a row in ``import_undo`` that
carries enough information to reverse the commit: the kind+id of every
inserted record, and the pre-replace row contents (with kind) of every
replaced record. ``undo_import`` deletes the inserts and restores the
snapshots, then evicts the undo row.
Commit payload shape (received)::
{
"adds": [{"kind": ..., "entity": {...full row...}}, ...],
"replaces": [{"kind": ..., "id": ..., "new_content": {...}}, ...],
"renames": [{"kind": ..., "old_id": ..., "new_id": ..., "content": {...}}, ...]
}
``kind`` is one of: ``wildcard``, ``fixed_values``, ``combine``,
``derivation``, ``constraint``, ``bundle``, ``category``, ``template``.
The first five are dispatched to the ``modules`` table (discriminated by
the ``type`` column). Templates have their own ``templates`` table and
support add/replace/rename like bundles. Categories appear only in
``adds`` (name-based merge, no replace/rename concept).
Undo metadata shape (persisted in ``import_undo`` row)::
imported_records: list[{"kind": str, "id": str}]
— records added or renamed; undo deletes each.
replaced_snapshots: dict[id, {"kind": str, "row": {... full pre-replace ...}}]
— rows to restore on undo.
rename_map: dict[old_id, new_id]
— informational mapping; the new_id also appears
in imported_records for the actual delete.
Atomicity note: each repository method opens its own ``with self._conn:``
block, which auto-commits per call. To get a single atomic commit across
multi-row inserts we bypass the repositories and use raw SQL inside one
outer transaction — mirroring the legacy ``wp_api/import_export.py``
``import_bundle`` pattern.
"""
from __future__ import annotations
import json
import logging
import secrets
import sqlite3
from typing import Any
from engine._fingerprint import module_fingerprint
from engine._utils import now_iso
from engine.modules.snapshot import payload_hash
logger = logging.getLogger(__name__)
class _ImporterContractError(ValueError):
"""Raised for importer-defined contract violations.
Carries a user-safe message. Caught separately from raw sqlite
errors so the envelope can preserve our wording but generalize
sqlite's (which can leak column/constraint internals).
"""
# Module kinds dispatched to the `modules` table. Mirrors the 5 entries
# of `engine.db.repositories._VALID_TYPES` — kept duplicated here so a
# typo in the discriminator field is caught locally, not silently
# inserted as an unknown type.
_MODULE_KINDS: frozenset[str] = frozenset({
"wildcard", "fixed_values", "combine", "derivation", "constraint",
})
def _is_module_kind(kind: str) -> bool:
return kind in _MODULE_KINDS
def _require_entity_fields(
kind: str, op: str, entity: dict[str, Any], required: tuple[str, ...],
) -> None:
"""Raise ``_ImporterContractError`` listing every missing field.
Treats ``None``, missing key, and falsy non-empty-string values as
missing. Empty strings ``""`` are allowed — callers explicitly opt
into "must be non-empty" by passing the field name; empty string
fields like ``description`` are NOT included in ``required``.
"""
missing = [
k for k in required
if entity.get(k) is None
or (not entity.get(k) and entity.get(k) != "")
]
if missing:
raise _ImporterContractError(
f"{op} for {kind} missing required field(s): {sorted(missing)}",
)
def _gen_undo_id() -> str:
"""Generate a fresh 16-hex undo id.
Wider than the 8-hex module/bundle ids — undo entries can pile up
over a session, and a wider id keeps collision probability
negligible without a uniqueness check round-trip.
"""
return secrets.token_hex(8)
def _module_fp_for_entity(entity: dict[str, Any]) -> str:
"""Compute snapshot_fingerprint for a module row dict.
Mirrors the formula `ModuleRepository.create` uses at line 126:
derive payload_hash on-the-fly from payload, then build the
fingerprint. The exporter (Task 12) stamps fingerprints on rows
before they leave the source DB, but we must re-compute on the
destination because (a) the source-side hash may differ if the
payload was migrated en route, and (b) the destination repository
contract is that any write recomputes the fingerprint.
"""
return module_fingerprint({
"type": entity.get("type", ""),
"name": entity.get("name", ""),
"description": entity.get("description", ""),
"tags": entity.get("tags") or [],
"payload_hash": payload_hash(entity.get("payload") or {}),
})
def _insert_module(
conn: sqlite3.Connection, kind: str, entity: dict[str, Any], *,
target_id: str | None = None,
) -> str:
"""INSERT a module row using raw SQL. Returns the inserted id.
``target_id`` overrides ``entity["id"]`` — used by the rename path
where the inbound entity carries its original id but we want to
insert under a freshly-allocated id. When None, ``entity["id"]``
is used verbatim (the add path).
"""
# When target_id is supplied (rename path), the caller has already
# validated new_id; only `name` is required from the entity.
required = ("name",) if target_id else ("name", "id")
op_label = "rename" if target_id else "add"
_require_entity_fields(kind, op_label, entity, required)
mid = target_id or entity["id"]
now = now_iso()
fp = _module_fp_for_entity({**entity, "type": kind})
conn.execute(
"INSERT INTO modules("
"id, type, name, description, category_id, tags, "
"is_favorite, payload, snapshot_fingerprint, version, "
"created_at, updated_at, "
"community_post_slug, community_version_number, "
"content_rating"
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
(
mid, kind, entity["name"], entity.get("description", ""),
entity.get("category_id"),
json.dumps(entity.get("tags") or []),
int(entity.get("is_favorite", False)),
json.dumps(entity.get("payload") or {}),
fp,
entity.get("version", 1),
entity.get("created_at", now),
entity.get("updated_at", now),
# Community origin -- only set when the install bridge stamps
# them on the entity (see manager/main.ts snapshotLibrary +
# the embed's install call). Local Create/Import-from-file
# paths leave these NULL so the row stays "locally authored".
entity.get("community_post_slug"),
entity.get("community_version_number"),
# content_rating defaults to 'safe' when the entity carries
# no value -- migration 015 stamps the same default. Community
# install passes 'nsfw' through via the entity when the post
# carries content_rating='nsfw'.
entity.get("content_rating", "safe"),
),
)
return mid
def _insert_bundle(
conn: sqlite3.Connection, entity: dict[str, Any], *,
target_id: str | None = None,
) -> str:
"""INSERT a bundle row using raw SQL. Returns the inserted id.
Mirrors `BundleRepository.create` for the field set, including
`payload_hash` recomputation off `children` (the source-side hash
may not match this DB's hashing rules — we always recompute on
insert).
"""
required = ("name",) if target_id else ("name", "id")
op_label = "rename" if target_id else "add"
_require_entity_fields("bundle", op_label, entity, required)
bid = target_id or entity["id"]
children_blob = list(entity.get("children") or [])
ph = payload_hash({"children": children_blob})
now = now_iso()
conn.execute(
"INSERT INTO bundles("
"id, name, description, color, category_id, tags, "
"is_favorite, children, payload_hash, version, "
"created_at, updated_at, "
"community_post_slug, community_version_number, "
"content_rating"
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
(
bid, entity["name"], entity.get("description", ""),
entity.get("color"),
entity.get("category_id"),
json.dumps(entity.get("tags") or []),
int(entity.get("is_favorite", False)),
json.dumps(children_blob),
ph,
entity.get("version", 1),
entity.get("created_at", now),
entity.get("updated_at", now),
# Community origin -- see _insert_module comment above.
entity.get("community_post_slug"),
entity.get("community_version_number"),
# content_rating -- see _insert_module comment above.
entity.get("content_rating", "safe"),
),
)
return bid
def _update_module(
conn: sqlite3.Connection, mid: str, kind: str, content: dict[str, Any],
) -> None:
"""UPDATE the modules row identified by `mid` to mirror `content`.
Preserves `mid` and the row's `type` (the kind dispatch already
routed us here so type is implied). Bumps `version` and stamps
`updated_at` like `ModuleRepository.update`.
Community origin (migration 013) is rewritten ONLY when the new
content explicitly carries it — a community Update-in-place
install stamps both fields on the entity, while a vanilla Import
tab replace leaves them absent and the row keeps its prior
community_* values (or NULL for locally-authored rows).
"""
_require_entity_fields(kind, "replace", content, ("name",))
now = now_iso()
fp = _module_fp_for_entity({**content, "type": kind})
if "community_post_slug" in content or "community_version_number" in content:
# `content_rating` uses COALESCE so missing key preserves the
# existing value -- community-update installs that only bump
# the version don't accidentally wipe a user-set NSFW flag.
conn.execute(
"UPDATE modules SET "
"name = ?, description = ?, category_id = ?, tags = ?, "
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
"version = version + 1, updated_at = ?, "
"community_post_slug = ?, community_version_number = ?, "
"content_rating = COALESCE(?, content_rating) "
"WHERE id = ?;",
(
content["name"], content.get("description", ""),
content.get("category_id"),
json.dumps(content.get("tags") or []),
int(content.get("is_favorite", False)),
json.dumps(content.get("payload") or {}),
fp, now,
content.get("community_post_slug"),
content.get("community_version_number"),
content.get("content_rating"),
mid,
),
)
else:
conn.execute(
"UPDATE modules SET "
"name = ?, description = ?, category_id = ?, tags = ?, "
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
"version = version + 1, updated_at = ?, "
"content_rating = COALESCE(?, content_rating) "
"WHERE id = ?;",
(
content["name"], content.get("description", ""),
content.get("category_id"),
json.dumps(content.get("tags") or []),
int(content.get("is_favorite", False)),
json.dumps(content.get("payload") or {}),
fp, now,
content.get("content_rating"),
mid,
),
)
def _update_bundle(
conn: sqlite3.Connection, bid: str, content: dict[str, Any],
) -> None:
"""UPDATE the bundles row identified by `bid` to mirror `content`.
See _update_module for the community_* rewrite semantics.
"""
_require_entity_fields("bundle", "replace", content, ("name",))
children_blob = list(content.get("children") or [])
ph = payload_hash({"children": children_blob})
now = now_iso()
if "community_post_slug" in content or "community_version_number" in content:
conn.execute(
"UPDATE bundles SET "
"name = ?, description = ?, color = ?, category_id = ?, "
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
"version = version + 1, updated_at = ?, "
"community_post_slug = ?, community_version_number = ?, "
"content_rating = COALESCE(?, content_rating) "
"WHERE id = ?;",
(
content["name"], content.get("description", ""),
content.get("color"),
content.get("category_id"),
json.dumps(content.get("tags") or []),
int(content.get("is_favorite", False)),
json.dumps(children_blob), ph, now,
content.get("community_post_slug"),
content.get("community_version_number"),
content.get("content_rating"),
bid,
),
)
else:
conn.execute(
"UPDATE bundles SET "
"name = ?, description = ?, color = ?, category_id = ?, "
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
"version = version + 1, updated_at = ?, "
"content_rating = COALESCE(?, content_rating) "
"WHERE id = ?;",
(
content["name"], content.get("description", ""),
content.get("color"),
content.get("category_id"),
json.dumps(content.get("tags") or []),
int(content.get("is_favorite", False)),
json.dumps(children_blob), ph, now,
content.get("content_rating"),
bid,
),
)
def _module_exists(conn: sqlite3.Connection, mid: str) -> bool:
row = conn.execute("SELECT 1 FROM modules WHERE id = ?;", (mid,)).fetchone()
return row is not None
def _bundle_exists(conn: sqlite3.Connection, bid: str) -> bool:
row = conn.execute("SELECT 1 FROM bundles WHERE id = ?;", (bid,)).fetchone()
return row is not None
def _category_id_exists(conn: sqlite3.Connection, cid: str) -> bool:
row = conn.execute(
"SELECT 1 FROM module_categories WHERE id = ?;", (cid,),
).fetchone()
return row is not None
def _fetch_module_row(
conn: sqlite3.Connection, mid: str,
) -> dict[str, Any] | None:
"""Read a modules row as a plain dict (JSON columns deserialised)."""
row = conn.execute("SELECT * FROM modules WHERE id = ?;", (mid,)).fetchone()
if row is None:
return None
return {
"id": row["id"],
"type": row["type"],
"name": row["name"],
"description": row["description"],
"category_id": row["category_id"],
"tags": json.loads(row["tags"]),
"is_favorite": bool(row["is_favorite"]),
"payload": json.loads(row["payload"]),
"snapshot_fingerprint": row["snapshot_fingerprint"],
"version": row["version"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def _fetch_bundle_row(
conn: sqlite3.Connection, bid: str,
) -> dict[str, Any] | None:
"""Read a bundles row as a plain dict (JSON columns deserialised)."""
row = conn.execute("SELECT * FROM bundles WHERE id = ?;", (bid,)).fetchone()
if row is None:
return None
return {
"id": row["id"],
"name": row["name"],
"description": row["description"],
"color": row["color"],
"category_id": row["category_id"],
"tags": json.loads(row["tags"]),
"is_favorite": bool(row["is_favorite"]),
"children": json.loads(row["children"]),
"payload_hash": row["payload_hash"],
"version": row["version"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def _insert_template(
conn: sqlite3.Connection, entity: dict[str, Any], *,
target_id: str | None = None,
) -> str:
"""INSERT a template row using raw SQL. Returns the inserted id.
Mirrors `_insert_bundle` but for the templates table — no
`payload_hash` / `version` columns (templates can't drift). The
rename path passes ``target_id`` (only `name` required); the add
path uses ``entity["id"]`` verbatim (`name` + `id` required).
"""
required = ("name",) if target_id else ("name", "id")
op_label = "rename" if target_id else "add"
_require_entity_fields("template", op_label, entity, required)
tid = target_id or entity["id"]
now = now_iso()
conn.execute(
"INSERT INTO templates("
"id, name, description, category_id, tags, "
"is_favorite, template_string, created_at, updated_at"
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?);",
(
tid, entity["name"], entity.get("description", ""),
entity.get("category_id"),
json.dumps(entity.get("tags") or []),
int(entity.get("is_favorite", False)),
entity.get("template_string", ""),
entity.get("created_at", now),
entity.get("updated_at", now),
),
)
return tid
def _update_template(
conn: sqlite3.Connection, tid: str, content: dict[str, Any],
) -> None:
"""UPDATE the templates row identified by `tid` to mirror `content`.
Stamps `updated_at`; no `version` bump (templates have no version
column).
"""
_require_entity_fields("template", "replace", content, ("name",))
now = now_iso()
conn.execute(
"UPDATE templates SET "
"name = ?, description = ?, category_id = ?, tags = ?, "
"is_favorite = ?, template_string = ?, updated_at = ? "
"WHERE id = ?;",
(
content["name"], content.get("description", ""),
content.get("category_id"),
json.dumps(content.get("tags") or []),
int(content.get("is_favorite", False)),
content.get("template_string", ""),
now, tid,
),
)
def _template_exists(conn: sqlite3.Connection, tid: str) -> bool:
row = conn.execute("SELECT 1 FROM templates WHERE id = ?;", (tid,)).fetchone()
return row is not None
def _fetch_template_row(
conn: sqlite3.Connection, tid: str,
) -> dict[str, Any] | None:
"""Read a templates row as a plain dict (JSON columns deserialised)."""
row = conn.execute("SELECT * FROM templates WHERE id = ?;", (tid,)).fetchone()
if row is None:
return None
return {
"id": row["id"],
"name": row["name"],
"description": row["description"],
"category_id": row["category_id"],
"tags": json.loads(row["tags"]),
"is_favorite": bool(row["is_favorite"]),
"template_string": row["template_string"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def commit_import(
conn: sqlite3.Connection, payload: dict[str, Any],
) -> dict[str, Any]:
"""Apply a classified commit payload atomically.
Returns ``{"ok": True, "undo_id": str, "summary": {...counts...}}``
on success, or ``{"ok": False, "error": str}`` on contract
violation (collision on add, missing-id on replace, missing-id on
rename target table, etc.).
On any error all in-progress writes are rolled back via the outer
``with conn:`` block, and the import_undo row is NOT inserted.
"""
adds = list(payload.get("adds") or [])
replaces = list(payload.get("replaces") or [])
renames = list(payload.get("renames") or [])
imported_records: list[dict[str, str]] = []
replaced_snapshots: dict[str, dict[str, Any]] = {}
rename_map: dict[str, str] = {}
try:
with conn:
# ---- ADDS ---------------------------------------------------
for op in adds:
kind = op.get("kind")
entity = op.get("entity") or {}
if kind == "category":
# Mirror legacy wp_api/import_export.py:76 verbatim
# (no .strip()) so behaviour stays bit-for-bit
# compatible. Tightening here would silently change
# name-merge semantics for whitespace-padded names.
name = entity.get("name") or ""
if not name:
# Skip nameless categories the same way the
# legacy import_bundle does — but loudly, since
# the new pipeline pre-validates.
raise _ImporterContractError(
"category add missing 'name' field",
)
# Name-based merge (case-insensitive). Matches the
# legacy `import_bundle` behaviour at lines 71-82
# of wp_api/import_export.py.
existing = conn.execute(
"SELECT id FROM module_categories "
"WHERE name = ? COLLATE NOCASE;",
(name,),
).fetchone()
if existing is not None:
# Skip — nothing to undo.
continue
cid = entity.get("id") or name.lower()
if _category_id_exists(conn, cid):
# Same id, different name — treat as collision.
raise _ImporterContractError(
f"category id collision: {cid!r}",
)
conn.execute(
"INSERT INTO module_categories("
"id, name, color, icon, sort_order"
") VALUES(?, ?, ?, ?, ?);",
(
cid, name,
entity.get("color"), entity.get("icon"),
entity.get("sort_order", 0),
),
)
imported_records.append({"kind": "category", "id": cid})
elif kind == "bundle":
_require_entity_fields(
"bundle", "add", entity, ("id", "name"),
)
bid = entity["id"]
if _bundle_exists(conn, bid):
raise _ImporterContractError(
f"bundle id collision on add: {bid!r}",
)
_insert_bundle(conn, entity)
imported_records.append({"kind": "bundle", "id": bid})
elif kind == "template":
_require_entity_fields(
"template", "add", entity, ("id", "name"),
)
tid = entity["id"]
if _template_exists(conn, tid):
raise _ImporterContractError(
f"template id collision on add: {tid!r}",
)
_insert_template(conn, entity)
imported_records.append({"kind": "template", "id": tid})
elif _is_module_kind(kind or ""):
_require_entity_fields(
kind, "add", entity, ("id", "name"), # type: ignore[arg-type]
)
mid = entity["id"]
if _module_exists(conn, mid):
raise _ImporterContractError(
f"module id collision on add: {mid!r}",
)
_insert_module(conn, kind, entity) # type: ignore[arg-type]
imported_records.append({"kind": kind, "id": mid}) # type: ignore[dict-item]
else:
raise _ImporterContractError(
f"unknown kind in add: {kind!r}",
)
# ---- REPLACES ----------------------------------------------
# Categories never appear here (spec lock #2).
for op in replaces:
kind = op.get("kind")
rid = op.get("id")
new_content = op.get("new_content") or {}
if not rid:
raise _ImporterContractError(
f"{kind} replace missing 'id' field",
)
if kind == "bundle":
existing = _fetch_bundle_row(conn, rid)
if existing is None:
raise _ImporterContractError(
f"bundle replace target {rid!r} not found",
)
replaced_snapshots[rid] = {"kind": "bundle", "row": existing}
_update_bundle(conn, rid, new_content)
elif kind == "template":
existing = _fetch_template_row(conn, rid)
if existing is None:
raise _ImporterContractError(
f"template replace target {rid!r} not found",
)
replaced_snapshots[rid] = {"kind": "template", "row": existing}
_update_template(conn, rid, new_content)
elif _is_module_kind(kind or ""):
existing = _fetch_module_row(conn, rid)
if existing is None:
raise _ImporterContractError(
f"{kind} replace target {rid!r} not found",
)
replaced_snapshots[rid] = {"kind": kind, "row": existing} # type: ignore[dict-item]
_update_module(conn, rid, kind, new_content) # type: ignore[arg-type]
else:
raise _ImporterContractError(
f"unknown kind in replace: {kind!r}",
)
# ---- RENAMES -----------------------------------------------
# Insert at new_id, treat as a fresh add for undo purposes.
# Categories never appear here (spec lock #2).
for op in renames:
kind = op.get("kind")
old_id = op.get("old_id")
new_id = op.get("new_id")
content = op.get("content") or {}
if not new_id:
raise _ImporterContractError(
f"{kind} rename missing 'new_id' field",
)
if not old_id:
raise _ImporterContractError(
f"{kind} rename missing 'old_id' field",
)
# Content carries the renamed entity; `name` must be
# present (id field is irrelevant — we use new_id).
if not content.get("name"):
raise _ImporterContractError(
f"{kind} rename missing 'name' field in content",
)
if kind == "bundle":
if _bundle_exists(conn, new_id):
raise _ImporterContractError(
f"bundle new_id collision on rename: {new_id!r}",
)
_insert_bundle(conn, content, target_id=new_id)
imported_records.append({"kind": "bundle", "id": new_id})
rename_map[old_id] = new_id
elif kind == "template":
if _template_exists(conn, new_id):
raise _ImporterContractError(
f"template new_id collision on rename: {new_id!r}",
)
_insert_template(conn, content, target_id=new_id)
imported_records.append({"kind": "template", "id": new_id})
rename_map[old_id] = new_id
elif _is_module_kind(kind or ""):
if _module_exists(conn, new_id):
raise _ImporterContractError(
f"module new_id collision on rename: {new_id!r}",
)
_insert_module(conn, kind, content, target_id=new_id) # type: ignore[arg-type]
imported_records.append({"kind": kind, "id": new_id}) # type: ignore[dict-item]
rename_map[old_id] = new_id
else:
raise _ImporterContractError(
f"unknown kind in rename: {kind!r}",
)
# ---- UNDO ROW ----------------------------------------------
undo_id = _gen_undo_id()
conn.execute(
"INSERT INTO import_undo("
"id, created_at, imported_records, "
"replaced_snapshots, rename_map"
") VALUES(?, ?, ?, ?, ?);",
(
undo_id, now_iso(),
json.dumps(imported_records),
json.dumps(replaced_snapshots),
json.dumps(rename_map),
),
)
except _ImporterContractError as exc:
# `with conn:` already rolled back on exception. Return the
# importer-side contract message verbatim — we wrote it, it's
# safe.
return {"ok": False, "error": str(exc)}
except (sqlite3.IntegrityError, sqlite3.DatabaseError) as exc:
# Raw sqlite error — don't leak column/constraint internals.
# The pre-checks above (`_module_exists`, `_bundle_exists`,
# `_require_entity_fields`) cover every contract-level
# violation we expect; reaching here means the DB rejected
# the write for a lower-level reason (e.g. FK, NOT NULL,
# corruption). Log the real cause for ops; return a generic
# envelope to the caller.
logger.warning("import commit failed at DB layer: %s", exc)
return {"ok": False, "error": "database integrity violation"}
return {
"ok": True,
"undo_id": undo_id,
"summary": {
"added": len(imported_records) - len(rename_map),
"replaced": len(replaced_snapshots),
"renamed": len(rename_map),
},
}
def get_undo_entry(
conn: sqlite3.Connection, undo_id: str,
) -> dict[str, Any] | None:
"""Read the persisted undo metadata. Returns ``None`` if missing.
The dict shape mirrors what `commit_import` wrote:
``{id, created_at, imported_records, replaced_snapshots, rename_map}``.
"""
row = conn.execute(
"SELECT * FROM import_undo WHERE id = ?;", (undo_id,),
).fetchone()
if row is None:
return None
return {
"id": row["id"],
"created_at": row["created_at"],
"imported_records": json.loads(row["imported_records"]),
"replaced_snapshots": json.loads(row["replaced_snapshots"]),
"rename_map": json.loads(row["rename_map"]),
}
def undo_import(
conn: sqlite3.Connection, undo_id: str,
) -> dict[str, Any]:
"""Reverse a previous `commit_import` call.
Steps inside a single ``with conn:`` transaction:
1. Restore each replaced row to its pre-replace state.
2. Delete each inserted record (adds + renames' new_ids).
3. Delete the import_undo row.
Returns ``{"ok": True}`` on success, ``{"ok": False, "error": str}``
on missing entry or any restore/delete error (rolls back).
"""
entry = get_undo_entry(conn, undo_id)
if entry is None:
return {"ok": False, "error": f"undo entry {undo_id!r} not found"}
try:
with conn:
# ---- RESTORE REPLACES --------------------------------------
for rid, snap in entry["replaced_snapshots"].items():
kind = snap["kind"]
row = snap["row"]
if kind == "bundle":
# Restore literal columns including the original
# payload_hash, version, timestamps. We bypass
# `_update_bundle` because that helper bumps
# `version` and recomputes `payload_hash` — neither
# is correct for an undo (we want the row to look
# bit-identical to its pre-replace state).
conn.execute(
"UPDATE bundles SET "
"name = ?, description = ?, color = ?, "
"category_id = ?, tags = ?, is_favorite = ?, "
"children = ?, payload_hash = ?, version = ?, "
"created_at = ?, updated_at = ? "
"WHERE id = ?;",
(
row["name"], row["description"], row["color"],
row["category_id"],
json.dumps(row["tags"]),
int(row["is_favorite"]),
json.dumps(row["children"]),
row["payload_hash"],
row["version"],
row["created_at"], row["updated_at"], rid,
),
)
elif kind == "template":
# Restore literal columns to the pre-replace state.
# Templates have no version / payload_hash columns,
# so the snapshot is just the metadata + string.
conn.execute(
"UPDATE templates SET "
"name = ?, description = ?, category_id = ?, "
"tags = ?, is_favorite = ?, template_string = ?, "
"created_at = ?, updated_at = ? "
"WHERE id = ?;",
(
row["name"], row["description"],
row["category_id"],
json.dumps(row["tags"]),
int(row["is_favorite"]),
row["template_string"],
row["created_at"], row["updated_at"], rid,
),
)
elif _is_module_kind(kind):
conn.execute(
"UPDATE modules SET "
"name = ?, description = ?, category_id = ?, "
"tags = ?, is_favorite = ?, payload = ?, "
"snapshot_fingerprint = ?, version = ?, "
"created_at = ?, updated_at = ? "
"WHERE id = ?;",
(
row["name"], row["description"],
row["category_id"],
json.dumps(row["tags"]),
int(row["is_favorite"]),
json.dumps(row["payload"]),
row["snapshot_fingerprint"],
row["version"],
row["created_at"], row["updated_at"], rid,
),
)
else:
raise _ImporterContractError(
f"unknown kind {kind!r} in undo snapshot",
)
# ---- DELETE INSERTS ----------------------------------------
for rec in entry["imported_records"]:
kind = rec["kind"]
rec_id = rec["id"]
if kind == "bundle":
conn.execute(
"DELETE FROM bundles WHERE id = ?;", (rec_id,),
)
elif kind == "template":
conn.execute(
"DELETE FROM templates WHERE id = ?;", (rec_id,),
)
elif kind == "category":
conn.execute(
"DELETE FROM module_categories WHERE id = ?;",
(rec_id,),
)
elif _is_module_kind(kind):
conn.execute(
"DELETE FROM modules WHERE id = ?;", (rec_id,),
)
else:
raise _ImporterContractError(
f"unknown kind {kind!r} in imported_records",
)
# ---- EVICT UNDO ROW ----------------------------------------
conn.execute(
"DELETE FROM import_undo WHERE id = ?;", (undo_id,),
)
except _ImporterContractError as exc:
return {"ok": False, "error": str(exc)}
except KeyError as exc:
# Corrupt undo metadata (missing 'kind'/'id'/'row' keys). Treat
# as our contract violation — the undo blob is internal so the
# safe message names the missing field without leaking SQL.
return {
"ok": False,
"error": f"corrupt undo metadata: missing key {exc!s}",
}
except sqlite3.DatabaseError as exc:
logger.warning("undo failed at DB layer: %s", exc)
return {"ok": False, "error": "database integrity violation"}
return {"ok": True}