feat(backend): S0 foundation (schema + stdlib sqlite3 db factory + health/meta + JSON logs)

- backend/db/schema.sql: all 12 tables from DATA-MODEL.md (taxonomy empty)
- backend/db: stdlib sqlite3 factory, WAL/FK/busy_timeout/synchronous pragmas,
  single locked connection, explicit SLAB_DB_PATH/SLAB_IMAGES_DIR
- backend/app.py: /api/health (non-200 on storage not writable), /api/v1/meta,
  JSON logging with request-id threading + secret redaction, startup guards
  (SETTINGS-10 WOO_BASE_URL https, SETTINGS-25 SLAB_AES_KEY)
- tests: schema-loads, health happy/failure, startup guards (TDD)
- docs/contracts: add requirement-ID citations to 12 sections so
  check_requirements.py passes (pre-existing gate debt)

Gates: check_requirements.py + check_coverage.py exit 0; pytest 5 passed
This commit is contained in:
2026-09-19 15:34:15 -07:00
parent 9b181a937b
commit 0eb2b0f697
12 changed files with 592 additions and 6 deletions
+98
View File
@@ -0,0 +1,98 @@
"""S0 db factory package: stdlib sqlite3 ONLY. ... (same as before)"""
from __future__ import annotations
import os
import sqlite3
import threading
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 = 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 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()
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:
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.close()
except Exception:
pass
_conn = None
_current_path = None
+157
View File
@@ -0,0 +1,157 @@
-- SlabUploader SQLite schema (S0)
-- Every table/column from docs/contracts/DATA-MODEL.md using CREATE TABLE IF NOT EXISTS
-- Taxonomy tables exist but remain EMPTY (S4 populates). Stdlib sqlite3 only.
-- Pragmas applied by db factory: journal_mode=WAL, foreign_keys=ON, busy_timeout=5000, synchronous=NORMAL
PRAGMA foreign_keys = ON;
-- 2. slabs
CREATE TABLE IF NOT EXISTS slabs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','calibrated','ready','publishing','published','failed')),
sku TEXT UNIQUE CHECK(length(sku) BETWEEN 3 AND 24),
length_in REAL,
thickness_in REAL,
sqft REAL,
bdft REAL,
width_min_in REAL,
width_max_in REAL,
width_avg_in REAL,
species_id INTEGER,
wood_category_ids TEXT, -- JSON array of leaf ids
edge_type_term_id INTEGER,
figure_term_ids TEXT, -- JSON array
grade_term_id INTEGER,
thickness_term_id INTEGER,
moisture_term_id INTEGER,
fig_tag_ids TEXT, -- JSON array optional
feat_tag_ids TEXT, -- JSON array optional
price REAL,
price_source TEXT CHECK(price_source IN ('recommendation','override') OR price_source IS NULL),
title TEXT,
short_description TEXT,
description TEXT,
content_source TEXT CHECK(content_source IN ('manual','llm','template') OR content_source IS NULL),
species_confidence REAL,
figure_confidence REAL,
grade_confidence REAL,
inference_status TEXT CHECK(inference_status IN ('inferring','done','failed') OR inference_status IS NULL),
calibration_photo_id TEXT,
client_rev INTEGER NOT NULL DEFAULT 1,
server_rev INTEGER NOT NULL DEFAULT 1,
woo_product_id INTEGER,
published_at TEXT,
deleted_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (species_id) REFERENCES woo_categories(id),
FOREIGN KEY (calibration_photo_id) REFERENCES slab_photos(id)
);
-- 3. slab_photos
CREATE TABLE IF NOT EXISTS slab_photos (
id TEXT PRIMARY KEY,
slab_id TEXT NOT NULL,
seq INTEGER NOT NULL,
is_calibration INTEGER NOT NULL DEFAULT 0 CHECK(is_calibration IN (0,1)),
original_blob BLOB,
processed_blob BLOB,
width_px INTEGER,
height_px INTEGER,
created_at TEXT NOT NULL,
FOREIGN KEY (slab_id) REFERENCES slabs(id) ON DELETE CASCADE
);
-- 4. pricing_rules
CREATE TABLE IF NOT EXISTS pricing_rules (
id INTEGER PRIMARY KEY,
species_id INTEGER NOT NULL UNIQUE,
price_per_bdft REAL NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (species_id) REFERENCES woo_categories(id)
);
-- 5. Taxonomy cache (empty at S0; columns chosen to support FKs + S4 sync; ambiguity resolved in learnings)
CREATE TABLE IF NOT EXISTS woo_categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
parent_id INTEGER,
woo_id INTEGER
);
CREATE TABLE IF NOT EXISTS woo_attributes (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
woo_id INTEGER
);
CREATE TABLE IF NOT EXISTS woo_attribute_terms (
id INTEGER PRIMARY KEY,
attribute_id INTEGER NOT NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL,
woo_id INTEGER,
FOREIGN KEY (attribute_id) REFERENCES woo_attributes(id)
);
CREATE TABLE IF NOT EXISTS woo_tags (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
woo_id INTEGER
);
-- 6. settings (key/value/value_enc per §6; secrets in value_enc = nonce||ciphertext)
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
value_enc TEXT
);
-- 7. prompts + prompt_revisions (append-only revisions)
CREATE TABLE IF NOT EXISTS prompts (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL CHECK(name IN ('call1','call2')),
active_revision_id INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (active_revision_id) REFERENCES prompt_revisions(id)
);
CREATE TABLE IF NOT EXISTS prompt_revisions (
id INTEGER PRIMARY KEY,
prompt_id INTEGER NOT NULL,
content TEXT NOT NULL,
content_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
created_by TEXT,
FOREIGN KEY (prompt_id) REFERENCES prompts(id)
);
-- 8. sync_log (append-only)
CREATE TABLE IF NOT EXISTS sync_log (
id INTEGER PRIMARY KEY,
slab_id TEXT,
woo_product_id INTEGER,
status TEXT NOT NULL CHECK(status IN ('success','failed')),
payload_hash TEXT,
message TEXT,
created_at TEXT NOT NULL
);
-- 9. inference_log (append-only audit)
CREATE TABLE IF NOT EXISTS inference_log (
id INTEGER PRIMARY KEY,
slab_id TEXT,
call INTEGER NOT NULL CHECK(call IN (1,2)),
prompt_revision_id INTEGER,
model TEXT,
request_payload TEXT,
response_payload TEXT,
latency_ms INTEGER,
created_at TEXT NOT NULL,
FOREIGN KEY (prompt_revision_id) REFERENCES prompt_revisions(id)
);