import Database from "better-sqlite3"; import type { VehicleClass } from "@parking/shared"; // One table. Each row is one booth decision: what the camera saw, what the operator // chose, and (once reviewed) what a trusted person says the vehicle is. The crop itself // lives on disk beside the DB (crops//.jpg) so the trainer on the same host // reads it straight off the volume. export interface ItemRow { id: string; booth: string; /** "wash" = a desk decision (operator fields set); "entry" = a sampled entry read (pure * training material: crop + the camera's class, operator fields empty). */ kind: "wash" | "entry"; orderRef: string; at: string; operatorRef: string; operatorCategoryId: string; operatorCategoryName: string; /** The vision classes the operator's category covers at that site (its mapping) — what * lets a reviewer's CLASS be compared with an operator's CATEGORY. JSON array. */ operatorClasses: string; service: string; visionClass: string; visionConfidence: number; visionCategoryId: string | null; downgraded: number; imageWidth: number; imageHeight: number; plateBlurred: number; imagePath: string; receivedAt: string; reviewLabel: string | null; // a VehicleClass, or "unusable" reviewedAt: string | null; reviewer: string | null; } export type ReviewVerdict = VehicleClass | "unusable"; export class CollectorDb { readonly #db: Database.Database; constructor(file: string) { this.#db = new Database(file); this.#db.pragma("journal_mode = WAL"); this.#db.exec(` CREATE TABLE IF NOT EXISTS items ( id TEXT PRIMARY KEY, booth TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'wash', order_ref TEXT NOT NULL, at TEXT NOT NULL, operator_ref TEXT NOT NULL DEFAULT '', operator_category_id TEXT NOT NULL DEFAULT '', operator_category_name TEXT NOT NULL DEFAULT '', operator_classes TEXT NOT NULL DEFAULT '[]', service TEXT NOT NULL, vision_class TEXT NOT NULL, vision_confidence REAL NOT NULL, vision_category_id TEXT, downgraded INTEGER NOT NULL DEFAULT 0, image_width INTEGER NOT NULL, image_height INTEGER NOT NULL, plate_blurred INTEGER NOT NULL, image_path TEXT NOT NULL, received_at TEXT NOT NULL, review_label TEXT, reviewed_at TEXT, reviewer TEXT ); CREATE INDEX IF NOT EXISTS items_pending ON items (reviewed_at, received_at); CREATE INDEX IF NOT EXISTS items_booth ON items (booth, received_at); `); this.#migrate(); } /** Columns added after the first deploy, with the DDL that adds them to an EXISTING * table. `CREATE TABLE IF NOT EXISTS` above only shapes a NEW database; a volume that * was created by an earlier build keeps its old columns, and every query naming a new * one then fails ("no such column: kind" — art-docker-station, 2026-09-16: the * collector's /health, every ingest, and the trainer's readiness all broke on a DB * from before `kind`). Each entry must be addable to a populated table, i.e. nullable * or carrying a DEFAULT. Append here whenever a column joins the CREATE above. */ static readonly #ADDED_COLUMNS: ReadonlyArray = [ ["kind", "TEXT NOT NULL DEFAULT 'wash'"], ["operator_ref", "TEXT NOT NULL DEFAULT ''"], ["operator_category_id", "TEXT NOT NULL DEFAULT ''"], ["operator_category_name", "TEXT NOT NULL DEFAULT ''"], ["operator_classes", "TEXT NOT NULL DEFAULT '[]'"], ["vision_category_id", "TEXT"], ["downgraded", "INTEGER NOT NULL DEFAULT 0"], ]; /** Bring an existing `items` table up to the current column set (idempotent). */ #migrate(): void { const present = new Set( (this.#db.prepare("PRAGMA table_info(items)").all() as { name: string }[]).map((c) => c.name), ); for (const [name, ddl] of CollectorDb.#ADDED_COLUMNS) { if (!present.has(name)) this.#db.exec(`ALTER TABLE items ADD COLUMN ${name} ${ddl}`); } } /** The current column names of `items` (for tests and diagnostics). */ columns(): string[] { return (this.#db.prepare("PRAGMA table_info(items)").all() as { name: string }[]).map((c) => c.name); } close(): void { this.#db.close(); } static #map(r: Record): ItemRow { return { id: r.id as string, booth: r.booth as string, kind: r.kind === "entry" ? "entry" : "wash", orderRef: r.order_ref as string, at: r.at as string, operatorRef: r.operator_ref as string, operatorCategoryId: r.operator_category_id as string, operatorCategoryName: r.operator_category_name as string, operatorClasses: r.operator_classes as string, service: r.service as string, visionClass: r.vision_class as string, visionConfidence: r.vision_confidence as number, visionCategoryId: (r.vision_category_id as string | null) ?? null, downgraded: r.downgraded as number, imageWidth: r.image_width as number, imageHeight: r.image_height as number, plateBlurred: r.plate_blurred as number, imagePath: r.image_path as string, receivedAt: r.received_at as string, reviewLabel: (r.review_label as string | null) ?? null, reviewedAt: (r.reviewed_at as string | null) ?? null, reviewer: (r.reviewer as string | null) ?? null, }; } get(id: string): ItemRow | null { const r = this.#db.prepare("SELECT * FROM items WHERE id = ?").get(id) as Record | undefined; return r ? CollectorDb.#map(r) : null; } insert(row: Omit): void { this.#db .prepare( `INSERT INTO items (id, booth, kind, order_ref, at, operator_ref, operator_category_id, operator_category_name, operator_classes, service, vision_class, vision_confidence, vision_category_id, downgraded, image_width, image_height, plate_blurred, image_path, received_at) VALUES (@id, @booth, @kind, @orderRef, @at, @operatorRef, @operatorCategoryId, @operatorCategoryName, @operatorClasses, @service, @visionClass, @visionConfidence, @visionCategoryId, @downgraded, @imageWidth, @imageHeight, @plateBlurred, @imagePath, @receivedAt)`, ) .run(row); } list(status: "pending" | "reviewed", limit: number, booth?: string): ItemRow[] { const where = [status === "pending" ? "reviewed_at IS NULL" : "reviewed_at IS NOT NULL"]; const params: unknown[] = []; if (booth) { where.push("booth = ?"); params.push(booth); } const order = status === "pending" ? "received_at ASC" : "reviewed_at DESC"; const rows = this.#db .prepare(`SELECT * FROM items WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT ?`) .all(...params, limit) as Record[]; return rows.map((r) => CollectorDb.#map(r)); } review(id: string, label: ReviewVerdict, reviewer: string): ItemRow | null { this.#db .prepare("UPDATE items SET review_label = ?, reviewed_at = ?, reviewer = ? WHERE id = ?") .run(label, new Date().toISOString(), reviewer, id); return this.get(id); } /** Per booth: received / pending / reviewed. Per operator (booth + hash): how often the * reviewer's class fell inside the operator's chosen category (agree) or outside * (disagree) — the honest-mistake / fraud rate the outbox exists for. */ stats(): { booths: { booth: string; received: number; pending: number; reviewed: number; entries: number }[]; operators: { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }[]; } { const booths = this.#db .prepare( `SELECT booth, COUNT(*) AS received, SUM(CASE WHEN reviewed_at IS NULL THEN 1 ELSE 0 END) AS pending, SUM(CASE WHEN reviewed_at IS NOT NULL THEN 1 ELSE 0 END) AS reviewed, SUM(CASE WHEN kind = 'entry' THEN 1 ELSE 0 END) AS entries FROM items GROUP BY booth ORDER BY booth`, ) .all() as { booth: string; received: number; pending: number; reviewed: number; entries: number }[]; // Operator agreement is a WASH thing — an entry sample has no operator decision. const reviewed = this.#db .prepare("SELECT booth, operator_ref, operator_classes, review_label FROM items WHERE reviewed_at IS NOT NULL AND kind = 'wash'") .all() as { booth: string; operator_ref: string; operator_classes: string; review_label: string }[]; const ops = new Map(); for (const r of reviewed) { const key = `${r.booth} ${r.operator_ref}`; let o = ops.get(key); if (!o) ops.set(key, (o = { booth: r.booth, operatorRef: r.operator_ref, reviewed: 0, agree: 0, disagree: 0, unusable: 0 })); o.reviewed += 1; if (r.review_label === "unusable") o.unusable += 1; else if ((JSON.parse(r.operator_classes) as string[]).includes(r.review_label)) o.agree += 1; else o.disagree += 1; } return { booths, operators: [...ops.values()].sort((a, b) => b.disagree - a.disagree) }; } /** Reviewed, usable rows — the training set. */ labelled(): ItemRow[] { const rows = this.#db .prepare("SELECT * FROM items WHERE reviewed_at IS NOT NULL AND review_label != 'unusable' ORDER BY reviewed_at") .all() as Record[]; return rows.map((r) => CollectorDb.#map(r)); } }