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
14 changes: 13 additions & 1 deletion packages/loopover-miner/lib/claim-ledger.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,18 @@ function addApiBaseUrlScope(db) {
db.exec("ALTER TABLE miner_claims_v2 RENAME TO miner_claims");
}

// v2 -> v3 (#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 own v1->v2 migration's sibling in portfolio-queue.js.
function addTenantIdColumn(db) {
const hasTenantIdColumn = db
.prepare("PRAGMA table_info(miner_claims)")
.all()
.some((column) => column.name === "tenant_id");
if (!hasTenantIdColumn) db.exec("ALTER TABLE miner_claims ADD COLUMN tenant_id TEXT");
}

/**
* Opens the local claim ledger, creating the table on first use. `UNIQUE(api_base_url, repo_full_name,
* issue_number)` keeps ONE row per claimed issue per forge host, and `recordClaim` is a single atomic
Expand All @@ -118,7 +130,7 @@ export function openClaimLedger(dbPath = resolveClaimLedgerDbPath()) {
)
`);
// Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations.
applySchemaMigrations(db, [addApiBaseUrlScope]);
applySchemaMigrations(db, [addApiBaseUrlScope, addTenantIdColumn]);

// Idempotent claim in ONE atomic statement: insert a new active claim, or — only if the existing row is NOT
// already active — re-activate it (a released/expired claim can be re-claimed). The `WHERE status <> 'active'`
Expand Down
17 changes: 15 additions & 2 deletions packages/loopover-miner/lib/event-ledger.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,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. portfolio-queue.js's
// leased_at addition).
function addTenantIdColumn(db) {
const hasTenantIdColumn = db
.prepare("PRAGMA table_info(miner_event_ledger)")
.all()
.some((column) => column.name === "tenant_id");
if (!hasTenantIdColumn) db.exec("ALTER TABLE miner_event_ledger ADD COLUMN tenant_id TEXT");
}

/**
* Opens the local append-only event ledger, creating the table on first use. `seq` is a monotonically increasing
* counter maintained by this module (next = current MAX(seq) + 1) rather than relying on `AUTOINCREMENT`'s
Expand All @@ -114,8 +127,8 @@ export function initEventLedger(dbPath = resolveEventLedgerDbPath()) {
created_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]);
// Opt-in retention (#4834): prune aged/excess rows when an operator has enabled it; a no-op by default.
pruneLedgerByRetention(db, EVENT_LEDGER_RETENTION_SPEC, resolveLedgerRetentionPolicy(), Date.now());

Expand Down
11 changes: 11 additions & 0 deletions packages/loopover-miner/lib/portfolio-queue.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,17 @@ export function initPortfolioQueueStore(dbPath = resolvePortfolioQueueDbPath())
migrationDb.exec("ALTER TABLE miner_portfolio_queue ADD COLUMN reenqueue_count INTEGER NOT NULL DEFAULT 0");
}
},
// v4 -> v5 (#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 the v3->v4 migration immediately above.
(migrationDb) => {
const hasTenantIdColumn = migrationDb
.prepare("PRAGMA table_info(miner_portfolio_queue)")
.all()
.some((column) => column.name === "tenant_id");
if (!hasTenantIdColumn) migrationDb.exec("ALTER TABLE miner_portfolio_queue ADD COLUMN tenant_id TEXT");
},
]);

// `rowid` is a stable, unique key assigned once at first insert (re-enqueue updates in place, never re-inserts),
Expand Down
15 changes: 14 additions & 1 deletion packages/loopover-miner/lib/run-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ function addApiBaseUrlScope(db) {
db.exec("ALTER TABLE miner_run_state_v2 RENAME TO miner_run_state");
}

// v2 -> v3 (#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 every other additive migration in this file's siblings (e.g.
// portfolio-queue.js's v3->v4 attempts_count addition).
function addTenantIdColumn(db) {
const hasTenantIdColumn = db
.prepare("PRAGMA table_info(miner_run_state)")
.all()
.some((column) => column.name === "tenant_id");
if (!hasTenantIdColumn) db.exec("ALTER TABLE miner_run_state ADD COLUMN tenant_id TEXT");
}

/**
* Opens the 100% local/client-side miner run-state store. The database only lives on this machine;
* this module never uploads, syncs, or phones home with its contents. (#2289, #5563)
Expand All @@ -78,7 +91,7 @@ export function initRunStateStore(dbPath = resolveRunStateDbPath()) {
)
`);
// Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations.
applySchemaMigrations(db, [addApiBaseUrlScope]);
applySchemaMigrations(db, [addApiBaseUrlScope, addTenantIdColumn]);

const getStatement = db.prepare(
"SELECT state FROM miner_run_state WHERE api_base_url = ? AND repo_full_name = ?",
Expand Down
64 changes: 64 additions & 0 deletions test/unit/miner-claim-ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ describe("loopover-miner claim ledger (#2314)", () => {
"claimed_at",
"status",
"note",
"tenant_id",
]);
for (const name of ["api_base_url", "repo_full_name", "issue_number", "claimed_at", "status"]) {
expect(columns.find((column) => column.name === name)?.notnull).toBe(1);
Expand Down Expand Up @@ -362,6 +363,69 @@ describe("loopover-miner claim ledger (#2314)", () => {
// The corrupt row was dropped, not migrated -- only the valid row survived the rebuild.
expect(ledger.listClaims().map((claim) => claim.repoFullName)).toEqual(["acme/widgets"]);
});

it("v2 -> v3 (#4939): adds an additive tenant_id column, NULL for every pre-existing row -- self-host behavior byte-identical", () => {
const root = mkdtempSync(join(tmpdir(), "loopover-miner-claim-legacy-v2-"));
roots.push(root);
const dbPath = join(root, "legacy-v2.sqlite3");
const legacy = new DatabaseSync(dbPath);
legacy.exec(`
CREATE TABLE miner_claims (
id INTEGER PRIMARY KEY AUTOINCREMENT,
api_base_url TEXT NOT NULL,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
claimed_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'released', 'expired')),
note TEXT,
UNIQUE (api_base_url, repo_full_name, issue_number)
)
`);
legacy.exec("PRAGMA user_version = 2");
legacy.exec(
"INSERT INTO miner_claims (api_base_url, repo_full_name, issue_number, claimed_at, status, note) VALUES ('https://github.com/ghapi', 'acme/widgets', 5, '2026-01-01T00:00:00.000Z', 'active', 'pre-migration')",
);
legacy.close();

const ledger = openClaimLedger(dbPath);
ledgers.push(ledger);
// The pre-existing row is untouched -- no consumer reads tenant_id yet, so it isn't part of the
// public claim shape; verified directly against the schema instead.
expect(ledger.listClaims().map((claim) => claim.note)).toEqual(["pre-migration"]);
const readonly = new DatabaseSync(dbPath, { readOnly: true });
const columns = readonly.prepare("PRAGMA table_info(miner_claims)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("tenant_id");
const row = readonly.prepare("SELECT tenant_id FROM miner_claims WHERE repo_full_name = ?").get("acme/widgets") as { tenant_id: string | null };
expect(row.tenant_id).toBeNull();
readonly.close();
});

it("REGRESSION: a v2 file that (unusually) already carries tenant_id is not re-altered into a duplicate-column error", () => {
const root = mkdtempSync(join(tmpdir(), "loopover-miner-claim-legacy-partial-v3-"));
roots.push(root);
const dbPath = join(root, "legacy-partial-v3.sqlite3");
const legacy = new DatabaseSync(dbPath);
legacy.exec(`
CREATE TABLE miner_claims (
id INTEGER PRIMARY KEY AUTOINCREMENT,
api_base_url TEXT NOT NULL,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
claimed_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'released', 'expired')),
note TEXT,
tenant_id TEXT,
UNIQUE (api_base_url, repo_full_name, issue_number)
)
`);
legacy.exec("PRAGMA user_version = 2");
legacy.close();

expect(() => {
const ledger = openClaimLedger(dbPath);
ledgers.push(ledger);
}).not.toThrow();
});
});

describe("purgeByRepo (#5564)", () => {
Expand Down
62 changes: 62 additions & 0 deletions test/unit/miner-event-ledger.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mkdtempSync, readFileSync, rmSync, statSync } 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 {
closeDefaultEventLedger,
Expand Down Expand Up @@ -184,4 +185,65 @@ describe("loopover-miner event ledger (#2290)", () => {
expect(() => ledger.purgeByRepo("no-slash")).toThrow("invalid_repo_full_name");
});
});

describe("schema migrations", () => {
it("v1 -> v2 (#4939): adds an additive tenant_id column, NULL for every pre-existing row -- self-host behavior byte-identical", () => {
const root = mkdtempSync(join(tmpdir(), "loopover-miner-event-legacy-v1-"));
roots.push(root);
const dbPath = join(root, "legacy-v1.sqlite3");
const legacy = new DatabaseSync(dbPath);
legacy.exec(`
CREATE TABLE miner_event_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT,
seq INTEGER NOT NULL UNIQUE,
event_type TEXT NOT NULL,
repo_full_name TEXT,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL
)
`);
legacy.exec("PRAGMA user_version = 1");
legacy.exec(
"INSERT INTO miner_event_ledger (seq, event_type, repo_full_name, payload_json, created_at) VALUES (1, 'discovered_issue', 'acme/widgets', '{}', '2026-01-01T00:00:00.000Z')",
);
legacy.close();

const ledger = initEventLedger(dbPath);
ledgers.push(ledger);
// The pre-existing row is untouched -- no consumer reads tenant_id yet, so it isn't part of the
// public event shape; verified directly against the schema instead.
expect(ledger.readEvents().map((event) => event.type)).toEqual(["discovered_issue"]);
const readonly = new DatabaseSync(dbPath, { readOnly: true });
const columns = readonly.prepare("PRAGMA table_info(miner_event_ledger)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("tenant_id");
const row = readonly.prepare("SELECT tenant_id FROM miner_event_ledger WHERE seq = 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-event-legacy-partial-v2-"));
roots.push(root);
const dbPath = join(root, "legacy-partial-v2.sqlite3");
const legacy = new DatabaseSync(dbPath);
legacy.exec(`
CREATE TABLE miner_event_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT,
seq INTEGER NOT NULL UNIQUE,
event_type TEXT NOT NULL,
repo_full_name TEXT,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL,
tenant_id TEXT
)
`);
legacy.exec("PRAGMA user_version = 1");
legacy.close();

expect(() => {
const ledger = initEventLedger(dbPath);
ledgers.push(ledger);
}).not.toThrow();
});
});
});
9 changes: 5 additions & 4 deletions test/unit/miner-migrate-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,9 @@ describe("loopover-miner migrate (#4871)", () => {
const results = runMigrateChecks(env);
const portfolioQueue = results.find((result) => result.name === "portfolio-queue");

// Runs ALL THREE post-baseline migrations in sequence: v1->v2 adds leased_at, v2->v3 adds api_base_url
// (#5563), v3->v4 adds the attempt-history counters (#5654).
expect(portfolioQueue).toMatchObject({ ok: true, status: "migrated", versionBefore: 1, versionAfter: 4 });
// Runs ALL FOUR post-baseline migrations in sequence: v1->v2 adds leased_at, v2->v3 adds api_base_url
// (#5563), v3->v4 adds the attempt-history counters (#5654), v4->v5 adds tenant_id (#4939).
expect(portfolioQueue).toMatchObject({ ok: true, status: "migrated", versionBefore: 1, versionAfter: 5 });

const verifyDb = new DatabaseSync(dbPath, { readOnly: true });
try {
Expand All @@ -97,7 +97,8 @@ describe("loopover-miner migrate (#4871)", () => {
expect(columns).toContain("attempts_count");
expect(columns).toContain("consecutive_failures");
expect(columns).toContain("reenqueue_count");
expect(verifyDb.prepare("PRAGMA user_version").get()?.user_version).toBe(4);
expect(columns).toContain("tenant_id");
expect(verifyDb.prepare("PRAGMA user_version").get()?.user_version).toBe(5);
} finally {
verifyDb.close();
}
Expand Down
78 changes: 78 additions & 0 deletions test/unit/miner-portfolio-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,5 +565,83 @@ describe("loopover-miner portfolio/queue store (#2292)", () => {
reachedDone: false,
});
});

it("v4 -> v5 (#4939): adds an additive tenant_id column, NULL for every pre-existing row -- self-host behavior byte-identical", () => {
const root = mkdtempSync(join(tmpdir(), "loopover-miner-portfolio-legacy-v4-"));
roots.push(root);
const dbPath = join(root, "legacy-v4.sqlite3");
const legacy = new DatabaseSync(dbPath);
legacy.exec(`
CREATE TABLE miner_portfolio_queue (
api_base_url TEXT NOT NULL,
repo_full_name TEXT NOT NULL,
identifier TEXT NOT NULL,
priority REAL NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'in_progress', 'done')),
enqueued_at TEXT NOT NULL,
leased_at TEXT,
attempts_count INTEGER NOT NULL DEFAULT 0,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
reenqueue_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (api_base_url, repo_full_name, identifier)
)
`);
legacy.exec("PRAGMA user_version = 4");
legacy.exec(
"INSERT INTO miner_portfolio_queue (api_base_url, repo_full_name, identifier, priority, status, enqueued_at, leased_at, attempts_count, consecutive_failures, reenqueue_count) VALUES ('https://github.com/ghapi', 'acme/widgets', 'issue:5', 3, 'queued', '2026-01-01T00:00:00.000Z', NULL, 0, 0, 0)",
);
legacy.close();

const store = initPortfolioQueueStore(dbPath);
stores.push(store);
// The pre-existing row is untouched by the additive migration -- no consumer reads tenant_id yet, so
// it isn't part of the public row shape; verified directly against the schema instead.
expect(store.listQueue("acme/widgets")).toEqual([
{
apiBaseUrl: "https://github.com/ghapi",
repoFullName: "acme/widgets",
identifier: "issue:5",
priority: 3,
status: "queued",
enqueuedAt: "2026-01-01T00:00:00.000Z",
},
]);
const readonly = new DatabaseSync(dbPath, { readOnly: true });
const columns = readonly.prepare("PRAGMA table_info(miner_portfolio_queue)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("tenant_id");
const row = readonly.prepare("SELECT tenant_id FROM miner_portfolio_queue WHERE identifier = ?").get("issue:5") as { tenant_id: string | null };
expect(row.tenant_id).toBeNull();
readonly.close();
});

it("REGRESSION: a v4 file that (unusually) already carries tenant_id is not re-altered into a duplicate-column error", () => {
const root = mkdtempSync(join(tmpdir(), "loopover-miner-portfolio-legacy-partial-v5-"));
roots.push(root);
const dbPath = join(root, "legacy-partial-v5.sqlite3");
const legacy = new DatabaseSync(dbPath);
legacy.exec(`
CREATE TABLE miner_portfolio_queue (
api_base_url TEXT NOT NULL,
repo_full_name TEXT NOT NULL,
identifier TEXT NOT NULL,
priority REAL NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'in_progress', 'done')),
enqueued_at TEXT NOT NULL,
leased_at TEXT,
attempts_count INTEGER NOT NULL DEFAULT 0,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
reenqueue_count INTEGER NOT NULL DEFAULT 0,
tenant_id TEXT,
PRIMARY KEY (api_base_url, repo_full_name, identifier)
)
`);
legacy.exec("PRAGMA user_version = 4");
legacy.close();

expect(() => {
const store = initPortfolioQueueStore(dbPath);
stores.push(store);
}).not.toThrow();
});
});
});
Loading