"""Durable SQLite metadata store for archived product word clouds.""" from __future__ import annotations import json import sqlite3 import threading import uuid from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any from .schemas import ( ProductImageRecord, ProductInput, ProductRecord, ProductVersionRecord, ProductWordcloudArchiveRecord, ) def _now() -> datetime: return datetime.now(timezone.utc) class ProductPurgeInProgressError(ValueError): """Raised when restore loses the atomic race to physical purge.""" class ProductArchiveStore: """Owns durable product metadata and logical cleanup state.""" def __init__(self, root: Path) -> None: self.root = root self.root.mkdir(parents=True, exist_ok=True) self.db_path = self.root / "product_archive.db" self._lock = threading.Lock() self._init_db() def _connect(self) -> sqlite3.Connection: conn = sqlite3.connect(str(self.db_path), check_same_thread=False) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA busy_timeout=5000") return conn def _init_db(self) -> None: with self._lock, self._connect() as conn: conn.executescript( """ CREATE TABLE IF NOT EXISTS products ( product_id TEXT PRIMARY KEY, source TEXT NOT NULL, external_product_id TEXT, name TEXT NOT NULL, sku TEXT NOT NULL DEFAULT '', specification TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, purge_after TEXT, cover_image_id TEXT ); CREATE UNIQUE INDEX IF NOT EXISTS idx_products_source_external_id ON products(source, external_product_id); CREATE INDEX IF NOT EXISTS idx_products_name ON products(name); CREATE INDEX IF NOT EXISTS idx_products_purge_after ON products(purge_after); CREATE TABLE IF NOT EXISTS product_versions ( version_id TEXT PRIMARY KEY, product_id TEXT NOT NULL, version TEXT NOT NULL DEFAULT '', metadata TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_product_versions_product_id ON product_versions(product_id); CREATE TABLE IF NOT EXISTS product_images ( image_id TEXT PRIMARY KEY, product_id TEXT NOT NULL, version_id TEXT NOT NULL, image_path TEXT NOT NULL DEFAULT '', image_type TEXT NOT NULL DEFAULT '', is_cover INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_product_images_product_id ON product_images(product_id); CREATE INDEX IF NOT EXISTS idx_product_images_version_id ON product_images(version_id); CREATE TABLE IF NOT EXISTS product_wordcloud_archives ( archive_id TEXT PRIMARY KEY, product_id TEXT NOT NULL, version_id TEXT NOT NULL, archive_path TEXT NOT NULL DEFAULT '', source_job_id TEXT NOT NULL DEFAULT '', source_asset_id TEXT NOT NULL DEFAULT '', db_checksum TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_product_wordcloud_archives_product_id ON product_wordcloud_archives(product_id); CREATE INDEX IF NOT EXISTS idx_product_wordcloud_archives_version_id ON product_wordcloud_archives(version_id); CREATE TABLE IF NOT EXISTS cleanup_records ( cleanup_id TEXT PRIMARY KEY, product_id TEXT NOT NULL, status TEXT NOT NULL, purge_after TEXT, created_at TEXT NOT NULL, completed_at TEXT ); CREATE INDEX IF NOT EXISTS idx_cleanup_records_product_id ON cleanup_records(product_id); CREATE INDEX IF NOT EXISTS idx_cleanup_records_purge_after ON cleanup_records(purge_after); """ ) self._ensure_column(conn, "products", "cover_image_id", "TEXT") self._ensure_column(conn, "product_images", "is_cover", "INTEGER NOT NULL DEFAULT 0") self._ensure_column(conn, "product_wordcloud_archives", "source_job_id", "TEXT NOT NULL DEFAULT ''") self._ensure_column(conn, "product_wordcloud_archives", "source_asset_id", "TEXT NOT NULL DEFAULT ''") self._ensure_column(conn, "product_wordcloud_archives", "db_checksum", "TEXT NOT NULL DEFAULT ''") @staticmethod def _ensure_column(conn: sqlite3.Connection, table: str, column: str, declaration: str) -> None: columns = {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")} if column not in columns: conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {declaration}") def _execute(self, sql: str, parameters: tuple[Any, ...] = ()) -> None: with self._lock, self._connect() as conn: conn.execute(sql, parameters) def _fetchone(self, sql: str, parameters: tuple[Any, ...] = ()) -> sqlite3.Row | None: with self._lock, self._connect() as conn: return conn.execute(sql, parameters).fetchone() def _fetchall(self, sql: str, parameters: tuple[Any, ...] = ()) -> list[sqlite3.Row]: with self._lock, self._connect() as conn: return conn.execute(sql, parameters).fetchall() @staticmethod def _product_from_row(row: sqlite3.Row) -> ProductRecord: return ProductRecord( product_id=row["product_id"], source=row["source"], external_product_id=row["external_product_id"], name=row["name"], sku=row["sku"], specification=row["specification"], status=row["status"], created_at=datetime.fromisoformat(row["created_at"]), updated_at=datetime.fromisoformat(row["updated_at"]), purge_after=datetime.fromisoformat(row["purge_after"]) if row["purge_after"] else None, cover_image_id=row["cover_image_id"], ) def upsert_product(self, product: ProductInput) -> ProductRecord: if not product.name.strip(): raise ValueError("product name must not be blank") if product.source == "external" and not (product.external_product_id or "").strip(): raise ValueError("external products require external_product_id") now = _now() existing = None if product.source == "external": existing = self._fetchone( "SELECT * FROM products WHERE source = ? AND external_product_id = ?", (product.source, product.external_product_id), ) if existing: self._execute( """UPDATE products SET name = ?, sku = ?, specification = ?, updated_at = ? WHERE product_id = ?""", (product.name, product.sku, product.specification, now.isoformat(), existing["product_id"]), ) return self.get_product(existing["product_id"]) product_id = f"prod_{uuid.uuid4().hex}" self._execute( """INSERT INTO products ( product_id, source, external_product_id, name, sku, specification, status, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?)""", (product_id, product.source, product.external_product_id, product.name, product.sku, product.specification, now.isoformat(), now.isoformat()), ) return self.get_product(product_id) def get_product(self, product_id: str) -> ProductRecord: row = self._fetchone("SELECT * FROM products WHERE product_id = ?", (product_id,)) if row is None: raise ValueError(f"unknown product_id: {product_id}") return self._product_from_row(row) def list_products(self, query: str = "") -> list[ProductRecord]: if query: rows = self._fetchall( "SELECT * FROM products WHERE name LIKE ? ORDER BY updated_at DESC, product_id", (f"%{query}%",), ) else: rows = self._fetchall("SELECT * FROM products ORDER BY updated_at DESC, product_id") return [self._product_from_row(row) for row in rows] def create_version( self, product_id: str, version: str = "", metadata: dict[str, Any] | None = None, now: datetime | None = None, ) -> ProductVersionRecord: self.get_product(product_id) created_at = now or _now() record = ProductVersionRecord( version_id=f"ver_{uuid.uuid4().hex}", product_id=product_id, version=version, metadata=metadata or {}, created_at=created_at, ) self._execute( "INSERT INTO product_versions (version_id, product_id, version, metadata, created_at) VALUES (?, ?, ?, ?, ?)", (record.version_id, record.product_id, record.version, json.dumps(record.metadata, ensure_ascii=False), record.created_at.isoformat()), ) return record def _before_archive_commit(self) -> None: """Test seam: runs inside the archive metadata transaction before commit.""" def write_archive_version( self, product_id: str, version_id: str, metadata: dict[str, Any], preview_path: str, archives: list[dict[str, str]], now: datetime | None = None, ) -> tuple[ProductVersionRecord, ProductImageRecord, list[ProductWordcloudArchiveRecord]]: """Persist one complete archive version and make its preview the sole cover.""" created_at = now or _now() version = ProductVersionRecord( version_id=version_id, product_id=product_id, metadata=metadata, created_at=created_at ) image = ProductImageRecord( image_id=f"img_{uuid.uuid4().hex}", product_id=product_id, version_id=version_id, image_path=preview_path, image_type="design_preview", is_cover=True, created_at=created_at, ) records = [ ProductWordcloudArchiveRecord( archive_id=f"wca_{uuid.uuid4().hex}", product_id=product_id, version_id=version_id, archive_path=item["archive_path"], source_job_id=item["source_job_id"], source_asset_id=item["source_asset_id"], db_checksum=item["db_checksum"], created_at=created_at, ) for item in archives ] with self._lock, self._connect() as conn: product = conn.execute("SELECT product_id FROM products WHERE product_id = ?", (product_id,)).fetchone() if product is None: raise ValueError(f"unknown product_id: {product_id}") conn.execute("BEGIN") conn.execute( "INSERT INTO product_versions (version_id, product_id, version, metadata, created_at) VALUES (?, ?, ?, ?, ?)", (version.version_id, version.product_id, version.version, json.dumps(version.metadata, ensure_ascii=False), version.created_at.isoformat()), ) conn.execute( "UPDATE product_images SET is_cover = 0 WHERE product_id = ? AND is_cover = 1", (product_id,) ) conn.execute( """INSERT INTO product_images (image_id, product_id, version_id, image_path, image_type, is_cover, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)""", (image.image_id, image.product_id, image.version_id, image.image_path, image.image_type, 1, image.created_at.isoformat()), ) for record in records: conn.execute( """INSERT INTO product_wordcloud_archives (archive_id, product_id, version_id, archive_path, source_job_id, source_asset_id, db_checksum, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", (record.archive_id, record.product_id, record.version_id, record.archive_path, record.source_job_id, record.source_asset_id, record.db_checksum, record.created_at.isoformat()), ) conn.execute("UPDATE products SET cover_image_id = ?, updated_at = ? WHERE product_id = ?", (image.image_id, created_at.isoformat(), product_id)) self._before_archive_commit() conn.commit() return version, image, records def add_image( self, product_id: str, version_id: str, image_path: str = "", image_type: str = "", now: datetime | None = None, ) -> ProductImageRecord: self._require_version(product_id, version_id) record = ProductImageRecord( image_id=f"img_{uuid.uuid4().hex}", product_id=product_id, version_id=version_id, image_path=image_path, image_type=image_type, created_at=now or _now(), ) self._execute( """INSERT INTO product_images (image_id, product_id, version_id, image_path, image_type, created_at) VALUES (?, ?, ?, ?, ?, ?)""", (record.image_id, record.product_id, record.version_id, record.image_path, record.image_type, record.created_at.isoformat()), ) return record def add_wordcloud_archive( self, product_id: str, version_id: str, archive_path: str = "", now: datetime | None = None, ) -> ProductWordcloudArchiveRecord: self._require_version(product_id, version_id) record = ProductWordcloudArchiveRecord( archive_id=f"wca_{uuid.uuid4().hex}", product_id=product_id, version_id=version_id, archive_path=archive_path, created_at=now or _now(), ) self._execute( """INSERT INTO product_wordcloud_archives (archive_id, product_id, version_id, archive_path, created_at) VALUES (?, ?, ?, ?, ?)""", (record.archive_id, record.product_id, record.version_id, record.archive_path, record.created_at.isoformat()), ) return record def _require_version(self, product_id: str, version_id: str) -> None: row = self._fetchone( "SELECT version_id FROM product_versions WHERE product_id = ? AND version_id = ?", (product_id, version_id), ) if row is None: raise ValueError(f"unknown version_id for product: {version_id}") def mark_pending_cleanup(self, product_id: str, now: datetime) -> ProductRecord: product = self.get_product(product_id) if product.status not in ("active", "failed_cleanup"): raise ValueError("product cannot be marked pending cleanup") purge_after = now + timedelta(days=30) self._execute( "UPDATE products SET status = ?, purge_after = ?, updated_at = ? WHERE product_id = ?", ("pending_cleanup", purge_after.isoformat(), now.isoformat(), product_id), ) self._execute( """INSERT INTO cleanup_records (cleanup_id, product_id, status, purge_after, created_at) VALUES (?, ?, ?, ?, ?)""", (f"cln_{uuid.uuid4().hex}", product_id, "pending_cleanup", purge_after.isoformat(), now.isoformat()), ) return self.get_product(product_id) def restore_product(self, product_id: str, now: datetime | None = None) -> ProductRecord: restored_at = now or _now() with self._lock, self._connect() as conn: conn.execute("BEGIN IMMEDIATE") product = conn.execute( "SELECT product_id, status FROM products WHERE product_id = ?", (product_id,) ).fetchone() if product is None: raise ValueError(f"unknown product_id: {product_id}") if product["status"] == "purged": raise ValueError("purged product cannot be restored") purging = conn.execute( """SELECT 1 FROM cleanup_records WHERE product_id = ? AND status = 'purging' LIMIT 1""", (product_id,), ).fetchone() if purging is not None: raise ProductPurgeInProgressError("product is purging") conn.execute( "UPDATE products SET status = ?, purge_after = NULL, updated_at = ? WHERE product_id = ?", ("active", restored_at.isoformat(), product_id), ) conn.execute( """UPDATE cleanup_records SET status = ?, completed_at = ? WHERE product_id = ? AND status IN ('pending_cleanup', 'failed_cleanup')""", ("restored", restored_at.isoformat(), product_id), ) return self.get_product(product_id) def due_product_cleanups(self, now: datetime) -> list[ProductRecord]: rows = self._fetchall( """SELECT products.* FROM products WHERE products.status = ? AND products.purge_after IS NOT NULL AND products.purge_after <= ? AND EXISTS ( SELECT 1 FROM cleanup_records WHERE cleanup_records.product_id = products.product_id AND cleanup_records.status = 'pending_cleanup' ) ORDER BY products.purge_after, products.product_id""", ("pending_cleanup", now.isoformat()), ) return [self._product_from_row(row) for row in rows] def claim_product_purge(self, product_id: str, now: datetime) -> ProductRecord | None: """Atomically win the right to delete a due product's files.""" with self._lock, self._connect() as conn: conn.execute("BEGIN IMMEDIATE") row = conn.execute( "SELECT * FROM products WHERE product_id = ?", (product_id,) ).fetchone() if row is None: return None purge_after = datetime.fromisoformat(row["purge_after"]) if row["purge_after"] else None if row["status"] != "pending_cleanup" or purge_after is None or purge_after > now: return None cleanup = conn.execute( """SELECT cleanup_id FROM cleanup_records WHERE product_id = ? AND status = 'pending_cleanup' ORDER BY created_at DESC, cleanup_id DESC LIMIT 1""", (product_id,), ).fetchone() if cleanup is None: return None updated = conn.execute( """UPDATE cleanup_records SET status = 'purging' WHERE cleanup_id = ? AND status = 'pending_cleanup'""", (cleanup["cleanup_id"],), ) if updated.rowcount != 1: return None return self._product_from_row(row) def fail_product_purge(self, product_id: str) -> ProductRecord: """Quarantine a purge whose physical deletion failed mid-way.""" with self._lock, self._connect() as conn: conn.execute("BEGIN IMMEDIATE") claim = conn.execute( """SELECT 1 FROM cleanup_records WHERE product_id = ? AND status = 'purging' LIMIT 1""", (product_id,), ).fetchone() if claim is None: raise ValueError("product purge is not claimed") updated = conn.execute( """UPDATE cleanup_records SET status = 'failed_cleanup' WHERE product_id = ? AND status = 'purging'""", (product_id,), ) if updated.rowcount != 1: raise ValueError("product purge is not claimed") conn.execute( "UPDATE products SET status = 'failed_cleanup', updated_at = ? WHERE product_id = ?", (_now().isoformat(), product_id), ) return self.get_product(product_id) def finalize_product_purge(self, product_id: str, now: datetime) -> ProductRecord: """Atomically mark a claimed product purged after file removal.""" with self._lock, self._connect() as conn: conn.execute("BEGIN IMMEDIATE") claim = conn.execute( """SELECT 1 FROM cleanup_records WHERE product_id = ? AND status = 'purging' LIMIT 1""", (product_id,), ).fetchone() if claim is None: raise ValueError("product purge is not claimed") conn.execute( "UPDATE products SET status = ?, updated_at = ? WHERE product_id = ?", ("purged", now.isoformat(), product_id), ) conn.execute( """UPDATE cleanup_records SET status = ?, completed_at = ? WHERE product_id = ? AND status = 'purging'""", ("purged", now.isoformat(), product_id), ) return self.get_product(product_id) def purge_product(self, product_id: str, now: datetime | None = None) -> ProductRecord: purged_at = now or _now() if self.claim_product_purge(product_id, purged_at) is None: raise ValueError("product is not due for purge") return self.finalize_product_purge(product_id, purged_at)