From 5fa5be2136629afdcfca35e7dfb1e7bbead3c35a Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 13 Apr 2026 19:09:08 -0300 Subject: [PATCH] feat: add legacy combo reference canonicalization to database health check and improve table existence handling --- package-lock.json | 20 ++----- package.json | 2 +- src/lib/db/healthCheck.ts | 69 ++++++++++++++++------- tests/unit/db-health-check.test.mjs | 87 +++++++++++++++++++++++++++-- 4 files changed, 136 insertions(+), 42 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2e25e2dd1f..b4a15c7080 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,7 @@ "recharts": "^3.7.0", "selfsigned": "^5.5.0", "tsx": "^4.21.0", - "undici": "^8.0.2", + "undici": "^7.24.7", "uuid": "^13.0.0", "wreq-js": "^2.0.1", "xxhash-wasm": "^1.1.0", @@ -13099,16 +13099,6 @@ "node": "20 || >=22" } }, - "node_modules/jsdom/node_modules/undici": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", - "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -19735,12 +19725,12 @@ } }, "node_modules/undici": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.1.0.tgz", - "integrity": "sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", "license": "MIT", "engines": { - "node": ">=22.19.0" + "node": ">=20.18.1" } }, "node_modules/undici-types": { diff --git a/package.json b/package.json index ccd9e9be7d..23e4b83c3d 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,7 @@ "recharts": "^3.7.0", "selfsigned": "^5.5.0", "tsx": "^4.21.0", - "undici": "^8.0.2", + "undici": "^7.24.7", "uuid": "^13.0.0", "wreq-js": "^2.0.1", "xxhash-wasm": "^1.1.0", diff --git a/src/lib/db/healthCheck.ts b/src/lib/db/healthCheck.ts index fa952086b6..5b84550ae6 100644 --- a/src/lib/db/healthCheck.ts +++ b/src/lib/db/healthCheck.ts @@ -1,3 +1,5 @@ +import { normalizeComboStep } from "@/lib/combos/steps"; + type SqliteDatabase = import("better-sqlite3").Database; type JsonRecord = Record; @@ -152,9 +154,29 @@ function repairComboRows( const nextModels: unknown[] = []; let removedSteps = 0; let clearedConnectionPins = 0; + let normalizedLegacyComboRefs = 0; - for (const rawStep of currentModels) { + for (const [index, rawStep] of currentModels.entries()) { if (!isRecord(rawStep)) { + if (typeof rawStep === "string") { + const normalizedStep = normalizeComboStep(rawStep, { + comboName: row.name, + index, + allCombos: existingComboNames, + }); + if (normalizedStep?.kind === "combo-ref") { + if ( + normalizedStep.comboName === row.name || + !existingComboNames.has(normalizedStep.comboName) + ) { + removedSteps += 1; + continue; + } + nextModels.push(normalizedStep); + normalizedLegacyComboRefs += 1; + continue; + } + } nextModels.push(rawStep); continue; } @@ -181,11 +203,11 @@ function repairComboRows( nextModels.push(rawStep); } - if (removedSteps === 0 && clearedConnectionPins === 0) { + if (removedSteps === 0 && clearedConnectionPins === 0 && normalizedLegacyComboRefs === 0) { continue; } - issueCount += removedSteps + clearedConnectionPins; + issueCount += removedSteps + clearedConnectionPins + normalizedLegacyComboRefs; if (!options.autoRepair) continue; const nextCombo = { @@ -198,6 +220,9 @@ function repairComboRows( clearedConnectionPins > 0 ? `${clearedConnectionPins} missing connection pin(s) cleared.` : null, + normalizedLegacyComboRefs > 0 + ? `${normalizedLegacyComboRefs} legacy combo ref step(s) canonicalized.` + : null, ] .filter(Boolean) .join(" "), @@ -207,7 +232,7 @@ function repairComboRows( }; updateComboStmt.run(JSON.stringify(nextCombo), checkedAt, row.id); - repairedCount += removedSteps + clearedConnectionPins; + repairedCount += removedSteps + clearedConnectionPins + normalizedLegacyComboRefs; } return { issueCount, repairedCount }; @@ -391,22 +416,26 @@ export function runDbHealthCheck( }); } - const comboRows = db - .prepare( - "SELECT id, name, data, sort_order, created_at, updated_at FROM combos ORDER BY name COLLATE NOCASE ASC" - ) - .all() as ComboRow[]; - const comboRepair = repairComboRows(db, comboRows, checkedAt, { autoRepair }); - if (comboRepair.issueCount > 0) { - issues.push({ - type: "broken_reference", - table: "combos", - description: - "Combos contained broken combo references, invalid JSON, or pinned connections that no longer exist.", - count: comboRepair.issueCount, - }); - ensureBackupBeforeRepair(); - repairedCount += comboRepair.repairedCount; + if (hasRows(db, "combos")) { + const comboRows = db + .prepare( + "SELECT id, name, data, sort_order, created_at, updated_at FROM combos ORDER BY name COLLATE NOCASE ASC" + ) + .all() as ComboRow[]; + const comboRepair = repairComboRows(db, comboRows, checkedAt, { autoRepair }); + if (comboRepair.issueCount > 0) { + issues.push({ + type: "broken_reference", + table: "combos", + description: + "Combos contained broken combo references, legacy combo refs, invalid JSON, or pinned connections that no longer exist.", + count: comboRepair.issueCount, + }); + if (autoRepair) { + ensureBackupBeforeRepair(); + repairedCount += comboRepair.repairedCount; + } + } } const orphanQuotaCount = countOrphanQuotaSnapshots(db); diff --git a/tests/unit/db-health-check.test.mjs b/tests/unit/db-health-check.test.mjs index cf05e68845..f0228e5b70 100644 --- a/tests/unit/db-health-check.test.mjs +++ b/tests/unit/db-health-check.test.mjs @@ -77,6 +77,19 @@ test("runDbHealthCheck reports issues without mutating when autoRepair is disabl assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_fallback_chains").get().count, 1); }); +test("runDbHealthCheck tolerates databases without a combos table", async () => { + const db = core.getDbInstance(); + db.exec("DROP TABLE combos"); + + const result = healthCheckDb.runDbHealthCheck(db, { autoRepair: false }); + + assert.equal(result.isHealthy, true); + assert.equal( + result.issues.some((issue) => issue.table === "combos"), + false + ); +}); + test("runDbHealthCheck auto-repairs orphan rows and invalid JSON payloads", async () => { const db = core.getDbInstance(); insertBrokenRows(db); @@ -110,6 +123,25 @@ test("runDbHealthCheck repairs broken combo payloads, combo refs and stale conne name: "Healthy Connection", apiKey: "sk-healthy", }); + db.prepare( + "INSERT INTO combos (id, name, data, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" + ).run( + "combo-child", + "combo-child", + JSON.stringify({ + id: "combo-child", + name: "combo-child", + strategy: "priority", + models: ["openai/gpt-4o-mini"], + config: {}, + isActive: true, + createdAt: now, + updatedAt: now, + }), + 0, + now, + now + ); db.prepare( "INSERT INTO combos (id, name, data, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" @@ -125,6 +157,8 @@ test("runDbHealthCheck repairs broken combo payloads, combo refs and stale conne name: "combo-broken", strategy: "priority", models: [ + "combo-child", + "combo-broken", { id: "ref-missing", kind: "combo-ref", comboName: "missing-child", weight: 0 }, { id: "model-pinned", @@ -168,17 +202,58 @@ test("runDbHealthCheck repairs broken combo payloads, combo refs and stale conne result.issues.some((issue) => issue.table === "combos"), true ); - assert.equal(result.repairedCount, 3); + assert.equal(result.repairedCount, 5); assert.equal(invalidCombo.isActive, false); assert.match(invalidCombo.repairNote, /invalid JSON/i); - assert.equal(repairedCombo.models.length, 2); + assert.equal(repairedCombo.models.length, 3); assert.equal( - repairedCombo.models.some((step) => step.kind === "combo-ref"), - false + repairedCombo.models.some( + (step) => step.kind === "combo-ref" && step.comboName === "combo-child" + ), + true ); - assert.equal("connectionId" in repairedCombo.models[0], false); - assert.equal(repairedCombo.models[1].connectionId, activeConnection.id); + assert.equal("connectionId" in repairedCombo.models[1], false); + assert.equal(repairedCombo.models[2].connectionId, activeConnection.id); assert.match(repairedCombo.repairNote, /broken combo step/i); + assert.match(repairedCombo.repairNote, /legacy combo ref/i); +}); + +test("runDbHealthCheck diagnosis does not request backups for combo-only issues", async () => { + const db = core.getDbInstance(); + const now = new Date().toISOString(); + let backupAttempts = 0; + + db.prepare( + "INSERT INTO combos (id, name, data, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" + ).run( + "combo-broken", + "combo-broken", + JSON.stringify({ + id: "combo-broken", + name: "combo-broken", + strategy: "priority", + models: [{ id: "ref-missing", kind: "combo-ref", comboName: "missing-child", weight: 0 }], + config: {}, + isActive: true, + createdAt: now, + updatedAt: now, + }), + 1, + now, + now + ); + + const result = healthCheckDb.runDbHealthCheck(db, { + autoRepair: false, + createBackupBeforeRepair: () => { + backupAttempts += 1; + return true; + }, + }); + + assert.equal(result.repairedCount, 0); + assert.equal(result.backupCreated, false); + assert.equal(backupAttempts, 0); }); test("getDbInstance can auto-repair persisted broken rows when startup repair is forced", async () => {