feat(greenfield): rebuild SlabUploader M1-M3 on clean foundation, all gates green
Clean re-implementation of the full product (not a port of the old M3 tree): - P0 foundation: 13-table schema + per-call sqlite factory (thread-safe), FastAPI app (health, JSON logging + secret redaction, CORS, startup guard). - P1 M1 Capture: mask/sheetKeep, geometry/rect (min-area rect), measure, png crop/inventory/overlay, EXIF decodeOrientedBitmap. - P2 M2 Photos: stateful capture session (batch, pick-cal, apply mask/crop to batch) + photos-ready lineup. - P3 M3 Create: real backend (crypto, woo client, taxonomy sync, pricing, inference Call1/Call2, review, publish, settings, slabs, photos) + real FE review/publish/settings screens consuming the API (no hardcoded Woo ids). - P4 gate exit: check_requirements + check_coverage both exit 0 (137/155 IDs, 0 gaps); backend 130/130, frontend 150/150, build green. Cite: docs/REQUIREMENTS.md (155 rows), docs/MILESTONES.md M1-M3.
This commit is contained in:
+40
-153
@@ -1,167 +1,54 @@
|
||||
"""S0 db factory package: stdlib sqlite3 ONLY. ... (same as before)"""
|
||||
"""Clean db factory (stdlib sqlite3 only).
|
||||
|
||||
get_connection opens a FRESH connection on every call (row_factory + pragmas
|
||||
applied). The P0.1 cached-singleton was NOT safe: FastAPI runs sync endpoints
|
||||
in anyio worker threads and async endpoints on the portal loop, and a cached
|
||||
connection bound on the main thread got silently re-opened against the default
|
||||
path once the app's event loop ran — tests passed only because a stale
|
||||
`slab_demo.db` in the repo masked the misdirection. A per-call connection makes
|
||||
the path binding explicit and thread-local by construction. Callers own the
|
||||
connection's lifetime (close when done; endpoints close via context).
|
||||
|
||||
init_db applies schema.sql + upserts schema_version (v=1).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DB_PATH_ENV = "SLAB_DB_PATH"
|
||||
IMAGES_DIR_ENV = "SLAB_IMAGES_DIR"
|
||||
SCHEMA_FILE = "schema.sql"
|
||||
|
||||
_lock = threading.Lock()
|
||||
_conn: sqlite3.Connection | None = None
|
||||
_current_path: str | None = None
|
||||
|
||||
|
||||
def _get_schema_path() -> Path:
|
||||
# backend/db/__init__.py -> backend/db/schema.sql
|
||||
return Path(__file__).parent / SCHEMA_FILE
|
||||
|
||||
|
||||
def get_connection(db_path: str | None = None) -> sqlite3.Connection:
|
||||
global _conn, _current_path
|
||||
if db_path is None:
|
||||
db_path = os.environ.get(DB_PATH_ENV)
|
||||
if not db_path:
|
||||
raise RuntimeError(f"{DB_PATH_ENV} env var is required")
|
||||
db_path = os.environ.get("SLAB_DB_PATH") or "slab_demo.db"
|
||||
db_path = str(Path(db_path).resolve())
|
||||
|
||||
with _lock:
|
||||
if _conn is None or _current_path != db_path:
|
||||
if _conn is not None:
|
||||
_conn.close()
|
||||
_conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||
_conn.row_factory = sqlite3.Row
|
||||
_conn.execute("PRAGMA journal_mode=WAL")
|
||||
_conn.execute("PRAGMA foreign_keys=ON")
|
||||
_conn.execute("PRAGMA busy_timeout=5000")
|
||||
_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
_current_path = db_path
|
||||
return _conn
|
||||
|
||||
def _has_schema_version(conn: sqlite3.Connection) -> bool:
|
||||
try:
|
||||
return conn.execute("SELECT 1 FROM schema_version LIMIT 1").fetchone() is not None
|
||||
except sqlite3.OperationalError:
|
||||
return False
|
||||
|
||||
# (table, column) pairs that must exist for the DB to be a valid M3 schema.
|
||||
# A pre-M3 (M2) volume lacks these, so any missing sentinel signals drift.
|
||||
_SCHEMA_SENTINELS = [
|
||||
("settings", "value"),
|
||||
("slabs", "deleted_at"),
|
||||
("slabs", "server_rev"),
|
||||
("slab_photos", "original_blob"),
|
||||
("sync_log", "payload_hash"),
|
||||
]
|
||||
|
||||
|
||||
def _schema_matches(conn: sqlite3.Connection) -> bool:
|
||||
for table, column in _SCHEMA_SENTINELS:
|
||||
try:
|
||||
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
except sqlite3.OperationalError:
|
||||
return False
|
||||
if column not in cols:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# Tables the app owns and that must be rebuilt together on drift.
|
||||
_M3_TABLES = [
|
||||
"slabs", "slab_photos", "pricing_rules", "settings", "woo_categories",
|
||||
"woo_attributes", "woo_attribute_terms", "woo_tags", "sync_log",
|
||||
"inference_log", "prompts", "prompt_revisions",
|
||||
]
|
||||
|
||||
|
||||
def _migrate(conn: sqlite3.Connection) -> None:
|
||||
"""Idempotent column additions for volumes created before a column existed.
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS` in schema.sql is a no-op on an existing table,
|
||||
so new columns must be added here. Each guard is a no-op once the column is
|
||||
present. #90 adds slab_photos.role (lineup kind) for the Review photo strip.
|
||||
"""
|
||||
photo_cols = {r[1] for r in conn.execute("PRAGMA table_info(slab_photos)").fetchall()}
|
||||
if photo_cols and "role" not in photo_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE slab_photos ADD COLUMN role TEXT NOT NULL DEFAULT 'batch' "
|
||||
"CHECK(role IN ('calibration','overlay','batch'))"
|
||||
)
|
||||
|
||||
|
||||
def init_db(db_path: str | None = None) -> sqlite3.Connection:
|
||||
conn = get_connection(db_path)
|
||||
schema_path = _get_schema_path()
|
||||
if not schema_path.exists():
|
||||
raise FileNotFoundError(f"Schema not found: {schema_path}")
|
||||
with open(schema_path, "r", encoding="utf-8") as f:
|
||||
schema = f.read()
|
||||
with _lock:
|
||||
conn.executescript(schema)
|
||||
conn.commit()
|
||||
if not _has_schema_version(conn):
|
||||
if not _schema_matches(conn):
|
||||
logger = logging.getLogger("slab.db")
|
||||
logger.info("Detected pre-M3 schema drift, rebuilding app tables")
|
||||
for t in _M3_TABLES:
|
||||
conn.execute(f"DROP TABLE IF EXISTS {t}")
|
||||
conn.executescript(schema)
|
||||
conn.execute("INSERT OR REPLACE INTO schema_version (version) VALUES (1)")
|
||||
conn.commit()
|
||||
# Safe to run every boot: no-op once the columns exist.
|
||||
with _lock:
|
||||
_migrate(conn)
|
||||
conn.commit()
|
||||
|
||||
# isolation_level=None -> autocommit mode. The P0.1 default (deferred
|
||||
# transactions) left a read-transaction open for the whole life of an
|
||||
# endpoint connection, which (a) blocked other writers with "database is
|
||||
# locked" and (b) made conn.commit() inside an open txn a no-op for
|
||||
# multi-statement units. Autocommit + explicit BEGIN for the few
|
||||
# multi-statement units (taxonomy sync) is the honest model for a
|
||||
# per-call-connection factory.
|
||||
conn = sqlite3.connect(db_path, check_same_thread=False, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
return conn
|
||||
|
||||
|
||||
def get_images_dir() -> Path:
|
||||
img = os.environ.get(IMAGES_DIR_ENV)
|
||||
if not img:
|
||||
raise RuntimeError(f"{IMAGES_DIR_ENV} env var is required")
|
||||
return Path(img).resolve()
|
||||
|
||||
|
||||
def probe_writable(conn: sqlite3.Connection | None = None) -> bool:
|
||||
def init_db(db_path: str | None = None) -> None:
|
||||
"""Apply schema.sql (CREATE IF NOT EXISTS) and ensure schema_version row=1."""
|
||||
schema_path = Path(__file__).parent / "schema.sql"
|
||||
if not schema_path.exists():
|
||||
raise FileNotFoundError(f"Schema not found: {schema_path}")
|
||||
conn = get_connection(db_path)
|
||||
try:
|
||||
c = conn or get_connection()
|
||||
c.execute("SELECT 1").fetchone()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def probe_images_writable() -> bool:
|
||||
try:
|
||||
img_dir = get_images_dir()
|
||||
if not img_dir.exists():
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
probe = img_dir / ".health-probe"
|
||||
probe.write_text("ok", encoding="utf-8")
|
||||
probe.unlink(missing_ok=True)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Close global conn so temp dirs can be cleaned on Windows (test only)."""
|
||||
global _conn, _current_path
|
||||
with _lock:
|
||||
if _conn is not None:
|
||||
try:
|
||||
_conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
_conn = None
|
||||
_current_path = None
|
||||
with open(schema_path, "r", encoding="utf-8") as f:
|
||||
schema = f.read()
|
||||
conn.executescript(schema)
|
||||
conn.execute("INSERT OR REPLACE INTO schema_version (version) VALUES (1)")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
Reference in New Issue
Block a user