From 729d7500127ef07453e4de448b55b20d23ea48f6 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:36:13 -0700 Subject: [PATCH] feat(miner): add tenant-scoping columns to all four local ledger schemas (#4939) Adds an additive tenant_id column to every local SQLite store the miner's ledger logic touches -- run-state, claim-ledger, portfolio-queue, event-ledger -- following each file's own established schema-migration convention (applySchemaMigrations + PRAGMA user_version, the same defensive column-presence guard as every prior additive migration in these files, e.g. portfolio-queue.js's leased_at/attempts_count additions). Schema-only, per #4783's tenancy spec informing this issue: nothing reads or writes tenant_id yet (no consumer exists until a future hosted deployment populates it), so self-host behavior is byte-identical -- every migrated row's tenant_id is NULL, exactly like every other optional column this file family already ships that way. Each store gets two new tests: a legacy-file migration test proving the column lands correctly with existing rows untouched, and a regression test proving a file that already has the column (a partial prior migration attempt) doesn't crash on re-open. Also updates miner-migrate-cli.test.ts's hardcoded target-version assertion, which was pinned to the pre-existing migration count. --- packages/loopover-miner/lib/claim-ledger.js | 14 +++- packages/loopover-miner/lib/event-ledger.js | 17 +++- .../loopover-miner/lib/portfolio-queue.js | 11 +++ packages/loopover-miner/lib/run-state.js | 15 +++- test/unit/miner-claim-ledger.test.ts | 64 +++++++++++++++ test/unit/miner-event-ledger.test.ts | 62 +++++++++++++++ test/unit/miner-migrate-cli.test.ts | 9 ++- test/unit/miner-portfolio-queue.test.ts | 78 +++++++++++++++++++ test/unit/miner-run-state.test.ts | 63 +++++++++++++++ 9 files changed, 325 insertions(+), 8 deletions(-) diff --git a/packages/loopover-miner/lib/claim-ledger.js b/packages/loopover-miner/lib/claim-ledger.js index b448458046..07507e8f6a 100644 --- a/packages/loopover-miner/lib/claim-ledger.js +++ b/packages/loopover-miner/lib/claim-ledger.js @@ -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 @@ -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'` diff --git a/packages/loopover-miner/lib/event-ledger.js b/packages/loopover-miner/lib/event-ledger.js index 14a99823f5..5a3596a7e5 100644 --- a/packages/loopover-miner/lib/event-ledger.js +++ b/packages/loopover-miner/lib/event-ledger.js @@ -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 @@ -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()); diff --git a/packages/loopover-miner/lib/portfolio-queue.js b/packages/loopover-miner/lib/portfolio-queue.js index 3162b3784d..f93c43f9b3 100644 --- a/packages/loopover-miner/lib/portfolio-queue.js +++ b/packages/loopover-miner/lib/portfolio-queue.js @@ -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), diff --git a/packages/loopover-miner/lib/run-state.js b/packages/loopover-miner/lib/run-state.js index 159cac563d..de9bfaa843 100644 --- a/packages/loopover-miner/lib/run-state.js +++ b/packages/loopover-miner/lib/run-state.js @@ -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) @@ -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 = ?", diff --git a/test/unit/miner-claim-ledger.test.ts b/test/unit/miner-claim-ledger.test.ts index 99a527e496..1d019a7b22 100644 --- a/test/unit/miner-claim-ledger.test.ts +++ b/test/unit/miner-claim-ledger.test.ts @@ -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); @@ -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://api.github.com', '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)", () => { diff --git a/test/unit/miner-event-ledger.test.ts b/test/unit/miner-event-ledger.test.ts index 81fe84f53d..b96bc700a3 100644 --- a/test/unit/miner-event-ledger.test.ts +++ b/test/unit/miner-event-ledger.test.ts @@ -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, @@ -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(); + }); + }); }); diff --git a/test/unit/miner-migrate-cli.test.ts b/test/unit/miner-migrate-cli.test.ts index a5c981ec5f..0dfb069465 100644 --- a/test/unit/miner-migrate-cli.test.ts +++ b/test/unit/miner-migrate-cli.test.ts @@ -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 { @@ -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(); } diff --git a/test/unit/miner-portfolio-queue.test.ts b/test/unit/miner-portfolio-queue.test.ts index 8f92efe31a..84f4b70d3d 100644 --- a/test/unit/miner-portfolio-queue.test.ts +++ b/test/unit/miner-portfolio-queue.test.ts @@ -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://api.github.com', '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://api.github.com", + 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(); + }); }); }); diff --git a/test/unit/miner-run-state.test.ts b/test/unit/miner-run-state.test.ts index 9555f16799..6d84e31f42 100644 --- a/test/unit/miner-run-state.test.ts +++ b/test/unit/miner-run-state.test.ts @@ -295,5 +295,68 @@ describe("loopover-miner run-state store (#2289)", () => { store.close(); } }); + + it("v2 -> v3 (#4939): adds an additive tenant_id column, NULL for every pre-existing row -- self-host behavior byte-identical", () => { + const dbPath = join(tempRoot(), "legacy-v2.sqlite3"); + const legacy = new DatabaseSync(dbPath); + legacy.exec(` + CREATE TABLE miner_run_state ( + api_base_url TEXT NOT NULL, + repo_full_name TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('idle', 'discovering', 'planning', 'preparing')), + updated_at TEXT NOT NULL, + PRIMARY KEY (api_base_url, repo_full_name) + ) + `); + legacy.exec("PRAGMA user_version = 2"); + legacy.exec( + "INSERT INTO miner_run_state (api_base_url, repo_full_name, state, updated_at) VALUES ('https://api.github.com', 'acme/widgets', 'planning', '2026-01-01T00:00:00.000Z')", + ); + legacy.close(); + + const store = initRunStateStore(dbPath); + try { + // The pre-existing row is untouched -- no consumer reads tenant_id yet, so it isn't part of the + // public row shape; verified directly against the schema instead. + expect(store.listRunStates()).toEqual([ + { + apiBaseUrl: "https://api.github.com", + repoFullName: "acme/widgets", + state: "planning", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ]); + } finally { + store.close(); + } + const readonly = new DatabaseSync(dbPath, { readOnly: true }); + const columns = readonly.prepare("PRAGMA table_info(miner_run_state)").all() as Array<{ name: string }>; + expect(columns.map((column) => column.name)).toContain("tenant_id"); + const row = readonly.prepare("SELECT tenant_id FROM miner_run_state 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 dbPath = join(tempRoot(), "legacy-partial-v3.sqlite3"); + const legacy = new DatabaseSync(dbPath); + legacy.exec(` + CREATE TABLE miner_run_state ( + api_base_url TEXT NOT NULL, + repo_full_name TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('idle', 'discovering', 'planning', 'preparing')), + updated_at TEXT NOT NULL, + tenant_id TEXT, + PRIMARY KEY (api_base_url, repo_full_name) + ) + `); + legacy.exec("PRAGMA user_version = 2"); + legacy.close(); + + expect(() => { + const store = initRunStateStore(dbPath); + store.close(); + }).not.toThrow(); + }); }); });