"""Persistence for the image update cache. ``update_service`` holds the registry-digest results in a module dict and knows nothing about storage — it is pure registry logic and stays unit-testable without a database. This module is its persistence half: it seeds that dict at startup and mirrors every write back into SQLite, wired up in ``main.lifespan``. What it buys: after a restart the update badges are there immediately instead of blank until the next background sweep (up to an hour), and the "already notified" marks come back with them, so a restart no longer re-announces updates the user has already seen. """ from __future__ import annotations import logging from sqlmodel import Session, select from database import engine from models.runtime_state import ImageStatus from services import update_service logger = logging.getLogger("stackpilot.image_status") def _to_status(row: ImageStatus) -> update_service.UpdateStatus: return update_service.UpdateStatus( image=row.image, update_available=row.update_available, current_digest=row.current_digest, remote_digest=row.remote_digest, checked_at=row.checked_at, error=row.error, ) def save(status: update_service.UpdateStatus, notified: bool) -> None: """Upsert one image's status. Opens its own session — the caller is the background loop, which has none.""" with Session(engine) as session: row = session.get(ImageStatus, status.image) if row is None: row = ImageStatus(image=status.image) row.update_available = status.update_available row.current_digest = status.current_digest row.remote_digest = status.remote_digest row.checked_at = status.checked_at row.error = status.error row.notified = notified session.add(row) session.commit() def install() -> int: """Seed the in-memory cache from the database and start mirroring writes. Returns how many entries were restored. """ with Session(engine) as session: rows = session.exec(select(ImageStatus)).all() update_service.restore_cache([(_to_status(r), r.notified) for r in rows]) update_service.set_persist_callback(save, prune) return len(rows) def prune(keep: set[str]) -> int: """Drop rows for images that are no longer used by any stack. Without this the table grows for the life of the install, one row per image tag that was ever running. """ removed = 0 with Session(engine) as session: for row in session.exec(select(ImageStatus)).all(): if row.image not in keep: session.delete(row) removed += 1 if removed: session.commit() return removed