From 56816102b5a79cdc1c356d2f85cd0954ece7775c Mon Sep 17 00:00:00 2001 From: real-venus Date: Sun, 12 Jul 2026 08:43:20 -0700 Subject: [PATCH] feat(miner): add store integrity checks and ledger retention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doctor previously health-checked only one local store with a bare SELECT 1, and the append-only event/governor/prediction ledgers grew forever with no retention policy. Add a shared store-maintenance module: - checkStoreIntegrity: run PRAGMA integrity_check on a store file (a not-yet-created store is healthy by absence; a store that cannot be opened/read is reported as not-ok). doctor now sweeps every local store, so it flags a corrupted one instead of probing just one. - resolveLedgerRetentionPolicy / pruneLedgerByRetention: an opt-in, age- and/or size-based retention policy for the append-only ledgers, OFF by default (enabled via GITTENSORY_MINER_LEDGER_RETENTION_DAYS / _MAX_ROWS). Pruning deletes aged and excess rows atomically; the three ledgers apply it at init. Timestamp columns are UTC ISO-8601, so the age cutoff is a correct lexicographic comparison. Table/column names are fixed internal constants, validated as plain identifiers before interpolation. Pure control flow over injected inputs (DB handle, env, caller-supplied clock) — no network and no internal clock in the prune path. Fully unit-tested, including the corrupt-store and both retention bounds. Closes #4834 --- packages/gittensory-miner/lib/event-ledger.js | 3 + .../gittensory-miner/lib/governor-ledger.js | 3 + .../gittensory-miner/lib/prediction-ledger.js | 3 + packages/gittensory-miner/lib/status.js | 24 +++ .../lib/store-maintenance.d.ts | 23 ++ .../gittensory-miner/lib/store-maintenance.js | 134 ++++++++++++ test/unit/miner-status.test.ts | 20 +- test/unit/miner-store-maintenance.test.ts | 199 ++++++++++++++++++ 8 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 packages/gittensory-miner/lib/store-maintenance.d.ts create mode 100644 packages/gittensory-miner/lib/store-maintenance.js create mode 100644 test/unit/miner-store-maintenance.test.ts diff --git a/packages/gittensory-miner/lib/event-ledger.js b/packages/gittensory-miner/lib/event-ledger.js index 09c0585f60..df8aee631a 100644 --- a/packages/gittensory-miner/lib/event-ledger.js +++ b/packages/gittensory-miner/lib/event-ledger.js @@ -1,6 +1,7 @@ import { isDeepStrictEqual } from "node:util"; import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; import { applySchemaMigrations } from "./schema-version.js"; +import { pruneLedgerByRetention, resolveLedgerRetentionPolicy, EVENT_LEDGER_RETENTION_SPEC } from "./store-maintenance.js"; // The miner's local, append-only event ledger (#2290): an immutable audit trail of every significant miner-loop // event (discovered_issue, plan_built, plan_step_completed, pr_prepared, … — a small fixed vocabulary for this @@ -106,6 +107,8 @@ export function initEventLedger(dbPath = resolveEventLedgerDbPath()) { `); // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet). applySchemaMigrations(db, []); + // Opt-in retention (#4834): prune aged/excess rows when an operator has enabled it; a no-op by default. + pruneLedgerByRetention(db, EVENT_LEDGER_RETENTION_SPEC, resolveLedgerRetentionPolicy(), Date.now()); const nextSeqStatement = db.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS nextSeq FROM miner_event_ledger"); const appendStatement = db.prepare(` diff --git a/packages/gittensory-miner/lib/governor-ledger.js b/packages/gittensory-miner/lib/governor-ledger.js index 0c39c47080..438efbf188 100644 --- a/packages/gittensory-miner/lib/governor-ledger.js +++ b/packages/gittensory-miner/lib/governor-ledger.js @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { normalizeGovernorLedgerEvent } from "@jsonbored/gittensory-engine"; import { applySchemaMigrations } from "./schema-version.js"; +import { pruneLedgerByRetention, resolveLedgerRetentionPolicy, GOVERNOR_LEDGER_RETENTION_SPEC } from "./store-maintenance.js"; // Append-only governor decision ledger (#2328): every allowed/denied/throttled/kill-switch outcome lands in a // local SQLite table for contributor audit. IMMUTABILITY INVARIANT: INSERT + SELECT only — never UPDATE/DELETE. @@ -90,6 +91,8 @@ export function initGovernorLedger(dbPath = resolveGovernorLedgerDbPath()) { db.exec("CREATE INDEX IF NOT EXISTS idx_governor_events_repo ON governor_events (repo_full_name, id)"); // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet). applySchemaMigrations(db, []); + // Opt-in retention (#4834): prune aged/excess rows when an operator has enabled it; a no-op by default. + pruneLedgerByRetention(db, GOVERNOR_LEDGER_RETENTION_SPEC, resolveLedgerRetentionPolicy(), Date.now()); const appendStatement = db.prepare(` INSERT INTO governor_events (ts, event_type, repo_full_name, action_class, decision, reason, payload_json) diff --git a/packages/gittensory-miner/lib/prediction-ledger.js b/packages/gittensory-miner/lib/prediction-ledger.js index 2bf7f4afaf..2176f3b556 100644 --- a/packages/gittensory-miner/lib/prediction-ledger.js +++ b/packages/gittensory-miner/lib/prediction-ledger.js @@ -2,6 +2,7 @@ import { chmodSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { DatabaseSync } from "node:sqlite"; +import { pruneLedgerByRetention, resolveLedgerRetentionPolicy, PREDICTION_LEDGER_RETENTION_SPEC } from "./store-maintenance.js"; // Append-only prediction ledger (#4263): every predicted-gate verdict the miner computes for a target lands in // a local SQLite table so a later self-improve pass can score the prediction against the realized pr_outcome. @@ -145,6 +146,8 @@ export function initPredictionLedger(dbPath = resolvePredictionLedgerDbPath()) { ) `); db.exec("CREATE INDEX IF NOT EXISTS idx_predictions_repo ON predictions (repo_full_name, id)"); + // Opt-in retention (#4834): prune aged/excess rows when an operator has enabled it; a no-op by default. + pruneLedgerByRetention(db, PREDICTION_LEDGER_RETENTION_SPEC, resolveLedgerRetentionPolicy(), Date.now()); const appendStatement = db.prepare(` INSERT INTO predictions diff --git a/packages/gittensory-miner/lib/status.js b/packages/gittensory-miner/lib/status.js index 7d50754b4a..09efd516ce 100644 --- a/packages/gittensory-miner/lib/status.js +++ b/packages/gittensory-miner/lib/status.js @@ -11,6 +11,14 @@ import { findExecutableOnPath, } from "./laptop-init.js"; import { resolveMinerVersion } from "./version.js"; +import { checkStoreIntegrity } from "./store-maintenance.js"; +import { resolveEventLedgerDbPath } from "./event-ledger.js"; +import { resolveGovernorLedgerDbPath } from "./governor-ledger.js"; +import { resolvePredictionLedgerDbPath } from "./prediction-ledger.js"; +import { resolvePortfolioQueueDbPath } from "./portfolio-queue.js"; +import { resolveClaimLedgerDbPath } from "./claim-ledger.js"; +import { resolveRunStateDbPath } from "./run-state.js"; +import { resolvePlanStoreDbPath } from "./plan-store.js"; // Slim laptop-mode CLI commands (#2288): `status` (what's installed + where local state lives) and `doctor` (is // this laptop set up correctly). Both are read-only and 100% local — no repo-scanning, no coding-agent invocation, @@ -271,6 +279,21 @@ function checkStateDirWritable(stateDir) { } } +/** Per-store `PRAGMA integrity_check` sweep for `doctor` (#4834) — flags a corrupted store instead of probing + * only one with `SELECT 1`. A store file that does not exist yet is healthy by absence. */ +function storeIntegrityChecks(env) { + const stores = [ + ["event-ledger", resolveEventLedgerDbPath(env)], + ["governor-ledger", resolveGovernorLedgerDbPath(env)], + ["prediction-ledger", resolvePredictionLedgerDbPath(env)], + ["portfolio-queue", resolvePortfolioQueueDbPath(env)], + ["claim-ledger", resolveClaimLedgerDbPath(env)], + ["run-state", resolveRunStateDbPath(env)], + ["plan-store", resolvePlanStoreDbPath(env)], + ]; + return stores.map(([name, dbPath]) => checkStoreIntegrity(`store-integrity:${name}`, dbPath)); +} + /** Run the doctor checks. Returns an array of { name, ok, detail }; only writes a transient probe in the state dir, * never touches the network. */ export function runDoctorChecks(env = process.env) { @@ -294,6 +317,7 @@ export function runDoctorChecks(env = process.env) { checkDockerPresent(), checkClaudeCliPresent({ env }), checkCodexCliPresent({ env }), + ...storeIntegrityChecks(env), ]; } diff --git a/packages/gittensory-miner/lib/store-maintenance.d.ts b/packages/gittensory-miner/lib/store-maintenance.d.ts new file mode 100644 index 0000000000..24a5472908 --- /dev/null +++ b/packages/gittensory-miner/lib/store-maintenance.d.ts @@ -0,0 +1,23 @@ +import type { DatabaseSync } from "node:sqlite"; + +export const LEDGER_RETENTION_DAYS_ENV: string; +export const LEDGER_RETENTION_MAX_ROWS_ENV: string; + +export type LedgerRetentionSpec = { table: string; timestampColumn: string; orderColumn: string }; +export const EVENT_LEDGER_RETENTION_SPEC: LedgerRetentionSpec; +export const GOVERNOR_LEDGER_RETENTION_SPEC: LedgerRetentionSpec; +export const PREDICTION_LEDGER_RETENTION_SPEC: LedgerRetentionSpec; + +export type StoreIntegrityResult = { name: string; ok: boolean; detail: string }; +export type LedgerRetentionPolicy = { maxAgeMs?: number; maxRows?: number }; + +export function describeError(error: unknown): string; +export function classifyIntegrityRows(rows: Array<{ integrity_check?: unknown }>): { ok: boolean; note: string }; +export function checkStoreIntegrity(name: string, dbPath: string): StoreIntegrityResult; +export function resolveLedgerRetentionPolicy(env?: Record): LedgerRetentionPolicy | null; +export function pruneLedgerByRetention( + db: DatabaseSync, + spec: LedgerRetentionSpec, + policy: LedgerRetentionPolicy | null, + nowMs: number, +): number; diff --git a/packages/gittensory-miner/lib/store-maintenance.js b/packages/gittensory-miner/lib/store-maintenance.js new file mode 100644 index 0000000000..39900640af --- /dev/null +++ b/packages/gittensory-miner/lib/store-maintenance.js @@ -0,0 +1,134 @@ +// Local-store maintenance for the miner (#4834): SQLite integrity checks + append-only ledger retention. +// +// Two independent, side-effect-light helpers used by `doctor` and the ledgers: +// 1. checkStoreIntegrity — run `PRAGMA integrity_check` on one store file and report health, so `doctor` can +// flag a corrupted store instead of only probing a single one with `SELECT 1`. +// 2. resolveLedgerRetentionPolicy / pruneLedgerByRetention — an opt-in, age- and/or size-based retention +// policy for the unbounded append-only ledgers (event, governor, prediction), which otherwise grow forever. +// OFF by default: retention only runs when an operator sets the env opt-in. +// Pure control flow over injected inputs (a DB handle, an env object, a caller-supplied clock) — no network, and +// no internal clock read in the prune path so it stays deterministic and unit-testable. +import { existsSync } from "node:fs"; +import { DatabaseSync } from "node:sqlite"; + +/** Env opt-ins for ledger retention (unset ⇒ retention disabled). */ +export const LEDGER_RETENTION_DAYS_ENV = "GITTENSORY_MINER_LEDGER_RETENTION_DAYS"; +export const LEDGER_RETENTION_MAX_ROWS_ENV = "GITTENSORY_MINER_LEDGER_RETENTION_MAX_ROWS"; + +/** Fixed retention specs for the three append-only ledgers. These identifiers are INTERNAL constants — never + * caller/user text — and are validated as plain identifiers before interpolation as defence in depth. */ +export const EVENT_LEDGER_RETENTION_SPEC = { table: "miner_event_ledger", timestampColumn: "created_at", orderColumn: "id" }; +export const GOVERNOR_LEDGER_RETENTION_SPEC = { table: "governor_events", timestampColumn: "ts", orderColumn: "id" }; +export const PREDICTION_LEDGER_RETENTION_SPEC = { table: "predictions", timestampColumn: "ts", orderColumn: "id" }; + +const SQL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** A readable message for a caught value, whether or not it is an Error. */ +export function describeError(error) { + return error instanceof Error ? error.message : String(error); +} + +/** + * Classify raw `PRAGMA integrity_check` rows. A healthy database yields a single `"ok"` row; a corrupt one yields + * one row per problem. Pure — extracted so both the healthy and problem paths are testable without a genuinely + * corrupt file (which SQLite typically refuses to open at all, i.e. the catch path below). + * @param {Array<{ integrity_check?: unknown }>} rows + * @returns {{ ok: boolean, note: string }} + */ +export function classifyIntegrityRows(rows) { + const problems = rows.map((row) => String(row.integrity_check)).filter((value) => value !== "ok"); + return problems.length === 0 ? { ok: true, note: "ok" } : { ok: false, note: problems.join("; ") }; +} + +/** + * Run `PRAGMA integrity_check` on a single store file. A store that does not exist yet is healthy by absence + * (nothing to corrupt). Never throws: a store that cannot be opened or read is reported as not-ok, so one bad + * store cannot abort the whole doctor sweep. + * @param {string} name - the check label (e.g. "event-ledger"). + * @param {string} dbPath - the store file path. + * @returns {{ name: string, ok: boolean, detail: string }} + */ +export function checkStoreIntegrity(name, dbPath) { + if (!existsSync(dbPath)) { + return { name, ok: true, detail: `${dbPath}: not created yet` }; + } + let db; + try { + db = new DatabaseSync(dbPath, { readonly: true }); + const { ok, note } = classifyIntegrityRows(db.prepare("PRAGMA integrity_check").all()); + return { name, ok, detail: `${dbPath}: ${note}` }; + } catch (error) { + return { name, ok: false, detail: `${dbPath}: ${describeError(error)}` }; + } finally { + db?.close(); + } +} + +/** Coerce an env value to a positive integer, or null (unset/blank/zero/negative/non-finite ⇒ null ⇒ disabled). + * Floors BEFORE the positivity test, so a fractional value below 1 (e.g. "0.5") floors to 0 and disables the + * bound rather than becoming a dangerous 0 that would prune the whole ledger. */ +function positiveIntOrNull(raw) { + if (raw === undefined || raw === null || String(raw).trim() === "") return null; + const numeric = Math.floor(Number(raw)); + return Number.isFinite(numeric) && numeric > 0 ? numeric : null; +} + +/** + * Resolve the opt-in ledger retention policy from an env object. OFF by default: returns null unless at least + * one bound is set to a positive value. A zero/negative/non-numeric value is treated as unset. When set, returns + * `{ maxAgeMs? }` (from a day count) and/or `{ maxRows? }`. + * @param {NodeJS.ProcessEnv} [env] + * @returns {{ maxAgeMs?: number, maxRows?: number } | null} + */ +export function resolveLedgerRetentionPolicy(env = process.env) { + const maxAgeDays = positiveIntOrNull(env[LEDGER_RETENTION_DAYS_ENV]); + const maxRows = positiveIntOrNull(env[LEDGER_RETENTION_MAX_ROWS_ENV]); + if (maxAgeDays === null && maxRows === null) return null; + const policy = {}; + if (maxAgeDays !== null) policy.maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000; + if (maxRows !== null) policy.maxRows = maxRows; + return policy; +} + +/** + * Prune one append-only ledger per a resolved retention policy: delete rows older than the age bound AND rows + * beyond the row-count bound (keeping the newest `maxRows` by `orderColumn`), atomically. A null policy is a + * no-op. `nowMs` is caller-supplied (no internal clock). Timestamp columns are UTC ISO-8601 strings, which sort + * lexicographically in chronological order, so a string comparison against the ISO cutoff selects older rows. + * @param {import("node:sqlite").DatabaseSync} db + * @param {{ table: string, timestampColumn: string, orderColumn: string }} spec + * @param {{ maxAgeMs?: number, maxRows?: number } | null} policy + * @param {number} nowMs + * @returns {number} rows deleted + */ +export function pruneLedgerByRetention(db, spec, policy, nowMs) { + if (!policy) return 0; + for (const identifier of [spec.table, spec.timestampColumn, spec.orderColumn]) { + if (!SQL_IDENTIFIER.test(identifier)) throw new Error(`unsafe SQL identifier: ${identifier}`); + } + let deleted = 0; + db.exec("BEGIN"); + try { + // Both bounds are guarded to be strictly positive as defence in depth: a 0 age would prune everything older + // than `now`, and a 0 row-cap makes `LIMIT 0` match no rows so `NOT IN (empty)` would delete the whole ledger. + if (policy.maxAgeMs !== undefined && policy.maxAgeMs > 0) { + const cutoff = new Date(nowMs - policy.maxAgeMs).toISOString(); + const info = db.prepare(`DELETE FROM ${spec.table} WHERE ${spec.timestampColumn} < ?`).run(cutoff); + deleted += Number(info.changes); + } + if (policy.maxRows !== undefined && policy.maxRows >= 1) { + const info = db + .prepare( + `DELETE FROM ${spec.table} WHERE ${spec.orderColumn} NOT IN ` + + `(SELECT ${spec.orderColumn} FROM ${spec.table} ORDER BY ${spec.orderColumn} DESC LIMIT ?)`, + ) + .run(policy.maxRows); + deleted += Number(info.changes); + } + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } + return deleted; +} diff --git a/test/unit/miner-status.test.ts b/test/unit/miner-status.test.ts index 1c562fd3d3..79fec77a2a 100644 --- a/test/unit/miner-status.test.ts +++ b/test/unit/miner-status.test.ts @@ -1,6 +1,7 @@ import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { resolveEventLedgerDbPath } from "../../packages/gittensory-miner/lib/event-ledger.js"; import { afterEach, describe, expect, it, vi } from "vitest"; import { buildEngineVersionSkewCheck, @@ -92,11 +93,28 @@ describe("gittensory-miner status/doctor (#2288)", () => { "docker-present", "claude-cli-present", "codex-cli-present", + "store-integrity:event-ledger", + "store-integrity:governor-ledger", + "store-integrity:prediction-ledger", + "store-integrity:portfolio-queue", + "store-integrity:claim-ledger", + "store-integrity:run-state", + "store-integrity:plan-store", ]); expect(runDoctor([], env)).toBe(0); expect(log).toHaveBeenCalled(); }); + it("doctor flags a corrupted store (#4834)", () => { + const env = { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state") }; + const eventLedgerPath = resolveEventLedgerDbPath(env); + mkdirSync(dirname(eventLedgerPath), { recursive: true }); + writeFileSync(eventLedgerPath, "this is not a sqlite database"); + const checks = runDoctorChecks(env); + expect(checks.find((check) => check.name === "store-integrity:event-ledger")?.ok).toBe(false); + expect(runDoctor([], env)).toBe(1); // a failed check makes doctor exit non-zero + }); + it("engine version skew helpers compare installed vs expected semver", () => { expect(compareInstalledEngineVersion("0.2.0", "0.2.0")).toBe(0); expect(compareInstalledEngineVersion("0.1.0", "0.2.0")).toBe(-1); diff --git a/test/unit/miner-store-maintenance.test.ts b/test/unit/miner-store-maintenance.test.ts new file mode 100644 index 0000000000..94b20daf45 --- /dev/null +++ b/test/unit/miner-store-maintenance.test.ts @@ -0,0 +1,199 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { afterEach, describe, expect, it } from "vitest"; +import { + EVENT_LEDGER_RETENTION_SPEC, + LEDGER_RETENTION_DAYS_ENV, + LEDGER_RETENTION_MAX_ROWS_ENV, + checkStoreIntegrity, + classifyIntegrityRows, + describeError, + pruneLedgerByRetention, + resolveLedgerRetentionPolicy, +} from "../../packages/gittensory-miner/lib/store-maintenance.js"; + +const tempDirs: string[] = []; +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "miner-store-maint-")); + tempDirs.push(dir); + return dir; +} + +// A minimal append-only ledger matching the event-ledger's retention spec (created_at TEXT, id order column). +function seedLedger(rows: Array<{ createdAt: string }>): DatabaseSync { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE miner_event_ledger (id INTEGER PRIMARY KEY AUTOINCREMENT, created_at TEXT NOT NULL)"); + const insert = db.prepare("INSERT INTO miner_event_ledger (created_at) VALUES (?)"); + for (const row of rows) insert.run(row.createdAt); + return db; +} +function rowCount(db: DatabaseSync): number { + return Number((db.prepare("SELECT COUNT(*) AS n FROM miner_event_ledger").get() as { n: number }).n); +} + +describe("classifyIntegrityRows (#4834)", () => { + it("reports ok for a single 'ok' row", () => { + expect(classifyIntegrityRows([{ integrity_check: "ok" }])).toEqual({ ok: true, note: "ok" }); + }); + it("joins every problem row when not ok", () => { + expect(classifyIntegrityRows([{ integrity_check: "row 3 missing" }, { integrity_check: "page 7 bad" }])).toEqual({ + ok: false, + note: "row 3 missing; page 7 bad", + }); + }); +}); + +describe("describeError (#4834)", () => { + it("uses an Error's message and stringifies a non-Error value", () => { + expect(describeError(new Error("boom"))).toBe("boom"); + expect(describeError("plain string")).toBe("plain string"); + }); +}); + +describe("checkStoreIntegrity (#4834)", () => { + it("treats a not-yet-created store as healthy by absence", () => { + const result = checkStoreIntegrity("event-ledger", join(tempDir(), "missing.sqlite3")); + expect(result).toMatchObject({ name: "event-ledger", ok: true }); + expect(result.detail).toContain("not created yet"); + }); + + it("reports ok for a healthy database file", () => { + const path = join(tempDir(), "healthy.sqlite3"); + const db = new DatabaseSync(path); + db.exec("CREATE TABLE t (id INTEGER)"); + db.close(); + const result = checkStoreIntegrity("plan-store", path); + expect(result.ok).toBe(true); + expect(result.detail).toContain("ok"); + }); + + it("reports not-ok for a file that is not a valid database (read fails after open)", () => { + const path = join(tempDir(), "garbage.sqlite3"); + writeFileSync(path, "this is not a sqlite database"); + const result = checkStoreIntegrity("event-ledger", path); + expect(result.ok).toBe(false); + expect(existsSync(path)).toBe(true); + }); + + it("reports not-ok when the path cannot be opened as a database at all (e.g. a directory)", () => { + // A directory exists but cannot be opened as a SQLite file, so the open itself throws (the handle is never + // assigned) — exercises the open-failure path and the no-handle-to-close branch. + const result = checkStoreIntegrity("event-ledger", tempDir()); + expect(result.ok).toBe(false); + }); +}); + +describe("resolveLedgerRetentionPolicy (#4834)", () => { + it("returns null (off) when neither env opt-in is set", () => { + expect(resolveLedgerRetentionPolicy({})).toBeNull(); + }); + it("returns an age policy from a day count", () => { + expect(resolveLedgerRetentionPolicy({ [LEDGER_RETENTION_DAYS_ENV]: "30" })).toEqual({ maxAgeMs: 30 * 86_400_000 }); + }); + it("returns a row-count policy", () => { + expect(resolveLedgerRetentionPolicy({ [LEDGER_RETENTION_MAX_ROWS_ENV]: "500" })).toEqual({ maxRows: 500 }); + }); + it("returns both bounds when both are set", () => { + const policy = resolveLedgerRetentionPolicy({ + [LEDGER_RETENTION_DAYS_ENV]: "7", + [LEDGER_RETENTION_MAX_ROWS_ENV]: "1000", + }); + expect(policy).toEqual({ maxAgeMs: 7 * 86_400_000, maxRows: 1000 }); + }); + it("ignores zero, negative, blank, non-numeric, and non-finite values (treated as unset)", () => { + expect(resolveLedgerRetentionPolicy({ [LEDGER_RETENTION_DAYS_ENV]: "0", [LEDGER_RETENTION_MAX_ROWS_ENV]: "-5" })).toBeNull(); + expect(resolveLedgerRetentionPolicy({ [LEDGER_RETENTION_DAYS_ENV]: " ", [LEDGER_RETENTION_MAX_ROWS_ENV]: "abc" })).toBeNull(); + expect(resolveLedgerRetentionPolicy({ [LEDGER_RETENTION_DAYS_ENV]: "Infinity" })).toBeNull(); + expect(resolveLedgerRetentionPolicy({ [LEDGER_RETENTION_MAX_ROWS_ENV]: "2.9" })).toEqual({ maxRows: 2 }); // floors ≥1 + }); + + it("floors a fractional value BELOW 1 to a disabled null, never a dangerous 0", () => { + // Regression: "0.5" must NOT resolve to 0 (which would prune the whole ledger) — it floors to 0 ⇒ disabled. + expect(resolveLedgerRetentionPolicy({ [LEDGER_RETENTION_MAX_ROWS_ENV]: "0.5" })).toBeNull(); + expect(resolveLedgerRetentionPolicy({ [LEDGER_RETENTION_DAYS_ENV]: "0.9" })).toBeNull(); + }); +}); + +describe("pruneLedgerByRetention (#4834)", () => { + const NOW = Date.parse("2026-07-12T00:00:00.000Z"); + const iso = (ms: number) => new Date(ms).toISOString(); + + it("is a no-op for a null policy (retention off)", () => { + const db = seedLedger([{ createdAt: iso(NOW) }]); + expect(pruneLedgerByRetention(db, EVENT_LEDGER_RETENTION_SPEC, null, NOW)).toBe(0); + expect(rowCount(db)).toBe(1); + db.close(); + }); + + it("deletes rows older than the age bound and keeps ones at or after the cutoff", () => { + const db = seedLedger([ + { createdAt: iso(NOW - 10 * 86_400_000) }, // 10 days old → pruned + { createdAt: iso(NOW - 5 * 86_400_000) }, // exactly at the 5-day cutoff → kept + { createdAt: iso(NOW - 1 * 86_400_000) }, // 1 day old → kept + ]); + const deleted = pruneLedgerByRetention(db, EVENT_LEDGER_RETENTION_SPEC, { maxAgeMs: 5 * 86_400_000 }, NOW); + expect(deleted).toBe(1); + expect(rowCount(db)).toBe(2); + db.close(); + }); + + it("keeps only the newest maxRows by the order column", () => { + const db = seedLedger(Array.from({ length: 5 }, (_, i) => ({ createdAt: iso(NOW - i * 1000) }))); + const deleted = pruneLedgerByRetention(db, EVENT_LEDGER_RETENTION_SPEC, { maxRows: 2 }, NOW); + expect(deleted).toBe(3); + const ids = (db.prepare("SELECT id FROM miner_event_ledger ORDER BY id ASC").all() as Array<{ id: number }>).map((r) => r.id); + expect(ids).toEqual([4, 5]); // the two most recently inserted rows + db.close(); + }); + + it("applies both bounds together", () => { + const db = seedLedger([ + { createdAt: iso(NOW - 100 * 86_400_000) }, // very old → age-pruned + { createdAt: iso(NOW - 2 * 86_400_000) }, + { createdAt: iso(NOW - 1 * 86_400_000) }, + { createdAt: iso(NOW) }, + ]); + const deleted = pruneLedgerByRetention(db, EVENT_LEDGER_RETENTION_SPEC, { maxAgeMs: 3 * 86_400_000, maxRows: 2 }, NOW); + expect(deleted).toBe(2); // 1 by age + 1 by row cap → newest 2 remain + expect(rowCount(db)).toBe(2); + db.close(); + }); + + it("does not prune when the ledger is within both bounds", () => { + const db = seedLedger([{ createdAt: iso(NOW) }, { createdAt: iso(NOW - 1000) }]); + expect(pruneLedgerByRetention(db, EVENT_LEDGER_RETENTION_SPEC, { maxAgeMs: 86_400_000, maxRows: 10 }, NOW)).toBe(0); + expect(rowCount(db)).toBe(2); + db.close(); + }); + + it("never deletes the whole ledger for a degenerate zero bound (defence in depth)", () => { + const db = seedLedger([{ createdAt: iso(NOW) }, { createdAt: iso(NOW - 100 * 86_400_000) }]); + // A 0 age would otherwise prune everything older than now; a 0 row-cap would make NOT IN (empty) delete all. + expect(pruneLedgerByRetention(db, EVENT_LEDGER_RETENTION_SPEC, { maxAgeMs: 0, maxRows: 0 }, NOW)).toBe(0); + expect(rowCount(db)).toBe(2); + db.close(); + }); + + it("rejects an unsafe SQL identifier in the spec", () => { + const db = seedLedger([]); + expect(() => + pruneLedgerByRetention(db, { table: "bad; DROP TABLE t", timestampColumn: "created_at", orderColumn: "id" }, { maxRows: 1 }, NOW), + ).toThrow(/unsafe SQL identifier/); + db.close(); + }); + + it("rolls back and rethrows when a delete fails (e.g. an unknown table)", () => { + const db = seedLedger([{ createdAt: iso(NOW) }]); + expect(() => + pruneLedgerByRetention(db, { table: "nonexistent_table", timestampColumn: "ts", orderColumn: "id" }, { maxAgeMs: 1000 }, NOW), + ).toThrow(); + // the original ledger is untouched, and the failed transaction left no open transaction behind + expect(rowCount(db)).toBe(1); + db.close(); + }); +});