Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions packages/loopover-miner/lib/governor-ledger.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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());

Expand Down
16 changes: 14 additions & 2 deletions packages/loopover-miner/lib/plan-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
73 changes: 73 additions & 0 deletions test/unit/miner-governor-ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> = [];
Expand Down Expand Up @@ -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();
});
});
});
62 changes: 62 additions & 0 deletions test/unit/miner-plan-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> = [];
Expand Down Expand Up @@ -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();
});
});
});