Skip to content

Commit 7ec0dea

Browse files
committed
fix(miner): add tenant_id column to governor-ledger and plan-store
#4939 added a nullable tenant_id TEXT column to the miner's local ledger schemas as hosted-readiness groundwork, but governor-ledger.js's governor_events table and plan-store.js's miner_plans table were left out — both passed an empty migrations array to applySchemaMigrations. Add an addTenantIdColumn migration to each, guarded by a PRAGMA table_info column-presence check (idempotent against an already-migrated file), mirroring event-ledger.js and run-state.js. A fresh store now stamps user_version 2; a pre-existing file upgrades in place with every row preserved and tenant_id NULL. No consumer reads or writes the column yet, so self-host behavior is byte-identical. Closes #6597
1 parent 4604090 commit 7ec0dea

4 files changed

Lines changed: 176 additions & 4 deletions

File tree

packages/loopover-miner/lib/governor-ledger.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,19 @@ function rowToDecision(row) {
9090
};
9191
}
9292

93+
// v1 -> v2 (#4939): additive tenant-scoping column, a prerequisite for any hosted, multi-tenant use of this
94+
// same store's logic. NULL for every row today -- self-host behavior is byte-identical, since nothing reads or
95+
// writes it yet (no consumer exists until a future hosted deployment populates it). Same defensive
96+
// column-presence guard as this file's sibling stores' own additive migrations (e.g. event-ledger.js's
97+
// tenant_id addition).
98+
function addTenantIdColumn(db) {
99+
const hasTenantIdColumn = db
100+
.prepare("PRAGMA table_info(governor_events)")
101+
.all()
102+
.some((column) => column.name === "tenant_id");
103+
if (!hasTenantIdColumn) db.exec("ALTER TABLE governor_events ADD COLUMN tenant_id TEXT");
104+
}
105+
93106
/**
94107
* Opens the append-only governor ledger, creating the table on first use. Rows are returned in ascending `id`
95108
* order (insertion order). (#2328)
@@ -113,8 +126,8 @@ export function initGovernorLedger(dbPath = resolveGovernorLedgerDbPath()) {
113126
)
114127
`);
115128
db.exec("CREATE INDEX IF NOT EXISTS idx_governor_events_repo ON governor_events (repo_full_name, id)");
116-
// Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet).
117-
applySchemaMigrations(db, []);
129+
// Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations.
130+
applySchemaMigrations(db, [addTenantIdColumn]);
118131
// Opt-in retention (#4834): prune aged/excess rows when an operator has enabled it; a no-op by default.
119132
pruneLedgerByRetention(db, GOVERNOR_LEDGER_RETENTION_SPEC, resolveLedgerRetentionPolicy(), Date.now());
120133

packages/loopover-miner/lib/plan-store.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,19 @@ function rowToRecord(row) {
143143
return { planId: row.plan_id, plan, status: row.status, updatedAt: row.updated_at };
144144
}
145145

146+
// v1 -> v2 (#4939): additive tenant-scoping column, a prerequisite for any hosted, multi-tenant use of this
147+
// same store's logic. NULL for every row today -- self-host behavior is byte-identical, since nothing reads or
148+
// writes it yet (no consumer exists until a future hosted deployment populates it). Same defensive
149+
// column-presence guard as the package's sibling stores' own additive migrations (e.g. event-ledger.js's
150+
// tenant_id addition).
151+
function addTenantIdColumn(db) {
152+
const hasTenantIdColumn = db
153+
.prepare("PRAGMA table_info(miner_plans)")
154+
.all()
155+
.some((column) => column.name === "tenant_id");
156+
if (!hasTenantIdColumn) db.exec("ALTER TABLE miner_plans ADD COLUMN tenant_id TEXT");
157+
}
158+
146159
/**
147160
* Opens the local plan store, creating the table on first use. `savePlan` is a single atomic INSERT…ON CONFLICT
148161
* upsert keyed by `plan_id`; the plan JSON is validated on save AND re-validated on load, so a corrupted row is
@@ -165,8 +178,8 @@ export function openPlanStore(dbPath = resolvePlanStoreDbPath()) {
165178
updated_at TEXT NOT NULL
166179
)
167180
`);
168-
// Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet).
169-
applySchemaMigrations(db, []);
181+
// Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations.
182+
applySchemaMigrations(db, [addTenantIdColumn]);
170183

171184
const saveStatement = db.prepare(`
172185
INSERT INTO miner_plans (plan_id, plan_json, status, updated_at)

test/unit/miner-governor-ledger.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
readGovernorEvents,
1616
resolveGovernorLedgerDbPath,
1717
} from "../../packages/loopover-miner/lib/governor-ledger.js";
18+
import { readSchemaVersion } from "../../packages/loopover-miner/lib/schema-version.js";
1819

1920
const roots: string[] = [];
2021
const ledgers: Array<{ close(): void }> = [];
@@ -194,6 +195,82 @@ describe("loopover-miner governor ledger (#2328)", () => {
194195
});
195196
});
196197

198+
describe("schema migrations", () => {
199+
it("stamps user_version 2 on a fresh store (baseline 1 plus the tenant_id migration)", () => {
200+
const ledger = tempLedger();
201+
const readonly = new DatabaseSync(ledger.dbPath, { readOnly: true });
202+
expect(readSchemaVersion(readonly)).toBe(2);
203+
const columns = readonly.prepare("PRAGMA table_info(governor_events)").all() as Array<{ name: string }>;
204+
expect(columns.map((column) => column.name)).toContain("tenant_id");
205+
readonly.close();
206+
});
207+
208+
it("v1 -> v2 (#4939): upgrades a pre-existing file in place, adding tenant_id NULL and preserving every row", () => {
209+
const root = mkdtempSync(join(tmpdir(), "loopover-miner-governor-legacy-v1-"));
210+
roots.push(root);
211+
const dbPath = join(root, "legacy-v1.sqlite3");
212+
const legacy = new DatabaseSync(dbPath);
213+
legacy.exec(`
214+
CREATE TABLE governor_events (
215+
id INTEGER PRIMARY KEY AUTOINCREMENT,
216+
ts TEXT NOT NULL,
217+
event_type TEXT NOT NULL,
218+
repo_full_name TEXT,
219+
action_class TEXT NOT NULL,
220+
decision TEXT NOT NULL,
221+
reason TEXT NOT NULL,
222+
payload_json TEXT NOT NULL
223+
)
224+
`);
225+
legacy.exec("PRAGMA user_version = 1");
226+
legacy.exec(
227+
"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', '{}')",
228+
);
229+
legacy.close();
230+
231+
const ledger = initGovernorLedger(dbPath);
232+
ledgers.push(ledger);
233+
// The pre-existing row survives the upgrade and reads back unchanged.
234+
expect(ledger.readGovernorEvents().map((event) => event.reason)).toEqual(["within budget"]);
235+
const readonly = new DatabaseSync(dbPath, { readOnly: true });
236+
expect(readSchemaVersion(readonly)).toBe(2);
237+
const columns = readonly.prepare("PRAGMA table_info(governor_events)").all() as Array<{ name: string }>;
238+
expect(columns.map((column) => column.name)).toContain("tenant_id");
239+
const row = readonly
240+
.prepare("SELECT tenant_id FROM governor_events WHERE repo_full_name = 'acme/widgets'")
241+
.get() as { tenant_id: string | null };
242+
expect(row.tenant_id).toBeNull();
243+
readonly.close();
244+
});
245+
246+
it("REGRESSION: a v1 file that (unusually) already carries tenant_id is not re-altered into a duplicate-column error", () => {
247+
const root = mkdtempSync(join(tmpdir(), "loopover-miner-governor-legacy-partial-v2-"));
248+
roots.push(root);
249+
const dbPath = join(root, "legacy-partial-v2.sqlite3");
250+
const legacy = new DatabaseSync(dbPath);
251+
legacy.exec(`
252+
CREATE TABLE governor_events (
253+
id INTEGER PRIMARY KEY AUTOINCREMENT,
254+
ts TEXT NOT NULL,
255+
event_type TEXT NOT NULL,
256+
repo_full_name TEXT,
257+
action_class TEXT NOT NULL,
258+
decision TEXT NOT NULL,
259+
reason TEXT NOT NULL,
260+
payload_json TEXT NOT NULL,
261+
tenant_id TEXT
262+
)
263+
`);
264+
legacy.exec("PRAGMA user_version = 1");
265+
legacy.close();
266+
267+
expect(() => {
268+
const ledger = initGovernorLedger(dbPath);
269+
ledgers.push(ledger);
270+
}).not.toThrow();
271+
});
272+
});
273+
197274
it("uses the default singleton ledger helpers and closes cleanly", () => {
198275
const root = mkdtempSync(join(tmpdir(), "loopover-miner-governor-default-"));
199276
roots.push(root);

test/unit/miner-plan-store.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
resolvePlanStoreDbPath,
1111
} from "../../packages/loopover-miner/lib/plan-store.js";
1212
import type { PlanDag } from "../../packages/loopover-miner/lib/plan-store.js";
13+
import { readSchemaVersion } from "../../packages/loopover-miner/lib/schema-version.js";
1314

1415
const roots: string[] = [];
1516
const stores: Array<{ close(): void }> = [];
@@ -172,4 +173,72 @@ describe("loopover-miner plan store (#2318)", () => {
172173
expect(() => store.loadPlan("p1")).toThrow("corrupted_plan_row");
173174
expect(() => store.listPlans()).toThrow("corrupted_plan_row");
174175
});
176+
177+
describe("schema migrations", () => {
178+
it("stamps user_version 2 on a fresh store (baseline 1 plus the tenant_id migration)", () => {
179+
const store = tempStore();
180+
const readonly = new DatabaseSync(store.dbPath, { readOnly: true });
181+
expect(readSchemaVersion(readonly)).toBe(2);
182+
const columns = readonly.prepare("PRAGMA table_info(miner_plans)").all() as Array<{ name: string }>;
183+
expect(columns.map((column) => column.name)).toContain("tenant_id");
184+
readonly.close();
185+
});
186+
187+
it("v1 -> v2 (#4939): upgrades a pre-existing file in place, adding tenant_id NULL and preserving every row", () => {
188+
const root = mkdtempSync(join(tmpdir(), "loopover-miner-plan-store-legacy-v1-"));
189+
roots.push(root);
190+
const dbPath = join(root, "legacy-v1.sqlite3");
191+
const legacy = new DatabaseSync(dbPath);
192+
legacy.exec(`
193+
CREATE TABLE miner_plans (
194+
plan_id TEXT PRIMARY KEY,
195+
plan_json TEXT NOT NULL,
196+
status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'completed', 'failed')),
197+
updated_at TEXT NOT NULL
198+
)
199+
`);
200+
legacy.exec("PRAGMA user_version = 1");
201+
legacy
202+
.prepare("INSERT INTO miner_plans VALUES (?, ?, ?, ?)")
203+
.run("p1", JSON.stringify(PLAN), "running", "2026-01-01T00:00:00.000Z");
204+
legacy.close();
205+
206+
const store = openPlanStore(dbPath);
207+
stores.push(store);
208+
// The pre-existing row survives the upgrade and reads back unchanged.
209+
expect(store.loadPlan("p1")?.status).toBe("running");
210+
const readonly = new DatabaseSync(dbPath, { readOnly: true });
211+
expect(readSchemaVersion(readonly)).toBe(2);
212+
const columns = readonly.prepare("PRAGMA table_info(miner_plans)").all() as Array<{ name: string }>;
213+
expect(columns.map((column) => column.name)).toContain("tenant_id");
214+
const row = readonly.prepare("SELECT tenant_id FROM miner_plans WHERE plan_id = 'p1'").get() as {
215+
tenant_id: string | null;
216+
};
217+
expect(row.tenant_id).toBeNull();
218+
readonly.close();
219+
});
220+
221+
it("REGRESSION: a v1 file that (unusually) already carries tenant_id is not re-altered into a duplicate-column error", () => {
222+
const root = mkdtempSync(join(tmpdir(), "loopover-miner-plan-store-legacy-partial-v2-"));
223+
roots.push(root);
224+
const dbPath = join(root, "legacy-partial-v2.sqlite3");
225+
const legacy = new DatabaseSync(dbPath);
226+
legacy.exec(`
227+
CREATE TABLE miner_plans (
228+
plan_id TEXT PRIMARY KEY,
229+
plan_json TEXT NOT NULL,
230+
status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'completed', 'failed')),
231+
updated_at TEXT NOT NULL,
232+
tenant_id TEXT
233+
)
234+
`);
235+
legacy.exec("PRAGMA user_version = 1");
236+
legacy.close();
237+
238+
expect(() => {
239+
const store = openPlanStore(dbPath);
240+
stores.push(store);
241+
}).not.toThrow();
242+
});
243+
});
175244
});

0 commit comments

Comments
 (0)