/**
* @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;
}
/**
* Chunk a `key LIKE` test-ID query so the OR fanout per statement is bounded.
*
* Each (deduped) base test ID expands to two LIKE clauses (raw +
* `@v%`-versioned), so a chunk size of 100 caps the OR list at 200 clauses
* per query — well within the Postgres planner's comfort zone for OR-of-LIKE
* expressions on large workspaces. SQLite handles arbitrary OR depth, but
* chunking keeps both adapters on the same execution path.
*
* Input `testIds` are first collapsed to their **base** form (the `@vN`
* suffix is stripped) and de-duplicated. Without this, a list containing
* both `"TC-1"` and `"TC-1@v2"` would emit overlapping patterns —
* `TC-1@v%::%` from the first and `TC-1@v2::%` from the second — and any
* row like `TC-1@v2::click::submit` would match both. Per-chunk `COUNT(*)`
* results would then be summed across chunks and double-count rows that
* straddle the chunk boundary; `SELECT *` callers would see duplicate rows.
* Collapsing to base IDs makes the per-row match set disjoint across the
* entire input, regardless of chunk size.
*
* @param {Object} db — better-sqlite3 Database handle
* @param {string[]} testIds
* @param {Function} sqlFn — `(clauses, params) => any` invoked per chunk; return value collected into the result array.
* @returns {Array} per-chunk results in input order
*/
const HEALING_TESTID_CHUNK = 100;
function chunkedTestIdQuery(db, testIds, sqlFn) {
// Collapse to unique base IDs so versioned variants of the same test
// don't emit overlapping LIKE patterns (which would inflate COUNT(*)
// sums across chunks and yield duplicate rows on SELECT). The two
// patterns per base ID — `${base}::%` and `${base}@v%::%` — already
// cover every row that any versioned variant of the same test could
// match, so dropping the variants loses no data.
const baseIds = [...new Set(testIds.map((t) => String(t).replace(/@v\d+$/, "")))];
const out = [];
for (let i = 0; i < baseIds.length; i += HEALING_TESTID_CHUNK) {
const slice = baseIds.slice(i, i + HEALING_TESTID_CHUNK);
const clauses = slice.flatMap(() => ["key LIKE ?", "key LIKE ?"]).join(" OR ");
const params = slice.flatMap((t) => [`${t}::%`, `${t}@v%::%`]);
out.push(sqlFn(clauses, params));
}
return out;
}
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;
}
/**
* Get all healing entries scoped to a list of test IDs.
*
* Filters at the SQL layer using `key LIKE` patterns so we never load other
* workspaces' rows into Node memory — the workspace-scoped replacement for
* `getAllAsDict()` when the caller already knows which test IDs belong to
* the current workspace. Matches both `<testId>::%` (raw) and `<testId>@v%::%`
* (versioned scope) prefixes, mirroring `countByTestIds` / `deleteByTestIds`.
*
* Returns raw rows — when the same `(testId, action, label)` tuple has both
* a legacy unversioned row and one or more versioned rows, callers will see
* one entry per row. `ORDER BY strategyVersion ASC NULLS FIRST` mirrors the
* sort `getByTestId` uses so callers can deduplicate via the
* "later-row-wins" pattern (Map.set keyed on `baseTestId::action::label`)
* and end up with the versioned entry as the survivor — matching the
* single-test reader's semantics.
*
* @param {string[]} testIds
* @returns {Object[]} Raw healing_history rows (key, strategyIndex, succeededAt, failCount, strategyVersion, …), oldest-first by strategyVersion.
*/
export function getByTestIds(testIds) {
if (!testIds || testIds.length === 0) return [];
const db = getDatabase();
ensureStrategyVersionColumn(db);
// Chunk the OR fanout, then concat. We re-sort the merged result so
// `strategyVersion ASC NULLS FIRST` semantics hold across chunk boundaries
// — callers rely on this ordering to dedupe via "later-row-wins".
const chunks = chunkedTestIdQuery(db, testIds, (clauses, params) =>
db.prepare(
`SELECT * FROM healing_history WHERE ${clauses} ORDER BY strategyVersion ASC NULLS FIRST`
).all(...params)
);
const rows = chunks.flat();
if (chunks.length > 1) {
rows.sort((a, b) => {
const av = a.strategyVersion;
const bv = b.strategyVersion;
if (av === bv) return 0;
if (av === null || av === undefined) return -1;
if (bv === null || bv === undefined) return 1;
return av - bv;
});
}
return rows;
}
/**
* 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 counts = chunkedTestIdQuery(db, testIds, (clauses, params) =>
db.prepare(`SELECT COUNT(*) as cnt FROM healing_history WHERE ${clauses}`).get(...params).cnt
);
return counts.reduce((a, b) => a + b, 0);
}
/**
* 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 counts = chunkedTestIdQuery(db, testIds, (clauses, params) =>
db.prepare(
`SELECT COUNT(*) as cnt FROM healing_history WHERE (${clauses}) AND strategyIndex >= 0 AND succeededAt IS NOT NULL`
).get(...params).cnt
);
return counts.reduce((a, b) => a + b, 0);
}