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: 16 additions & 0 deletions packages/loopover-miner/lib/prediction-ledger.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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());

Expand Down
104 changes: 104 additions & 0 deletions test/unit/miner-prediction-ledger.test.ts
Original file line number Diff line number Diff line change
@@ -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[] = [];
Expand Down Expand Up @@ -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();
}
});
});
});