/**
* @module database/repositories/healingRepo
* @description Self-healing history CRUD backed by SQLite.
*/
import { getDatabase } from "../sqlite.js";
/**
* Get a healing entry by key.
* @param {string} key — "<testId>::<action>::<label>"
* @returns {Object|undefined}
*/
export function get(key) {
const db = getDatabase();
return db.prepare("SELECT * FROM healing_history WHERE key = ?").get(key) || undefined;
}
/**
* Upsert a healing entry.
* @param {string} key
* @param {Object} entry — { strategyIndex, succeededAt, failCount }
*/
// Lazy migration flag — ensures the strategyVersion column exists before first use.
let _migrated = false;
function ensureStrategyVersionColumn(db) {
if (_migrated) return;
try { db.prepare("ALTER TABLE healing_history ADD COLUMN strategyVersion INTEGER").run(); } catch { /* already exists */ }
_migrated = true;
}
export function set(key, entry) {
const db = getDatabase();
ensureStrategyVersionColumn(db);
db.prepare(`
INSERT INTO healing_history (key, strategyIndex, succeededAt, failCount, strategyVersion)
VALUES (@key, @strategyIndex, @succeededAt, @failCount, @strategyVersion)
ON CONFLICT(key) DO UPDATE SET
strategyIndex = @strategyIndex,
succeededAt = @succeededAt,
failCount = @failCount,
strategyVersion = @strategyVersion
`).run({
key,
strategyIndex: entry.strategyIndex ?? -1,
succeededAt: entry.succeededAt || null,
failCount: entry.failCount || 0,
strategyVersion: entry.strategyVersion ?? null,
});
}
/**
* Get all healing entries as a dictionary keyed by composite key.
* @returns {Object<string, Object>}
*/
export function getAllAsDict() {
const db = getDatabase();
const rows = db.prepare("SELECT * FROM healing_history").all();
const dict = {};
for (const r of rows) dict[r.key] = r;
return dict;
}
/**
* Get all healing entries for a specific test (keys starting with "<testId>::").
*
* Accepts both raw test IDs (`"TC-1"`) and versioned scope IDs
* (`"TC-1@v2"`). When a versioned scope is passed we also query the
* legacy (unversioned) prefix so that pre-existing healing entries
* remain readable after upgrading to versioned scopes.
*
* @param {string} testId — raw test ID or versioned scope ID
* @returns {Object<string, Object>} Map of `"action::label"` → entry.
*/
export function getByTestId(testId) {
const db = getDatabase();
// Ensure the strategyVersion column exists before querying — the ORDER BY
// clause references it, but the column is not in the initial schema (001).
// On databases where set() has never been called, the lazy migration in
// ensureStrategyVersionColumn() hasn't run yet and the query would crash
// with "no such column: strategyVersion".
ensureStrategyVersionColumn(db);
// Strip the @vN suffix (if present) to derive the base test ID so we
// can also query legacy keys stored before versioned scopes existed.
const baseId = testId.replace(/@v\d+$/, "");
const patterns = [`${testId}::%`];
// When testId already contains @v, also query unversioned legacy keys
if (baseId !== testId) {
patterns.push(`${baseId}::%`);
}
// Also query any other versioned keys for this base ID
patterns.push(`${baseId}@v%::%`);
// Build a query with the right number of OR clauses
const uniquePatterns = [...new Set(patterns)];
const whereClauses = uniquePatterns.map(() => "key LIKE ?").join(" OR ");
// ORDER BY ensures that when multiple versions exist for the same
// action::label, the versioned entry (strategyVersion IS NOT NULL)
// is processed last and wins the collision in the flat result dict.
const rows = db.prepare(
`SELECT * FROM healing_history WHERE ${whereClauses} ORDER BY strategyVersion ASC NULLS FIRST`
).all(...uniquePatterns);
const result = {};
for (const r of rows) {
const sepIdx = r.key.indexOf("::");
if (sepIdx < 0) continue;
result[r.key.slice(sepIdx + 2)] = r;
}
return result;
}
/**
* Delete healing entries for a list of test IDs.
* @param {string[]} testIds
*/
export function deleteByTestIds(testIds) {
const db = getDatabase();
const stmt = db.prepare("DELETE FROM healing_history WHERE key LIKE ?");
const txn = db.transaction(() => {
for (const tid of testIds) {
stmt.run(`${tid}::%`);
stmt.run(`${tid}@v%::%`);
}
});
txn();
}
/**
* Count healing entries for specific test IDs.
* @param {string[]} testIds
* @returns {number}
*/
export function countByTestIds(testIds) {
if (!testIds || testIds.length === 0) return 0;
const db = getDatabase();
const clauses = [];
const params = [];
for (const tid of testIds) {
clauses.push("key LIKE ?", "key LIKE ?");
params.push(`${tid}::%`, `${tid}@v%::%`);
}
return db.prepare(`SELECT COUNT(*) as cnt FROM healing_history WHERE ${clauses.join(" OR ")}`).get(...params).cnt;
}
/**
* Count successful healing entries for specific test IDs.
* @param {string[]} testIds
* @returns {number}
*/
export function countSuccessesByTestIds(testIds) {
if (!testIds || testIds.length === 0) return 0;
const db = getDatabase();
const clauses = [];
const params = [];
for (const tid of testIds) {
clauses.push("key LIKE ?", "key LIKE ?");
params.push(`${tid}::%`, `${tid}@v%::%`);
}
return db.prepare(
`SELECT COUNT(*) as cnt FROM healing_history WHERE (${clauses.join(" OR ")}) AND strategyIndex >= 0 AND succeededAt IS NOT NULL`
).get(...params).cnt;
}