Files
wordcloud/backend/service/product_archive_store.py
T

371 lines
17 KiB
Python

"""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 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:
self.get_product(product_id)
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:
self.get_product(product_id)
restored_at = now or _now()
self._execute(
"UPDATE products SET status = ?, purge_after = NULL, updated_at = ? WHERE product_id = ?",
("active", restored_at.isoformat(), product_id),
)
self._execute(
"""UPDATE cleanup_records SET status = ?, completed_at = ?
WHERE product_id = ? AND status = ?""",
("restored", restored_at.isoformat(), product_id, "pending_cleanup"),
)
return self.get_product(product_id)
def due_product_cleanups(self, now: datetime) -> list[ProductRecord]:
rows = self._fetchall(
"""SELECT * FROM products WHERE status = ? AND purge_after IS NOT NULL AND purge_after <= ?
ORDER BY purge_after, product_id""",
("pending_cleanup", now.isoformat()),
)
return [self._product_from_row(row) for row in rows]
def purge_product(self, product_id: str, now: datetime | None = None) -> ProductRecord:
purged_at = now or _now()
product = self.get_product(product_id)
if (
product.status != "pending_cleanup"
or product.purge_after is None
or product.purge_after > purged_at
):
raise ValueError("product is not due for purge")
self._execute(
"UPDATE products SET status = ?, updated_at = ? WHERE product_id = ?",
("purged", purged_at.isoformat(), product_id),
)
self._execute(
"""UPDATE cleanup_records SET status = ?, completed_at = ?
WHERE product_id = ? AND status = ?""",
("purged", purged_at.isoformat(), product_id, "pending_cleanup"),
)
return self.get_product(product_id)