* feat(library): persist recoverable parent state * feat(library): expose versioned parent metadata reads * feat(library): protect parent metadata and archive writes * feat(library): save native recipes with atomic outcomes * feat(library): download complete portable library json * fix(library): name export owners list positives per spec * fix(library): normalise native category and harden restore and id errors * fix(library): measure stored titles past embedded NULs
87 lines
3.5 KiB
Python
87 lines
3.5 KiB
Python
"""Optional-old, required-current schema for Library parent state."""
|
|
|
|
import re
|
|
import sqlite3
|
|
|
|
TABLE = "prompt_library_state"
|
|
MAX_TITLE_LENGTH = 10_000
|
|
_DDL = (
|
|
f"CREATE TABLE {TABLE} (prompt_id INTEGER PRIMARY KEY, title TEXT, "
|
|
"FOREIGN KEY(prompt_id) REFERENCES prompts(id) ON DELETE CASCADE)"
|
|
)
|
|
_COLUMNS = (("prompt_id", "INTEGER", 0, 1), ("title", "TEXT", 0, 0))
|
|
_FOREIGN_KEYS = {("prompt_id", "prompts", "id", "CASCADE")}
|
|
|
|
|
|
def _compact(sql: str) -> str:
|
|
return re.sub(r"\s+", "", sql).lower()
|
|
|
|
|
|
def library_schema_error(
|
|
conn: sqlite3.Connection, *, allow_missing: bool = True
|
|
) -> str | None:
|
|
"""Reject unsupported Library state schemas and invalid persisted values.
|
|
|
|
Reads only: nothing is ever repaired. A missing table is acceptable for
|
|
backups that predate it unless ``allow_missing`` is false.
|
|
"""
|
|
objects = conn.execute(
|
|
"SELECT type,sql FROM sqlite_master WHERE name=? "
|
|
"OR (type IN ('index','trigger') AND tbl_name=?)",
|
|
(TABLE, TABLE),
|
|
).fetchall()
|
|
if not objects:
|
|
return None if allow_missing else "Missing Library state schema"
|
|
tables = [sql for kind, sql in objects if kind == "table"]
|
|
if len(tables) != 1 or len(objects) != 1:
|
|
return "Library state name is reserved by an unsupported schema object"
|
|
if _compact(tables[0] or "") != _compact(_DDL):
|
|
return "Library state has an invalid table definition"
|
|
columns = tuple(
|
|
(row[1], row[2].upper(), row[3], row[5])
|
|
for row in conn.execute(f"PRAGMA table_info({TABLE})")
|
|
)
|
|
if columns != _COLUMNS:
|
|
return "Library state has invalid columns"
|
|
foreign_keys = {
|
|
(row[3], row[2], row[4], row[6])
|
|
for row in conn.execute(f"PRAGMA foreign_key_list({TABLE})")
|
|
}
|
|
if foreign_keys != _FOREIGN_KEYS:
|
|
return "Library state has invalid foreign key definitions"
|
|
if conn.execute(f"PRAGMA foreign_key_check({TABLE})").fetchone():
|
|
return "Library state contains foreign key violations"
|
|
# length() stops at an embedded NUL, so bound by bytes in SQL (characters
|
|
# never outnumber bytes, and UTF-8 uses at most 4 bytes per character) and
|
|
# count the few remaining candidates as decoded characters.
|
|
if conn.execute(
|
|
f"SELECT 1 FROM {TABLE} WHERE typeof(title) NOT IN ('null','text') "
|
|
f"OR length(CAST(title AS BLOB)) > {MAX_TITLE_LENGTH * 4} LIMIT 1"
|
|
).fetchone():
|
|
return "Library state contains invalid titles"
|
|
for (raw,) in conn.execute(
|
|
f"SELECT CAST(title AS BLOB) FROM {TABLE} "
|
|
f"WHERE length(CAST(title AS BLOB)) > {MAX_TITLE_LENGTH}"
|
|
):
|
|
if len(bytes(raw).decode("utf-8", "replace")) > MAX_TITLE_LENGTH:
|
|
return "Library state contains invalid titles"
|
|
return None
|
|
|
|
|
|
def migrate_library_state(conn: sqlite3.Connection) -> None:
|
|
"""Install the table atomically; validate before and after creating it."""
|
|
conn.execute("SAVEPOINT prompt_library_state_schema")
|
|
try:
|
|
error = library_schema_error(conn)
|
|
if error:
|
|
raise sqlite3.DatabaseError(error)
|
|
conn.execute(_DDL.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
|
|
error = library_schema_error(conn, allow_missing=False)
|
|
if error:
|
|
raise sqlite3.DatabaseError(error)
|
|
conn.execute("RELEASE prompt_library_state_schema")
|
|
except BaseException:
|
|
conn.execute("ROLLBACK TO prompt_library_state_schema")
|
|
conn.execute("RELEASE prompt_library_state_schema")
|
|
raise
|