diff --git a/packages/loopover-miner/lib/governor-ledger.js b/packages/loopover-miner/lib/governor-ledger.js index 4b5ecd30b6..fd7570b2a3 100644 --- a/packages/loopover-miner/lib/governor-ledger.js +++ b/packages/loopover-miner/lib/governor-ledger.js @@ -89,6 +89,18 @@ function rowToDecision(row) { }; } +// v1 -> v2 (#4939/#6597): additive tenant-scoping column, a prerequisite for any hosted, multi-tenant use of +// this same store's logic. NULL for every row today -- self-host behavior is byte-identical, since nothing +// reads or writes it yet. Same defensive column-presence guard as this file's sibling stores' own additive +// migrations (e.g. event-ledger.js's addTenantIdColumn). +function addTenantIdColumn(db) { + const hasTenantIdColumn = db + .prepare("PRAGMA table_info(governor_events)") + .all() + .some((column) => column.name === "tenant_id"); + if (!hasTenantIdColumn) db.exec("ALTER TABLE governor_events ADD COLUMN tenant_id TEXT"); +} + /** * Opens the append-only governor ledger, creating the table on first use. Rows are returned in ascending `id` * order (insertion order). (#2328) @@ -109,8 +121,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, []); + // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations. + applySchemaMigrations(db, [addTenantIdColumn]); // 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()); diff --git a/packages/loopover-miner/lib/plan-store.js b/packages/loopover-miner/lib/plan-store.js index 8d7a3be3fa..a289648d05 100644 --- a/packages/loopover-miner/lib/plan-store.js +++ b/packages/loopover-miner/lib/plan-store.js @@ -142,6 +142,18 @@ function rowToRecord(row) { return { planId: row.plan_id, plan, status: row.status, updatedAt: row.updated_at }; } +// v1 -> v2 (#4939/#6597): additive tenant-scoping column, a prerequisite for any hosted, multi-tenant use of +// this same store's logic. NULL for every row today -- self-host behavior is byte-identical, since nothing +// reads or writes it yet. Same defensive column-presence guard as this file's sibling stores' own additive +// migrations (e.g. event-ledger.js's addTenantIdColumn). +function addTenantIdColumn(db) { + const hasTenantIdColumn = db + .prepare("PRAGMA table_info(miner_plans)") + .all() + .some((column) => column.name === "tenant_id"); + if (!hasTenantIdColumn) db.exec("ALTER TABLE miner_plans ADD COLUMN tenant_id TEXT"); +} + /** * Opens the local plan store, creating the table on first use. `savePlan` is a single atomic INSERT…ON CONFLICT * upsert keyed by `plan_id`; the plan JSON is validated on save AND re-validated on load, so a corrupted row is @@ -160,8 +172,8 @@ export function openPlanStore(dbPath = resolvePlanStoreDbPath()) { updated_at TEXT NOT NULL ) `); - // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet). - applySchemaMigrations(db, []); + // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations. + applySchemaMigrations(db, [addTenantIdColumn]); const saveStatement = db.prepare(` INSERT INTO miner_plans (plan_id, plan_json, status, updated_at) diff --git a/test/unit/miner-governor-ledger.test.ts b/test/unit/miner-governor-ledger.test.ts index d4ec6db29b..299df09fb6 100644 --- a/test/unit/miner-governor-ledger.test.ts +++ b/test/unit/miner-governor-ledger.test.ts @@ -15,6 +15,7 @@ import { readGovernorEvents, resolveGovernorLedgerDbPath, } from "../../packages/loopover-miner/lib/governor-ledger.js"; +import { readSchemaVersion } from "../../packages/loopover-miner/lib/schema-version.js"; const roots: string[] = []; const ledgers: Array<{ close(): void }> = []; @@ -214,4 +215,76 @@ describe("loopover-miner governor ledger (#2328)", () => { else process.env.LOOPOVER_MINER_CONFIG_DIR = previousConfigDir; } }); + + describe("schema migrations (#6597)", () => { + it("v1 -> v2 (#4939/#6597): adds an additive tenant_id column, NULL for every pre-existing row -- self-host behavior byte-identical", () => { + const root = mkdtempSync(join(tmpdir(), "loopover-miner-governor-legacy-v1-")); + roots.push(root); + const dbPath = join(root, "legacy-v1.sqlite3"); + const legacy = new DatabaseSync(dbPath); + legacy.exec(` + CREATE TABLE governor_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + event_type TEXT NOT NULL, + repo_full_name TEXT, + action_class TEXT NOT NULL, + decision TEXT NOT NULL, + reason TEXT NOT NULL, + payload_json TEXT NOT NULL + ) + `); + legacy.exec("CREATE INDEX idx_governor_events_repo ON governor_events (repo_full_name, id)"); + legacy.exec("PRAGMA user_version = 1"); + legacy.exec( + "INSERT INTO governor_events (ts, event_type, repo_full_name, action_class, decision, reason, payload_json) VALUES ('2026-01-01T00:00:00.000Z', 'allowed', 'acme/widgets', 'analyze', 'allow', 'within budget', '{}')", + ); + legacy.close(); + + const ledger = initGovernorLedger(dbPath); + ledgers.push(ledger); + expect(ledger.readGovernorEvents().map((event) => event.eventType)).toEqual(["allowed"]); + const readonly = new DatabaseSync(dbPath, { readOnly: true }); + const columns = readonly.prepare("PRAGMA table_info(governor_events)").all() as Array<{ name: string }>; + expect(columns.map((column) => column.name)).toContain("tenant_id"); + expect(readSchemaVersion(readonly)).toBe(2); + const row = readonly.prepare("SELECT tenant_id FROM governor_events WHERE id = 1").get() as { tenant_id: string | null }; + expect(row.tenant_id).toBeNull(); + readonly.close(); + }); + + it("REGRESSION: a v1 file that (unusually) already carries tenant_id is not re-altered into a duplicate-column error", () => { + const root = mkdtempSync(join(tmpdir(), "loopover-miner-governor-legacy-partial-v2-")); + roots.push(root); + const dbPath = join(root, "legacy-partial-v2.sqlite3"); + const legacy = new DatabaseSync(dbPath); + legacy.exec(` + CREATE TABLE governor_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + event_type TEXT NOT NULL, + repo_full_name TEXT, + action_class TEXT NOT NULL, + decision TEXT NOT NULL, + reason TEXT NOT NULL, + payload_json TEXT NOT NULL, + tenant_id TEXT + ) + `); + legacy.exec("PRAGMA user_version = 1"); + legacy.close(); + + expect(() => { + const ledger = initGovernorLedger(dbPath); + ledgers.push(ledger); + }).not.toThrow(); + }); + + it("opening a fresh store reports user_version = 2 via readSchemaVersion", () => { + const ledger = tempLedger(); + const readonly = new DatabaseSync(ledger.dbPath, { readOnly: true }); + expect(readSchemaVersion(readonly)).toBe(2); + readonly.close(); + }); + }); }); diff --git a/test/unit/miner-plan-store.test.ts b/test/unit/miner-plan-store.test.ts index 6770cf987c..94b1076599 100644 --- a/test/unit/miner-plan-store.test.ts +++ b/test/unit/miner-plan-store.test.ts @@ -10,6 +10,7 @@ import { resolvePlanStoreDbPath, } from "../../packages/loopover-miner/lib/plan-store.js"; import type { PlanDag } from "../../packages/loopover-miner/lib/plan-store.js"; +import { readSchemaVersion } from "../../packages/loopover-miner/lib/schema-version.js"; const roots: string[] = []; const stores: Array<{ close(): void }> = []; @@ -172,4 +173,65 @@ describe("loopover-miner plan store (#2318)", () => { expect(() => store.loadPlan("p1")).toThrow("corrupted_plan_row"); expect(() => store.listPlans()).toThrow("corrupted_plan_row"); }); + + describe("schema migrations (#6597)", () => { + it("v1 -> v2 (#4939/#6597): adds an additive tenant_id column, NULL for every pre-existing row -- self-host behavior byte-identical", () => { + const root = mkdtempSync(join(tmpdir(), "loopover-miner-plan-store-legacy-v1-")); + roots.push(root); + const dbPath = join(root, "legacy-v1.sqlite3"); + const legacy = new DatabaseSync(dbPath); + legacy.exec(` + CREATE TABLE miner_plans ( + plan_id TEXT PRIMARY KEY, + plan_json TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'completed', 'failed')), + updated_at TEXT NOT NULL + ) + `); + legacy.exec("PRAGMA user_version = 1"); + legacy.prepare("INSERT INTO miner_plans (plan_id, plan_json, status, updated_at) VALUES (?, ?, ?, ?)").run("p1", JSON.stringify(PLAN), "running", "2026-01-01T00:00:00.000Z"); + legacy.close(); + + const store = openPlanStore(dbPath); + stores.push(store); + expect(store.listPlans().map((record) => record.planId)).toEqual(["p1"]); + const readonly = new DatabaseSync(dbPath, { readOnly: true }); + const columns = readonly.prepare("PRAGMA table_info(miner_plans)").all() as Array<{ name: string }>; + expect(columns.map((column) => column.name)).toContain("tenant_id"); + expect(readSchemaVersion(readonly)).toBe(2); + const row = readonly.prepare("SELECT tenant_id FROM miner_plans WHERE plan_id = ?").get("p1") as { tenant_id: string | null }; + expect(row.tenant_id).toBeNull(); + readonly.close(); + }); + + it("REGRESSION: a v1 file that (unusually) already carries tenant_id is not re-altered into a duplicate-column error", () => { + const root = mkdtempSync(join(tmpdir(), "loopover-miner-plan-store-legacy-partial-v2-")); + roots.push(root); + const dbPath = join(root, "legacy-partial-v2.sqlite3"); + const legacy = new DatabaseSync(dbPath); + legacy.exec(` + CREATE TABLE miner_plans ( + plan_id TEXT PRIMARY KEY, + plan_json TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'completed', 'failed')), + updated_at TEXT NOT NULL, + tenant_id TEXT + ) + `); + legacy.exec("PRAGMA user_version = 1"); + legacy.close(); + + expect(() => { + const store = openPlanStore(dbPath); + stores.push(store); + }).not.toThrow(); + }); + + it("opening a fresh store reports user_version = 2 via readSchemaVersion", () => { + const store = tempStore(); + const readonly = new DatabaseSync(store.dbPath, { readOnly: true }); + expect(readSchemaVersion(readonly)).toBe(2); + readonly.close(); + }); + }); });