From bee409c6dbbdd6b02b35618c4cc33c7a9527adc5 Mon Sep 17 00:00:00 2001 From: e11734937-beep Date: Thu, 16 Jul 2026 21:19:29 +0200 Subject: [PATCH] fix(miner): stamp prediction-ledger schema version and add tenant_id column prediction-ledger.js was the one canonical local store that never called applySchemaMigrations, so it carried no PRAGMA user_version stamp and lacked the additive nullable tenant_id column its four sibling ledgers already have (#4939). Add an addTenantIdColumn migration (guarded by a PRAGMA table_info column-presence check, idempotent like its siblings) and run it via applySchemaMigrations right after the bootstrap schema, matching event-ledger.js/run-state.js. A fresh store now stamps user_version 2 and an existing file upgrades in place; tenant_id is NULL for every row and no consumer reads or writes it, so self-host behavior is byte-identical. Closes #6596 --- .../loopover-miner/lib/prediction-ledger.js | 16 +++ test/unit/miner-prediction-ledger.test.ts | 104 ++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/packages/loopover-miner/lib/prediction-ledger.js b/packages/loopover-miner/lib/prediction-ledger.js index 45545e5ec0..b27b63f09f 100644 --- a/packages/loopover-miner/lib/prediction-ledger.js +++ b/packages/loopover-miner/lib/prediction-ledger.js @@ -1,6 +1,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { openLocalStoreDb } from "./local-store.js"; +import { applySchemaMigrations } from "./schema-version.js"; import { PREDICTION_LEDGER_PURGE_SPEC, PREDICTION_LEDGER_RETENTION_SPEC, @@ -128,6 +129,19 @@ function rowToEntry(row) { }; } +// v1 -> v2 (#4939): 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 (no consumer exists until a future hosted deployment populates it). Same defensive +// column-presence guard as this file's sibling stores' own additive migrations (e.g. event-ledger.js's and +// run-state.js's own tenant_id additions), so re-running it against an already-migrated file is a no-op. +function addTenantIdColumn(db) { + const hasTenantIdColumn = db + .prepare("PRAGMA table_info(predictions)") + .all() + .some((column) => column.name === "tenant_id"); + if (!hasTenantIdColumn) db.exec("ALTER TABLE predictions ADD COLUMN tenant_id TEXT"); +} + /** * Opens the append-only prediction ledger, creating the table on first use. Rows are returned in ascending `id` * order (insertion order). (#4263) @@ -151,6 +165,8 @@ export function initPredictionLedger(dbPath = resolvePredictionLedgerDbPath()) { ) `); db.exec("CREATE INDEX IF NOT EXISTS idx_predictions_repo ON predictions (repo_full_name, id)"); + // 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, PREDICTION_LEDGER_RETENTION_SPEC, resolveLedgerRetentionPolicy(), Date.now()); diff --git a/test/unit/miner-prediction-ledger.test.ts b/test/unit/miner-prediction-ledger.test.ts index 8eccb19ee5..404ae0d385 100644 --- a/test/unit/miner-prediction-ledger.test.ts +++ b/test/unit/miner-prediction-ledger.test.ts @@ -1,8 +1,10 @@ import { mkdtempSync, rmSync } 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 { initPredictionLedger, resolvePredictionLedgerDbPath } from "../../packages/loopover-miner/lib/prediction-ledger.js"; +import { readSchemaVersion } from "../../packages/loopover-miner/lib/schema-version.js"; const ledgers: Array<{ close: () => void }> = []; const roots: string[] = []; @@ -107,4 +109,106 @@ describe("miner prediction ledger (#4263)", () => { expect(() => ledger.purgeByRepo("no-slash")).toThrow("invalid_repo_full_name"); }); }); + + describe("schema version + tenant_id migration (#4832/#4939)", () => { + function tempDbPath() { + const root = mkdtempSync(join(tmpdir(), "loopover-miner-prediction-")); + roots.push(root); + return join(root, "prediction-ledger.sqlite3"); + } + + it("stamps a freshly-opened store at schema version 2 (baseline + the tenant_id migration)", () => { + const ledger = tempLedger(); + const reader = new DatabaseSync(ledger.dbPath, { readOnly: true }); + try { + expect(readSchemaVersion(reader)).toBe(2); + const hasTenantId = reader + .prepare("PRAGMA table_info(predictions)") + .all() + .some((column) => column.name === "tenant_id"); + expect(hasTenantId).toBe(true); + } finally { + reader.close(); + } + }); + + it("upgrades a pre-migration file in place: adds tenant_id, preserves rows, reads null for old and new rows", () => { + const dbPath = tempDbPath(); + // Craft the pre-versioning on-disk shape: the bare v1 predictions table (no tenant_id), user_version 0, + // with one existing row -- exactly what the current code wrote before this migration existed. + const seed = new DatabaseSync(dbPath); + seed.exec(` + CREATE TABLE predictions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + repo_full_name TEXT NOT NULL, + target_id INTEGER NOT NULL, + head_sha TEXT, + conclusion TEXT NOT NULL, + pack TEXT NOT NULL, + readiness_score REAL, + blocker_codes_json TEXT NOT NULL, + warning_codes_json TEXT NOT NULL, + engine_version TEXT NOT NULL + ) + `); + seed + .prepare( + "INSERT INTO predictions (ts, repo_full_name, target_id, head_sha, conclusion, pack, readiness_score, blocker_codes_json, warning_codes_json, engine_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .run("2020-01-01T00:00:00.000Z", "owner/old-repo", 7, null, "failure", "gittensor", null, "[]", "[]", "0.1.0"); + expect(readSchemaVersion(seed)).toBe(0); + seed.close(); + + const ledger = initPredictionLedger(dbPath); + ledgers.push(ledger); + // The pre-existing row survives the in-place upgrade. + expect(ledger.readPredictions().map((entry) => entry.targetId)).toEqual([7]); + // A newly-appended row goes in through the normal path, which never writes tenant_id. + ledger.appendPrediction({ ...VALID, repoFullName: "owner/new-repo", targetId: 8 }); + + const reader = new DatabaseSync(dbPath, { readOnly: true }); + try { + expect(readSchemaVersion(reader)).toBe(2); + const rows = reader.prepare("SELECT target_id, tenant_id FROM predictions ORDER BY id ASC").all(); + expect(rows).toEqual([ + { target_id: 7, tenant_id: null }, + { target_id: 8, tenant_id: null }, + ]); + } finally { + reader.close(); + } + }); + + it("re-running the migration against a file that already has tenant_id is a no-op (idempotent guard)", () => { + const dbPath = tempDbPath(); + // First open migrates the fresh file up to v2: the tenant_id column is added and the version is stamped. + const first = initPredictionLedger(dbPath); + first.appendPrediction({ ...VALID, repoFullName: "owner/repo", targetId: 1 }); + first.close(); + + // Force the migration to run AGAIN on the next open: the column is already present, but resetting the + // stamped version below the target makes applySchemaMigrations re-invoke addTenantIdColumn -- exercising + // its column-already-present skip branch (no duplicate-column ALTER, no throw). + const reset = new DatabaseSync(dbPath); + reset.exec("PRAGMA user_version = 0"); + reset.close(); + + const reopened = initPredictionLedger(dbPath); + ledgers.push(reopened); + // Re-opened cleanly, the row survived, and the store re-stamped back to v2 with a single tenant_id column. + expect(reopened.readPredictions().map((entry) => entry.targetId)).toEqual([1]); + const reader = new DatabaseSync(dbPath, { readOnly: true }); + try { + expect(readSchemaVersion(reader)).toBe(2); + const tenantIdColumns = reader + .prepare("PRAGMA table_info(predictions)") + .all() + .filter((column) => column.name === "tenant_id"); + expect(tenantIdColumns).toHaveLength(1); + } finally { + reader.close(); + } + }); + }); });