// Upgrade path for local DBs written by pre-scope executor versions.//// These tests exercise both halves:// 1. The detector correctly identifies DBs missing the `scope_id`// column on `source`.// 2. The move-aside helper renames the file (plus WAL/SHM siblings)// so a subsequent fresh `migrate()` can create the new shape. import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";import { Database } from "bun:sqlite";import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";import { join } from "node:path";import { tmpdir } from "node:os";import { drizzle } from "drizzle-orm/bun-sqlite";import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { importLegacySecrets, isPreScopeSchema, moveAsidePreScopeDb, readLegacySecrets,} from "./db-upgrade"; const PRE_SCOPE_SCHEMA = ` CREATE TABLE source ( id TEXT PRIMARY KEY NOT NULL, plugin_id TEXT NOT NULL, kind TEXT NOT NULL, name TEXT NOT NULL, can_remove INTEGER DEFAULT 1 NOT NULL, can_refresh INTEGER DEFAULT 0 NOT NULL, can_edit INTEGER DEFAULT 0 NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE tool ( id TEXT PRIMARY KEY NOT NULL, source_id TEXT NOT NULL, plugin_id TEXT NOT NULL, name TEXT NOT NULL, description TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE secret ( id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, provider TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE blob ( namespace TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (namespace, key) );`; const SCOPED_SCHEMA = ` CREATE TABLE source ( id TEXT NOT NULL, scope_id TEXT NOT NULL, plugin_id TEXT NOT NULL, kind TEXT NOT NULL, name TEXT NOT NULL, can_remove INTEGER DEFAULT 1 NOT NULL, can_refresh INTEGER DEFAULT 0 NOT NULL, can_edit INTEGER DEFAULT 0 NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (scope_id, id) );`; const seed = (path: string, sql: string) => { const db = new Database(path); db.exec(sql); db.close();}; let workDir: string; beforeEach(() => { workDir = mkdtempSync(join(tmpdir(), "exec-dbup-"));}); afterEach(() => { rmSync(workDir, { recursive: true, force: true });}); describe("isPreScopeSchema", () => { it("returns true for a DB with a source table missing scope_id", () => { const path = join(workDir, "data.db"); seed(path, PRE_SCOPE_SCHEMA); expect(isPreScopeSchema(path)).toBe(true); }); it("returns false for a DB whose source table already has scope_id", () => { const path = join(workDir, "data.db"); seed(path, SCOPED_SCHEMA); expect(isPreScopeSchema(path)).toBe(false); }); it("returns false for a DB with no source table", () => { const path = join(workDir, "data.db"); seed(path, "CREATE TABLE unrelated (x TEXT);"); expect(isPreScopeSchema(path)).toBe(false); }); it("returns false when the DB file doesn't exist", () => { expect(isPreScopeSchema(join(workDir, "missing.db"))).toBe(false); });}); describe("moveAsidePreScopeDb", () => { it("renames data.db + wal/shm siblings and returns the backup path", () => { const path = join(workDir, "data.db"); seed(path, PRE_SCOPE_SCHEMA); writeFileSync(`${path}-wal`, "wal-bytes"); writeFileSync(`${path}-shm`, "shm-bytes"); const backup = moveAsidePreScopeDb(path); expect(backup).toMatch(/data\.db\.pre-scopes-\d+-[0-9a-f]{8}$/); expect(existsSync(path)).toBe(false); expect(existsSync(`${path}-wal`)).toBe(false); expect(existsSync(`${path}-shm`)).toBe(false); expect(existsSync(backup!)).toBe(true); expect(existsSync(`${backup}-wal`)).toBe(true); expect(existsSync(`${backup}-shm`)).toBe(true); }); it("is a no-op when the DB already has the scoped schema", () => { const path = join(workDir, "data.db"); seed(path, SCOPED_SCHEMA); expect(moveAsidePreScopeDb(path)).toBeNull(); expect(existsSync(path)).toBe(true); }); it("is a no-op when the DB doesn't exist yet (fresh install)", () => { expect(moveAsidePreScopeDb(join(workDir, "missing.db"))).toBeNull(); });}); // Integration: the whole reason this helper exists — a pre-scope DB// must be recoverable via fresh drizzle migrations after the move.describe("move-aside + fresh migrate end-to-end", () => { it("lets migrations run cleanly after an old DB is moved aside", () => { const path = join(workDir, "data.db"); seed(path, PRE_SCOPE_SCHEMA); const backup = moveAsidePreScopeDb(path); expect(backup).not.toBeNull(); const db = new Database(path); migrate(drizzle(db), { migrationsFolder: join(__dirname, "../../drizzle"), }); // migrate() should have produced the new schema — source now has scope_id. const cols = db.prepare("PRAGMA table_info('source')").all() as ReadonlyArray<{ readonly name: string; }>; expect(cols.some((c) => c.name === "scope_id")).toBe(true); db.close(); });}); describe("readLegacySecrets", () => { it("returns all rows from a pre-scope DB's secret table", () => { const path = join(workDir, "data.db"); seed(path, PRE_SCOPE_SCHEMA); const db = new Database(path); db.prepare("INSERT INTO secret (id, name, provider, created_at) VALUES (?, ?, ?, ?)").run( "sec_1", "GitHub Token", "onepassword", 1700000000, ); db.prepare("INSERT INTO secret (id, name, provider, created_at) VALUES (?, ?, ?, ?)").run( "sec_2", "Stripe", "keychain", 1700000001, ); db.close(); const rows = readLegacySecrets(path); expect(rows).toHaveLength(2); expect(rows[0]).toEqual({ id: "sec_1", name: "GitHub Token", provider: "onepassword", createdAt: 1700000000, }); }); it("returns [] when the DB has no secret table", () => { const path = join(workDir, "data.db"); seed(path, "CREATE TABLE unrelated (x TEXT);"); expect(readLegacySecrets(path)).toEqual([]); }); it("returns [] when the DB file doesn't exist", () => { expect(readLegacySecrets(join(workDir, "missing.db"))).toEqual([]); });}); describe("importLegacySecrets", () => { // Set up a fresh DB with the new (scoped) `secret` shape to import into. const createScopedDb = (path: string): Database => { const db = new Database(path); db.exec(` CREATE TABLE secret ( id TEXT NOT NULL, scope_id TEXT NOT NULL, name TEXT NOT NULL, provider TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (scope_id, id) ); `); return db; }; it("inserts rows stamped with the given scope id", () => { const path = join(workDir, "data.db"); const db = createScopedDb(path); importLegacySecrets(db, "scope_a", [ { id: "sec_1", name: "GH", provider: "onepassword", createdAt: 1 }, { id: "sec_2", name: "St", provider: "keychain", createdAt: 2 }, ]); const rows = db .prepare("SELECT id, scope_id, name, provider FROM secret ORDER BY id") .all() as ReadonlyArray<{ id: string; scope_id: string; name: string; provider: string }>; db.close(); expect(rows).toHaveLength(2); expect(rows[0]).toEqual({ id: "sec_1", scope_id: "scope_a", name: "GH", provider: "onepassword", }); expect(rows[1].scope_id).toBe("scope_a"); }); it("is a no-op with an empty list", () => { const path = join(workDir, "data.db"); const db = createScopedDb(path); importLegacySecrets(db, "scope_a", []); const count = (db.prepare("SELECT COUNT(*) as n FROM secret").get() as { n: number }).n; db.close(); expect(count).toBe(0); }); it("uses INSERT OR IGNORE so a second import of the same ids is a no-op", () => { const path = join(workDir, "data.db"); const db = createScopedDb(path); const rows = [{ id: "sec_1", name: "GH", provider: "onepassword", createdAt: 1 }]; importLegacySecrets(db, "scope_a", rows); // If the user's already re-registered the secret via a different // provider, the legacy row must NOT clobber it. db.prepare( "UPDATE secret SET provider = 'file' WHERE id = 'sec_1' AND scope_id = 'scope_a'", ).run(); importLegacySecrets(db, "scope_a", rows); const provider = ( db .prepare("SELECT provider FROM secret WHERE id = ? AND scope_id = ?") .get("sec_1", "scope_a") as { provider: string } ).provider; db.close(); expect(provider).toBe("file"); });});
// Upgrade path for local DBs written by pre-scope executor versions.
//
// These tests exercise both halves:
// 1. The detector correctly identifies DBs missing the `scope_id`
// column on `source`.
// 2. The move-aside helper renames the file (plus WAL/SHM siblings)
// so a subsequent fresh `migrate()` can create the new shape.
import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";
import { Database } from "bun:sqlite";
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import {
importLegacySecrets,
isPreScopeSchema,
moveAsidePreScopeDb,
readLegacySecrets,
} from "./db-upgrade";
const PRE_SCOPE_SCHEMA = `
CREATE TABLE source (
id TEXT PRIMARY KEY NOT NULL,
plugin_id TEXT NOT NULL,
kind TEXT NOT NULL,
name TEXT NOT NULL,
can_remove INTEGER DEFAULT 1 NOT NULL,
can_refresh INTEGER DEFAULT 0 NOT NULL,
can_edit INTEGER DEFAULT 0 NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE tool (
id TEXT PRIMARY KEY NOT NULL,
source_id TEXT NOT NULL,
plugin_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE secret (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
provider TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE blob (
namespace TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (namespace, key)
);
`;
const SCOPED_SCHEMA = `
CREATE TABLE source (
id TEXT NOT NULL,
scope_id TEXT NOT NULL,
plugin_id TEXT NOT NULL,
kind TEXT NOT NULL,
name TEXT NOT NULL,
can_remove INTEGER DEFAULT 1 NOT NULL,
can_refresh INTEGER DEFAULT 0 NOT NULL,
can_edit INTEGER DEFAULT 0 NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (scope_id, id)
);
`;
const seed = (path: string, sql: string) => {
const db = new Database(path);
db.exec(sql);
db.close();
};
let workDir: string;
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), "exec-dbup-"));
});
afterEach(() => {
rmSync(workDir, { recursive: true, force: true });
});
describe("isPreScopeSchema", () => {
it("returns true for a DB with a source table missing scope_id", () => {
const path = join(workDir, "data.db");
seed(path, PRE_SCOPE_SCHEMA);
expect(isPreScopeSchema(path)).toBe(true);
});
it("returns false for a DB whose source table already has scope_id", () => {
const path = join(workDir, "data.db");
seed(path, SCOPED_SCHEMA);
expect(isPreScopeSchema(path)).toBe(false);
});
it("returns false for a DB with no source table", () => {
const path = join(workDir, "data.db");
seed(path, "CREATE TABLE unrelated (x TEXT);");
expect(isPreScopeSchema(path)).toBe(false);
});
it("returns false when the DB file doesn't exist", () => {
expect(isPreScopeSchema(join(workDir, "missing.db"))).toBe(false);
});
});
describe("moveAsidePreScopeDb", () => {
it("renames data.db + wal/shm siblings and returns the backup path", () => {
const path = join(workDir, "data.db");
seed(path, PRE_SCOPE_SCHEMA);
writeFileSync(`${path}-wal`, "wal-bytes");
writeFileSync(`${path}-shm`, "shm-bytes");
const backup = moveAsidePreScopeDb(path);
expect(backup).toMatch(/data\.db\.pre-scopes-\d+-[0-9a-f]{8}$/);
expect(existsSync(path)).toBe(false);
expect(existsSync(`${path}-wal`)).toBe(false);
expect(existsSync(`${path}-shm`)).toBe(false);
expect(existsSync(backup!)).toBe(true);
expect(existsSync(`${backup}-wal`)).toBe(true);
expect(existsSync(`${backup}-shm`)).toBe(true);
});
it("is a no-op when the DB already has the scoped schema", () => {
const path = join(workDir, "data.db");
seed(path, SCOPED_SCHEMA);
expect(moveAsidePreScopeDb(path)).toBeNull();
expect(existsSync(path)).toBe(true);
});
it("is a no-op when the DB doesn't exist yet (fresh install)", () => {
expect(moveAsidePreScopeDb(join(workDir, "missing.db"))).toBeNull();
});
});
// Integration: the whole reason this helper exists — a pre-scope DB
// must be recoverable via fresh drizzle migrations after the move.
describe("move-aside + fresh migrate end-to-end", () => {
it("lets migrations run cleanly after an old DB is moved aside", () => {
const path = join(workDir, "data.db");
seed(path, PRE_SCOPE_SCHEMA);
const backup = moveAsidePreScopeDb(path);
expect(backup).not.toBeNull();
const db = new Database(path);
migrate(drizzle(db), {
migrationsFolder: join(__dirname, "../../drizzle"),
});
// migrate() should have produced the new schema — source now has scope_id.
const cols = db.prepare("PRAGMA table_info('source')").all() as ReadonlyArray<{
readonly name: string;
}>;
expect(cols.some((c) => c.name === "scope_id")).toBe(true);
db.close();
});
});
describe("readLegacySecrets", () => {
it("returns all rows from a pre-scope DB's secret table", () => {
const path = join(workDir, "data.db");
seed(path, PRE_SCOPE_SCHEMA);
const db = new Database(path);
db.prepare("INSERT INTO secret (id, name, provider, created_at) VALUES (?, ?, ?, ?)").run(
"sec_1",
"GitHub Token",
"onepassword",
1700000000,
);
db.prepare("INSERT INTO secret (id, name, provider, created_at) VALUES (?, ?, ?, ?)").run(
"sec_2",
"Stripe",
"keychain",
1700000001,
);
db.close();
const rows = readLegacySecrets(path);
expect(rows).toHaveLength(2);
expect(rows[0]).toEqual({
id: "sec_1",
name: "GitHub Token",
provider: "onepassword",
createdAt: 1700000000,
});
});
it("returns [] when the DB has no secret table", () => {
const path = join(workDir, "data.db");
seed(path, "CREATE TABLE unrelated (x TEXT);");
expect(readLegacySecrets(path)).toEqual([]);
});
it("returns [] when the DB file doesn't exist", () => {
expect(readLegacySecrets(join(workDir, "missing.db"))).toEqual([]);
});
});
describe("importLegacySecrets", () => {
// Set up a fresh DB with the new (scoped) `secret` shape to import into.
const createScopedDb = (path: string): Database => {
const db = new Database(path);
db.exec(`
CREATE TABLE secret (
id TEXT NOT NULL,
scope_id TEXT NOT NULL,
name TEXT NOT NULL,
provider TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (scope_id, id)
);
`);
return db;
};
it("inserts rows stamped with the given scope id", () => {
const path = join(workDir, "data.db");
const db = createScopedDb(path);
importLegacySecrets(db, "scope_a", [
{ id: "sec_1", name: "GH", provider: "onepassword", createdAt: 1 },
{ id: "sec_2", name: "St", provider: "keychain", createdAt: 2 },
]);
const rows = db
.prepare("SELECT id, scope_id, name, provider FROM secret ORDER BY id")
.all() as ReadonlyArray<{ id: string; scope_id: string; name: string; provider: string }>;
db.close();
expect(rows).toHaveLength(2);
expect(rows[0]).toEqual({
id: "sec_1",
scope_id: "scope_a",
name: "GH",
provider: "onepassword",
});
expect(rows[1].scope_id).toBe("scope_a");
});
it("is a no-op with an empty list", () => {
const path = join(workDir, "data.db");
const db = createScopedDb(path);
importLegacySecrets(db, "scope_a", []);
const count = (db.prepare("SELECT COUNT(*) as n FROM secret").get() as { n: number }).n;
db.close();
expect(count).toBe(0);
});
it("uses INSERT OR IGNORE so a second import of the same ids is a no-op", () => {
const path = join(workDir, "data.db");
const db = createScopedDb(path);
const rows = [{ id: "sec_1", name: "GH", provider: "onepassword", createdAt: 1 }];
importLegacySecrets(db, "scope_a", rows);
// If the user's already re-registered the secret via a different
// provider, the legacy row must NOT clobber it.
db.prepare(
"UPDATE secret SET provider = 'file' WHERE id = 'sec_1' AND scope_id = 'scope_a'",
).run();
importLegacySecrets(db, "scope_a", rows);
const provider = (
db
.prepare("SELECT provider FROM secret WHERE id = ? AND scope_id = ?")
.get("sec_1", "scope_a") as { provider: string }
).provider;
db.close();
expect(provider).toBe("file");
});
});