feat(cli): banir SQLite direto — withRuntime + src/lib/db/* modules (Fase 1.5)

This commit is contained in:
diegosouzapw
2026-05-14 23:41:02 -03:00
parent 162e2f4b98
commit ab911265ed
9 changed files with 371 additions and 232 deletions

View File

@@ -0,0 +1,31 @@
rules:
- id: cli-no-sqlite-direct
patterns:
- pattern: new Database(...)
paths:
include:
- "bin/**"
exclude:
- "bin/cli/sqlite.mjs"
message: >
Direct SQLite access in bin/ is banned. Use src/lib/db/* helpers or
withRuntime() from bin/cli/runtime.mjs. See CLAUDE.md hard rule #5 and
bin/cli/CONVENTIONS.md.
languages: [js]
severity: ERROR
- id: cli-no-raw-sql
patterns:
- pattern: $DB.prepare("INSERT INTO ...")
- pattern: $DB.prepare("DELETE FROM ...")
- pattern: $DB.prepare("UPDATE $TABLE SET ...")
paths:
include:
- "bin/**"
exclude:
- "bin/cli/sqlite.mjs"
message: >
Raw SQL in bin/ is banned. Use src/lib/db/* helpers. See CLAUDE.md
hard rule #5 and bin/cli/CONVENTIONS.md.
languages: [js]
severity: ERROR

View File

@@ -155,18 +155,24 @@ Single helper:
```js
import { withRuntime } from "./runtime.mjs";
await withRuntime(async (ctx) => {
if (ctx.kind === "http") return ctx.api("/v1/providers");
return ctx.db.providers.list();
await withRuntime(async ({ kind, api, db }) => {
if (kind === "http")
return api("/api/combos", { retry: false, timeout: 5000, acceptNotOk: true });
return db.combos.getCombos();
});
```
- `kind: "http"` when server is up (preferred).
- `kind: "db"` when offline (read-only operations).
- `kind: "http"` when server is up (preferred). `api` is `apiFetch` bound to
the current profile/base-URL.
- `kind: "db"` when server is offline. `db` exposes typed module exports:
- `db.combos``src/lib/db/combos.ts` (getCombos, getComboByName, createCombo,
deleteComboByName, setActiveCombo, …)
- `db.recovery``src/lib/db/recovery.ts` (countEncryptedCredentials,
resetEncryptedColumns)
- Mutations that require server **must** error with exit code `3` when the
server is down, never silently fall back.
- **Never** write raw SQL in commands — always go through `bin/cli/sqlite.mjs`
or the upstream `src/lib/db/` modules.
- **Never** write raw SQL in commands — always go through `src/lib/db/` modules.
The Semgrep rule at `.semgrep/rules/cli-no-sqlite.yaml` enforces this at commit time.
## 9. Audit of destructive actions

View File

@@ -1,7 +1,6 @@
import { Option } from "commander";
import { printHeading } from "../io.mjs";
import { openOmniRouteDb } from "../sqlite.mjs";
import { apiFetch, isServerUp } from "../api.mjs";
import { withRuntime } from "../runtime.mjs";
import { t } from "../i18n.mjs";
const VALID_STRATEGIES = [
@@ -72,51 +71,54 @@ export function registerCombo(program) {
}
export async function runComboListCommand(opts = {}) {
const { db } = await openOmniRouteDb();
try {
// TODO(1.5): replace raw SQL with src/lib/db/combos.ts
const combos = db
.prepare("SELECT id, name, strategy, enabled, target_count FROM combos ORDER BY name")
.all();
return await withRuntime(async ({ kind, api, db }) => {
let combos = [];
let activeCombo = null;
let activeCombo = null;
try {
const serverUp = await isServerUp();
if (serverUp) {
const res = await apiFetch("/api/combos/active", {
retry: false,
timeout: 3000,
acceptNotOk: true,
});
if (res.ok) {
const data = await res.json();
activeCombo = data.active || data.name || data.combo || null;
if (kind === "http") {
const [listRes, activeRes] = await Promise.all([
api("/api/combos", { retry: false, timeout: 5000, acceptNotOk: true }),
api("/api/settings", { retry: false, timeout: 3000, acceptNotOk: true }),
]);
if (listRes.ok) {
const data = await listRes.json();
combos = Array.isArray(data) ? data : (data.combos ?? []);
}
if (activeRes.ok) {
const settings = await activeRes.json();
activeCombo = settings?.activeCombo ?? null;
}
} else {
combos = await db.combos.getCombos();
}
if (opts.json || opts.output === "json") {
console.log(JSON.stringify({ combos, active: activeCombo }, null, 2));
return 0;
}
printHeading(t("combo.title"));
if (combos.length === 0) {
console.log(t("combo.noCombos"));
return 0;
}
for (const combo of combos) {
const comboName = combo.name ?? combo.id ?? "?";
const isActive = activeCombo && (comboName === activeCombo || combo.id === activeCombo);
const icon = isActive ? "\x1b[32m●\x1b[0m" : "\x1b[2m○\x1b[0m";
const enabled = combo.enabled !== false;
const status = enabled ? "\x1b[32menabled\x1b[0m" : "\x1b[31mdisabled\x1b[0m";
const strategy = (combo.strategy ?? "priority").padEnd(12);
console.log(` ${icon} ${comboName.padEnd(25)} [${strategy}] ${status}`);
}
} catch {}
if (opts.json || opts.output === "json") {
console.log(JSON.stringify({ combos, active: activeCombo }, null, 2));
return 0;
}
printHeading(t("combo.title"));
if (combos.length === 0) {
console.log(t("combo.noCombos"));
return 0;
}
for (const combo of combos) {
const isActive = activeCombo && (combo.name === activeCombo || combo.id === activeCombo);
const icon = isActive ? "\x1b[32m●\x1b[0m" : "\x1b[2m○\x1b[0m";
const status = combo.enabled ? "\x1b[32menabled\x1b[0m" : "\x1b[31mdisabled\x1b[0m";
const strategy = (combo.strategy || "priority").padEnd(12);
console.log(` ${icon} ${combo.name.padEnd(25)} [${strategy}] ${status}`);
}
return 0;
} finally {
db.close();
});
} catch (err) {
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
return 1;
}
}
@@ -126,40 +128,50 @@ export async function runComboSwitchCommand(name, opts = {}) {
return 1;
}
const serverUp = await isServerUp();
if (serverUp) {
try {
const res = await apiFetch("/api/combos/switch", {
method: "POST",
body: { name },
retry: false,
acceptNotOk: true,
});
if (res.ok) {
console.log(t("combo.switched", { name }));
return 0;
}
} catch {}
}
// DB fallback
const { db } = await openOmniRouteDb();
try {
// TODO(1.5): replace raw SQL with src/lib/db/combos.ts
const combo = db.prepare("SELECT id FROM combos WHERE name = ?").get(name);
if (!combo) {
console.error(`Combo '${name}' not found.`);
return 1;
}
return await withRuntime(async ({ kind, api, db }) => {
if (kind === "http") {
const listRes = await api("/api/combos", {
retry: false,
timeout: 5000,
acceptNotOk: true,
});
if (!listRes.ok) {
console.error(`Failed to fetch combo list (HTTP ${listRes.status}).`);
return 1;
}
const data = await listRes.json();
const combos = Array.isArray(data) ? data : (data.combos ?? []);
const found = combos.find((c) => c.name === name || c.id === name);
if (!found) {
console.error(`Combo '${name}' not found.`);
return 1;
}
const patchRes = await api("/api/settings", {
method: "PATCH",
body: { activeCombo: name },
retry: false,
acceptNotOk: true,
});
if (!patchRes.ok) {
console.error(`Failed to switch combo (HTTP ${patchRes.status}).`);
return 1;
}
} else {
const combo = await db.combos.getComboByName(name);
if (!combo) {
console.error(`Combo '${name}' not found.`);
return 1;
}
db.combos.setActiveCombo(name);
}
db.prepare(
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'activeCombo', ?)"
).run(JSON.stringify(name));
console.log(t("combo.switched", { name }));
return 0;
} finally {
db.close();
console.log(t("combo.switched", { name }));
return 0;
});
} catch (err) {
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
return 1;
}
}
@@ -174,23 +186,36 @@ export async function runComboCreateCommand(name, strategy = "priority", opts =
return 1;
}
const { db } = await openOmniRouteDb();
try {
// TODO(1.5): replace raw SQL with src/lib/db/combos.ts
const existing = db.prepare("SELECT id FROM combos WHERE name = ?").get(name);
if (existing) {
console.error(`Combo '${name}' already exists. Delete it first.`);
return 1;
}
return await withRuntime(async ({ kind, api, db }) => {
if (kind === "http") {
const res = await api("/api/combos", {
method: "POST",
body: { name, strategy, enabled: true, models: [], config: {} },
retry: false,
acceptNotOk: true,
});
if (!res.ok) {
const body = await res.text().catch(() => "");
const msg = body ? `${body}` : "";
console.error(`Failed to create combo (HTTP ${res.status})${msg}`);
return 1;
}
} else {
const existing = await db.combos.getComboByName(name);
if (existing) {
console.error(`Combo '${name}' already exists. Delete it first.`);
return 1;
}
await db.combos.createCombo({ name, strategy, enabled: true, models: [], config: {} });
}
db.prepare(
"INSERT INTO combos (name, strategy, enabled, target_count) VALUES (?, ?, 1, 0)"
).run(name, strategy);
console.log(t("combo.created", { name }));
return 0;
} finally {
db.close();
console.log(t("combo.created", { name }));
return 0;
});
} catch (err) {
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
return 1;
}
}
@@ -213,17 +238,47 @@ export async function runComboDeleteCommand(name, opts = {}) {
}
}
const { db } = await openOmniRouteDb();
try {
// TODO(1.5): replace raw SQL with src/lib/db/combos.ts
const result = db.prepare("DELETE FROM combos WHERE name = ?").run(name);
if (result.changes > 0) {
return await withRuntime(async ({ kind, api, db }) => {
if (kind === "http") {
const listRes = await api("/api/combos", {
retry: false,
timeout: 5000,
acceptNotOk: true,
});
if (!listRes.ok) {
console.error(`Failed to fetch combo list (HTTP ${listRes.status}).`);
return 1;
}
const data = await listRes.json();
const combos = Array.isArray(data) ? data : (data.combos ?? []);
const found = combos.find((c) => c.name === name || c.id === name);
if (!found) {
console.error(`Combo '${name}' not found.`);
return 1;
}
const delRes = await api(`/api/combos/${encodeURIComponent(found.id)}`, {
method: "DELETE",
retry: false,
acceptNotOk: true,
});
if (!delRes.ok) {
console.error(`Failed to delete combo (HTTP ${delRes.status}).`);
return 1;
}
} else {
const deleted = await db.combos.deleteComboByName(name);
if (!deleted) {
console.error(`Combo '${name}' not found.`);
return 1;
}
}
console.log(t("combo.deleted", { name }));
return 0;
}
console.error(`Combo '${name}' not found.`);
});
} catch (err) {
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
return 1;
} finally {
db.close();
}
}

View File

@@ -1,21 +1,13 @@
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { existsSync } from "node:fs";
import { resolveDataDir } from "../data-dir.mjs";
import { join } from "node:path";
import { homedir, platform } from "node:os";
const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
export async function runResetEncryptedColumns(argv) {
const dataDir = (() => {
const configured = process.env.DATA_DIR?.trim();
if (configured) return configured;
if (platform() === "win32") {
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
return join(appData, "omniroute");
}
const xdg = process.env.XDG_CONFIG_HOME?.trim();
if (xdg) return join(xdg, "omniroute");
return join(homedir(), ".omniroute");
})();
const dataDir = resolveDataDir();
const dbPath = join(dataDir, "storage.sqlite");
if (!existsSync(dbPath)) {
@@ -23,7 +15,8 @@ export async function runResetEncryptedColumns(argv) {
return 0;
}
const force = argv.includes("--force");
const force = Array.isArray(argv) ? argv.includes("--force") : argv?.force === true;
if (!force) {
console.log(`
\x1b[1m\x1b[33m⚠ WARNING: This will erase all encrypted credentials\x1b[0m
@@ -46,51 +39,28 @@ export async function runResetEncryptedColumns(argv) {
}
try {
const require = createRequire(import.meta.url);
const Database = require("better-sqlite3");
const db = new Database(dbPath);
const { countEncryptedCredentials, resetEncryptedColumns } = await import(
`${PROJECT_ROOT}/src/lib/db/recovery.ts`
);
const countResult = db
.prepare(
`SELECT COUNT(*) as cnt FROM provider_connections
WHERE api_key LIKE 'enc:v1:%'
OR access_token LIKE 'enc:v1:%'
OR refresh_token LIKE 'enc:v1:%'
OR id_token LIKE 'enc:v1:%'`
)
.get();
const count = countEncryptedCredentials();
const affected = countResult?.cnt ?? 0;
if (affected === 0) {
if (count === 0) {
console.log("\x1b[32m✔ No encrypted credentials found — nothing to reset.\x1b[0m");
db.close();
return 0;
}
const result = db
.prepare(
`UPDATE provider_connections
SET api_key = NULL,
access_token = NULL,
refresh_token = NULL,
id_token = NULL
WHERE api_key LIKE 'enc:v1:%'
OR access_token LIKE 'enc:v1:%'
OR refresh_token LIKE 'enc:v1:%'
OR id_token LIKE 'enc:v1:%'`
)
.run();
db.close();
const { affected } = resetEncryptedColumns({ dryRun: false });
console.log(
`\x1b[32m✔ Reset ${result.changes} provider connection(s).\x1b[0m\n` +
`\x1b[32m✔ Reset ${affected} provider connection(s).\x1b[0m\n` +
` Re-authenticate your providers in the dashboard or re-add API keys.\n`
);
return 0;
} catch (err) {
console.error(`\x1b[31m✖ Failed to reset encrypted columns:\x1b[0m ${err.message || err}`);
console.error(
`\x1b[31m✖ Failed to reset encrypted columns:\x1b[0m ${err instanceof Error ? err.message : String(err)}`
);
return 1;
}
}

View File

@@ -1,5 +1,8 @@
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { apiFetch, isServerUp } from "./api.mjs";
import { openOmniRouteDb } from "./sqlite.mjs";
const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
export class ServerOfflineError extends Error {
constructor(message = "Server is offline and operation requires HTTP runtime") {
@@ -17,21 +20,17 @@ function makeHttpContext(opts) {
};
}
async function importDbModules() {
const [combos, recovery] = await Promise.all([
import(`${PROJECT_ROOT}/src/lib/db/combos.ts`),
import(`${PROJECT_ROOT}/src/lib/db/recovery.ts`),
]);
return { combos, recovery };
}
async function makeDbContext() {
const { db, dataDir, dbPath } = await openOmniRouteDb();
return {
kind: "db",
db,
dataDir,
dbPath,
close: () => {
try {
db.close();
} catch {
// best-effort
}
},
};
const modules = await importDbModules();
return { kind: "db", db: modules };
}
export async function withRuntime(fn, opts = {}) {
@@ -48,12 +47,7 @@ export async function withRuntime(fn, opts = {}) {
}
}
const ctx = await makeDbContext();
try {
return await fn(ctx);
} finally {
ctx.close?.();
}
return fn(await makeDbContext());
}
export async function withHttp(fn, opts = {}) {
@@ -63,10 +57,5 @@ export async function withHttp(fn, opts = {}) {
}
export async function withDb(fn) {
const ctx = await makeDbContext();
try {
return await fn(ctx);
} finally {
ctx.close?.();
}
return fn(await makeDbContext());
}

View File

@@ -260,3 +260,15 @@ export async function deleteCombo(id: string) {
backupDbFile("pre-write");
return true;
}
export async function deleteComboByName(name: string) {
const combo = await getComboByName(name);
if (!combo || typeof combo.id !== "string") return false;
return deleteCombo(combo.id);
}
export function setActiveCombo(name: string, db = getDbInstance()) {
db.prepare(
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'activeCombo', ?)"
).run(JSON.stringify(name));
}

33
src/lib/db/recovery.ts Normal file
View File

@@ -0,0 +1,33 @@
import { getDbInstance } from "./core";
type DbInstance = ReturnType<typeof getDbInstance>;
const ENCRYPTED_COLUMNS = ["api_key", "access_token", "refresh_token", "id_token"] as const;
const ENCRYPTED_PATTERN = "enc:v1:%";
function buildWhereClause(): string {
return ENCRYPTED_COLUMNS.map((col) => `${col} LIKE '${ENCRYPTED_PATTERN}'`).join(" OR ");
}
export function countEncryptedCredentials(db: DbInstance = getDbInstance()): number {
const where = buildWhereClause();
const row = db
.prepare(`SELECT COUNT(*) AS cnt FROM provider_connections WHERE ${where}`)
.get() as { cnt: number } | undefined;
return row?.cnt ?? 0;
}
export function resetEncryptedColumns(
{ dryRun }: { dryRun: boolean },
db: DbInstance = getDbInstance()
): { affected: number } {
const affected = countEncryptedCredentials(db);
if (dryRun || affected === 0) return { affected };
const nullCols = ENCRYPTED_COLUMNS.map((col) => `${col} = NULL`).join(", ");
const where = buildWhereClause();
db.prepare(`UPDATE provider_connections SET ${nullCols} WHERE ${where}`).run();
return { affected };
}

View File

@@ -3,49 +3,27 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import Database from "better-sqlite3";
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
const ORIGINAL_FETCH = globalThis.fetch;
interface ComboRow {
id: number;
name: string;
strategy: string;
enabled: number;
}
function createTempDataDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cli-combo-"));
}
function initComboTable(dbPath: string) {
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
db.prepare(
"CREATE TABLE IF NOT EXISTS key_value (namespace TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (namespace, key))"
).run();
db.prepare(
"CREATE TABLE IF NOT EXISTS combos (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, strategy TEXT, enabled INTEGER DEFAULT 1, target_count INTEGER DEFAULT 0)"
).run();
db.close();
}
async function withComboEnv(fn: (dataDir: string, dbPath: string) => Promise<void>) {
async function withComboEnv(fn: (dataDir: string) => Promise<void>) {
const dataDir = createTempDataDir();
const dbPath = path.join(dataDir, "storage.sqlite");
process.env.DATA_DIR = dataDir;
// Mock fetch → simulates server offline so withRuntime falls back to DB
globalThis.fetch = (async () => {
throw new Error("server offline");
}) as typeof fetch;
initComboTable(dbPath);
const originalLog = console.log;
console.log = () => {};
try {
await fn(dataDir, dbPath);
await fn(dataDir);
} finally {
console.log = originalLog;
globalThis.fetch = ORIGINAL_FETCH;
@@ -56,23 +34,18 @@ async function withComboEnv(fn: (dataDir: string, dbPath: string) => Promise<voi
}
}
test("combo create inserts a new combo row", async () => {
await withComboEnv(async (_dataDir, dbPath) => {
test("combo create inserts a new combo via db module", async () => {
await withComboEnv(async () => {
const { runComboCreateCommand } = await import("../../bin/cli/commands/combo.mjs");
const result = await runComboCreateCommand("my-combo", "priority", {});
assert.equal(result, 0);
const db = new Database(dbPath);
const row = db
.prepare("SELECT name, strategy, enabled FROM combos WHERE name = ?")
.get("my-combo") as ComboRow | undefined;
db.close();
assert.ok(row);
assert.equal(row.name, "my-combo");
assert.equal(row.strategy, "priority");
assert.equal(row.enabled, 1);
// Verify via the same db module
const { getComboByName } = await import("../../src/lib/db/combos.ts");
const combo = await getComboByName("my-combo");
assert.ok(combo);
assert.equal(combo.name, "my-combo");
assert.equal(combo.strategy, "priority");
});
});
@@ -90,8 +63,8 @@ test("combo create fails if combo already exists", async () => {
});
});
test("combo delete removes the row", async () => {
await withComboEnv(async (_dataDir, dbPath) => {
test("combo delete removes the combo", async () => {
await withComboEnv(async () => {
const { runComboCreateCommand, runComboDeleteCommand } =
await import("../../bin/cli/commands/combo.mjs");
@@ -99,10 +72,9 @@ test("combo delete removes the row", async () => {
const result = await runComboDeleteCommand("to-delete", { yes: true });
assert.equal(result, 0);
const db = new Database(dbPath);
const row = db.prepare("SELECT id FROM combos WHERE name = ?").get("to-delete");
db.close();
assert.equal(row, undefined);
const { getComboByName } = await import("../../src/lib/db/combos.ts");
const combo = await getComboByName("to-delete");
assert.equal(combo, null);
});
});
@@ -114,8 +86,8 @@ test("combo list returns 0 with empty combos table", async () => {
});
});
test("combo switch updates key_value settings when server is offline", async () => {
await withComboEnv(async (_dataDir, dbPath) => {
test("combo switch updates active combo when server is offline", async () => {
await withComboEnv(async () => {
const { runComboCreateCommand, runComboSwitchCommand } =
await import("../../bin/cli/commands/combo.mjs");
@@ -123,13 +95,9 @@ test("combo switch updates key_value settings when server is offline", async ()
const result = await runComboSwitchCommand("my-switch", {});
assert.equal(result, 0);
const db = new Database(dbPath);
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = 'activeCombo'")
.get() as { value: string } | undefined;
db.close();
assert.ok(row);
assert.equal(JSON.parse(row.value), "my-switch");
// Verify active combo written to key_value settings
const { getSettings } = await import("../../src/lib/db/settings.ts");
const settings = await getSettings();
assert.equal((settings as Record<string, unknown>).activeCombo, "my-switch");
});
});

View File

@@ -0,0 +1,75 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
async function withRecoveryEnv(fn: (dataDir: string) => Promise<void>) {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-recovery-"));
process.env.DATA_DIR = dataDir;
try {
await fn(dataDir);
} finally {
fs.rmSync(dataDir, { recursive: true, force: true });
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
}
}
test("countEncryptedCredentials returns 0 on fresh db", async () => {
await withRecoveryEnv(async () => {
const { countEncryptedCredentials } = await import("../../src/lib/db/recovery.ts");
const count = countEncryptedCredentials();
assert.equal(count, 0);
});
});
test("resetEncryptedColumns dry-run returns affected count without mutating", async () => {
await withRecoveryEnv(async () => {
const { resetEncryptedColumns, countEncryptedCredentials } =
await import("../../src/lib/db/recovery.ts");
// Insert a fake encrypted row directly using the DB instance
const { getDbInstance } = await import("../../src/lib/db/core.ts");
const db = getDbInstance();
const now = new Date().toISOString();
db.prepare(
"INSERT INTO provider_connections (id, provider, name, api_key, created_at, updated_at) VALUES (?,?,?,?,?,?)"
).run("test-id", "openai", "test-conn", "enc:v1:fake-encrypted-value", now, now);
const countBefore = countEncryptedCredentials();
assert.equal(countBefore, 1);
const { affected } = resetEncryptedColumns({ dryRun: true });
assert.equal(affected, 1);
// Dry run should NOT have mutated
const countAfter = countEncryptedCredentials();
assert.equal(countAfter, 1);
});
});
test("resetEncryptedColumns force mode nulls encrypted columns", async () => {
await withRecoveryEnv(async () => {
const { resetEncryptedColumns } = await import("../../src/lib/db/recovery.ts");
const { getDbInstance } = await import("../../src/lib/db/core.ts");
const db = getDbInstance();
const now = new Date().toISOString();
db.prepare(
"INSERT INTO provider_connections (id, provider, name, api_key, access_token, created_at, updated_at) VALUES (?,?,?,?,?,?,?)"
).run("rec-id", "anthropic", "rec-conn", "enc:v1:key123", "enc:v1:tok456", now, now);
const { affected } = resetEncryptedColumns({ dryRun: false });
assert.ok(affected >= 1);
const row = db
.prepare("SELECT api_key, access_token FROM provider_connections WHERE id = ?")
.get("rec-id") as { api_key: null; access_token: null } | undefined;
assert.ok(row);
assert.equal(row.api_key, null);
assert.equal(row.access_token, null);
});
});