fix(collector,trainer): migrate an existing collector DB on open; trainer handlers answer 500 JSON
The reviewer host's collector.sqlite was created by an earlier build, before the `kind` column. CREATE TABLE IF NOT EXISTS shapes only a new database, so every query naming the column failed: the collector's /health (container unhealthy), every booth ingest, and the trainer's readiness — whose stdlib server printed the traceback and dropped the socket, which the collector could only render as "trainer not reachable: fetch failed". Nine days like that. - CollectorDb.#migrate(): PRAGMA table_info against the list of columns added since the first deploy; ALTER TABLE ADD COLUMN for each missing one (all nullable or defaulted). Append to that list whenever a column joins the CREATE. Test replays the original schema: health, ingest, stats, a legacy row reads back with the defaults. - Trainer Handler._guarded(): any unexpected exception → 500 JSON naming it, never a dropped connection; /health keeps answering. Test drives readiness against an old-schema DB. - The collector's training status proxy includes the trainer's error text. Wiki: the incident and the schema rule (vision-review-outbox), what the message means (bodytype-classifier-training), log. Deploy: the new collector migrates on start; nothing manual. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -151,3 +151,46 @@ describe("config", () => {
|
||||
expect(() => parseBoothTokens("nocolon")).toThrow(/bad pair/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema migration", () => {
|
||||
it("opens a database created before the `kind` column and adds the missing columns, so ingest and stats work", async () => {
|
||||
// art-docker-station, 2026-09-16: the volume's DB predated `kind`; CREATE TABLE IF NOT
|
||||
// EXISTS left it alone, and /health, every ingest and the trainer's readiness failed
|
||||
// with "no such column: kind". Replay: a file with the ORIGINAL column set.
|
||||
const { default: Database } = await import("better-sqlite3");
|
||||
const file = path.join(dir, "old.sqlite");
|
||||
const old = new Database(file);
|
||||
old.exec(`CREATE TABLE items (
|
||||
id TEXT PRIMARY KEY, booth TEXT NOT NULL, order_ref TEXT NOT NULL, at TEXT NOT NULL,
|
||||
service TEXT NOT NULL, vision_class TEXT NOT NULL, vision_confidence REAL NOT NULL,
|
||||
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)`);
|
||||
old.prepare(
|
||||
"INSERT INTO items (id, booth, order_ref, at, service, vision_class, vision_confidence, image_width, image_height, plate_blurred, image_path, received_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
).run("legacy-1", "booth-7", "o-0", "2026-09-01T00:00:00.000Z", "Standard", "suv", 0.8, 100, 100, 1, "crops/legacy-1.jpg", "2026-09-01T00:00:00.000Z");
|
||||
old.close();
|
||||
|
||||
const legacy = await buildCollector({ host: "127.0.0.1", port: 0, dataDir: dir, boothTokens: TOKENS, reviewer: REVIEWER, trainerUrl: null }, { dbFile: file });
|
||||
await legacy.ready();
|
||||
try {
|
||||
const health = await legacy.inject({ method: "GET", url: "/health" });
|
||||
expect(health.statusCode).toBe(200);
|
||||
expect(health.json()).toMatchObject({ ok: true, booths: 1, pending: 1 });
|
||||
|
||||
const { body, type } = multipart({ meta: JSON.stringify(meta({ item: "item-new" })) }, JPEG);
|
||||
const r = await legacy.inject({ method: "POST", url: "/ingest", headers: { authorization: `Bearer ${TOKENS.get("booth-7")!}`, "content-type": type }, payload: body });
|
||||
expect(r.statusCode).toBe(201);
|
||||
|
||||
const stats = await legacy.inject({ method: "GET", url: "/api/stats", headers: { authorization: basic } });
|
||||
expect(stats.statusCode).toBe(200);
|
||||
|
||||
// The legacy row reads back with the defaults the new columns carry.
|
||||
const cols = new Database(file, { readonly: true }).prepare("PRAGMA table_info(items)").all() as { name: string }[];
|
||||
expect(cols.map((c) => c.name)).toEqual(expect.arrayContaining(["kind", "operator_ref", "operator_classes", "vision_category_id", "downgraded"]));
|
||||
const legacyRow = new Database(file, { readonly: true }).prepare("SELECT kind, operator_classes, downgraded FROM items WHERE id = 'legacy-1'").get() as Record<string, unknown>;
|
||||
expect(legacyRow).toEqual({ kind: "wash", operator_classes: "[]", downgraded: 0 });
|
||||
} finally {
|
||||
await legacy.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user