feat(db): add SQLite database layer with encrypted secret storage

Add bun:sqlite-based database with automatic migration system. Includes
repositories for LLM providers (CRUD), model-role assignments, encrypted
API key secrets (AES-256-GCM via master.key), and system settings.
Single-file DB at data/assistant.db.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
jeffusion
2026-03-05 00:31:35 +08:00
committed by 路遥知码力
parent c9a2db3df2
commit 21fef999fb
8 changed files with 802 additions and 0 deletions

133
src/db/database.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* SQLite database initialization and migration runner.
*
* Uses bun:sqlite (zero-dependency, built into Bun runtime).
* Single file at DATA_DIR/assistant.db with WAL mode for concurrent reads.
*/
import { Database } from 'bun:sqlite';
import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { migration001Init } from './migrations/001_init';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface Migration {
version: number;
name: string;
up(db: Database): void;
}
// ---------------------------------------------------------------------------
// Migration registry (ordered by version)
// ---------------------------------------------------------------------------
const MIGRATIONS: Migration[] = [migration001Init];
// ---------------------------------------------------------------------------
// Database singleton
// ---------------------------------------------------------------------------
let db: Database | null = null;
/**
* Resolve the database file path.
* Defaults to `data/assistant.db` relative to CWD, overridable via `DATABASE_PATH` env.
*/
function getDbPath(): string {
return resolve(process.env.DATABASE_PATH || './data/assistant.db');
}
/**
* Initialize the SQLite database.
* Creates the file and parent directories if needed.
* Enables WAL mode and runs pending migrations.
*
* MUST be called once at application startup.
*/
export function initDatabase(): Database {
if (db) return db;
const dbPath = getDbPath();
const dir = dirname(dbPath);
mkdirSync(dir, { recursive: true });
db = new Database(dbPath);
// Enable WAL mode for better concurrent read performance
db.exec('PRAGMA journal_mode = WAL');
// Enable foreign keys
db.exec('PRAGMA foreign_keys = ON');
// Reasonable busy timeout for concurrent writes
db.exec('PRAGMA busy_timeout = 5000');
// Run migrations
runMigrations(db);
console.log(`📦 Database initialized at ${dbPath}`);
return db;
}
/**
* Get the database instance. Throws if not initialized.
*/
export function getDatabase(): Database {
if (!db) {
throw new Error('Database not initialized. Call initDatabase() at startup.');
}
return db;
}
/**
* Close the database connection gracefully.
*/
export function closeDatabase(): void {
if (db) {
db.close();
db = null;
}
}
// ---------------------------------------------------------------------------
// Migration runner
// ---------------------------------------------------------------------------
/**
* Create the migrations tracking table if it doesn't exist,
* then run any migrations that haven't been applied yet.
*/
function runMigrations(database: Database): void {
// Create migration tracking table
database.exec(`
CREATE TABLE IF NOT EXISTS _migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Get already-applied versions
const applied = new Set<number>(
database
.query('SELECT version FROM _migrations ORDER BY version')
.all()
.map((row: any) => row.version as number)
);
// Run pending migrations in order
for (const migration of MIGRATIONS) {
if (applied.has(migration.version)) continue;
console.log(` ⬆️ Running migration ${migration.version}: ${migration.name}`);
database.transaction(() => {
migration.up(database);
database
.query('INSERT INTO _migrations (version, name) VALUES (?, ?)')
.run(migration.version, migration.name);
})();
}
}

View File

@@ -0,0 +1,81 @@
/**
* Migration 001: Initial schema for pluggable LLM provider architecture.
*
* Creates tables:
* - llm_providers: Provider instance configuration
* - llm_secrets: Encrypted API key storage
* - model_role_assignments: Business role → provider+model mapping
* - system_settings: Generic KV settings store
*/
import type { Database } from 'bun:sqlite';
import type { Migration } from '../database';
export const migration001Init: Migration = {
version: 1,
name: 'init_llm_provider_schema',
up(db: Database): void {
// ── Table 1: llm_providers ──────────────────────────────────────────
db.exec(`
CREATE TABLE llm_providers (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN (
'openai_compatible',
'openai_responses',
'anthropic',
'gemini'
)),
base_url TEXT,
default_model TEXT NOT NULL,
is_enabled INTEGER NOT NULL DEFAULT 1,
extra_config TEXT DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// ── Table 2: llm_secrets ────────────────────────────────────────────
db.exec(`
CREATE TABLE llm_secrets (
provider_id TEXT PRIMARY KEY REFERENCES llm_providers(id) ON DELETE CASCADE,
ciphertext BLOB NOT NULL,
iv BLOB NOT NULL,
auth_tag BLOB NOT NULL,
key_version INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// ── Table 3: model_role_assignments ─────────────────────────────────
db.exec(`
CREATE TABLE model_role_assignments (
role TEXT PRIMARY KEY CHECK (role IN (
'legacy',
'planner',
'specialist',
'judge',
'embedding'
)),
provider_id TEXT NOT NULL REFERENCES llm_providers(id),
model TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// ── Table 4: system_settings ────────────────────────────────────────
db.exec(`
CREATE TABLE system_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
is_sensitive INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// ── Indexes ─────────────────────────────────────────────────────────
db.exec('CREATE INDEX idx_providers_type ON llm_providers(type)');
db.exec('CREATE INDEX idx_providers_enabled ON llm_providers(is_enabled)');
},
};

View File

@@ -0,0 +1,100 @@
/**
* Repository for model_role_assignments table.
* Maps business roles (legacy, planner, specialist, judge, embedding)
* to specific provider + model combinations.
*/
import { getDatabase } from '../database';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ModelRole = 'legacy' | 'planner' | 'specialist' | 'judge' | 'embedding';
export interface RoleAssignmentRow {
role: ModelRole;
provider_id: string;
model: string;
updated_at: string;
}
/** Enriched role assignment with provider metadata (for API responses). */
export interface RoleAssignmentWithProvider extends RoleAssignmentRow {
provider_name: string;
provider_type: string;
}
// ---------------------------------------------------------------------------
// Repository
// ---------------------------------------------------------------------------
export const modelRoleRepo = {
/**
* List all role assignments with provider info.
*/
list(): RoleAssignmentWithProvider[] {
const db = getDatabase();
return db
.query(
`SELECT
r.role,
r.provider_id,
r.model,
r.updated_at,
p.name AS provider_name,
p.type AS provider_type
FROM model_role_assignments r
JOIN llm_providers p ON r.provider_id = p.id
ORDER BY r.role`
)
.all() as RoleAssignmentWithProvider[];
},
/**
* Get the assignment for a specific role.
*/
getByRole(role: ModelRole): RoleAssignmentRow | null {
const db = getDatabase();
return (
(db
.query('SELECT * FROM model_role_assignments WHERE role = ?')
.get(role) as RoleAssignmentRow) || null
);
},
/**
* Set (upsert) a role → provider+model mapping.
*/
set(role: ModelRole, providerId: string, model: string): void {
const db = getDatabase();
db.query(
`INSERT INTO model_role_assignments (role, provider_id, model, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(role) DO UPDATE SET
provider_id = excluded.provider_id,
model = excluded.model,
updated_at = datetime('now')`
).run(role, providerId, model);
},
/**
* Remove a role assignment.
*/
delete(role: ModelRole): boolean {
const db = getDatabase();
const result = db.query('DELETE FROM model_role_assignments WHERE role = ?').run(role);
return result.changes > 0;
},
/**
* Get all roles assigned to a specific provider (used when disabling/deleting a provider).
*/
getRolesByProvider(providerId: string): ModelRole[] {
const db = getDatabase();
return db
.query('SELECT role FROM model_role_assignments WHERE provider_id = ?')
.all(providerId)
.map((row: any) => row.role as ModelRole);
},
};

View File

@@ -0,0 +1,149 @@
/**
* Repository for llm_providers table.
* CRUD operations for LLM provider configurations.
*/
import { getDatabase } from '../database';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ProviderType = 'openai_compatible' | 'openai_responses' | 'anthropic' | 'gemini';
export interface ProviderRow {
id: string;
name: string;
type: ProviderType;
base_url: string | null;
default_model: string;
is_enabled: number; // 0 or 1
extra_config: string; // JSON string
created_at: string;
updated_at: string;
}
export interface CreateProviderInput {
name: string;
type: ProviderType;
baseUrl?: string | null;
defaultModel: string;
extraConfig?: Record<string, unknown>;
}
export interface UpdateProviderInput {
name?: string;
baseUrl?: string | null;
defaultModel?: string;
isEnabled?: boolean;
extraConfig?: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Repository
// ---------------------------------------------------------------------------
export const providerRepo = {
/**
* List all providers, optionally filtered by enabled status.
*/
list(enabledOnly = false): ProviderRow[] {
const db = getDatabase();
if (enabledOnly) {
return db
.query('SELECT * FROM llm_providers WHERE is_enabled = 1 ORDER BY created_at')
.all() as ProviderRow[];
}
return db.query('SELECT * FROM llm_providers ORDER BY created_at').all() as ProviderRow[];
},
/**
* Get a single provider by ID.
*/
getById(id: string): ProviderRow | null {
const db = getDatabase();
return (db.query('SELECT * FROM llm_providers WHERE id = ?').get(id) as ProviderRow) || null;
},
/**
* Create a new provider. Returns the created row.
*/
create(input: CreateProviderInput): ProviderRow {
const db = getDatabase();
const extraConfig = JSON.stringify(input.extraConfig || {});
// Insert and let SQLite generate the ID via DEFAULT
db.query(
`INSERT INTO llm_providers (name, type, base_url, default_model, extra_config)
VALUES (?, ?, ?, ?, ?)`
).run(input.name, input.type, input.baseUrl ?? null, input.defaultModel, extraConfig);
// Retrieve the last inserted row (SQLite doesn't have RETURNING in all versions)
const row = db
.query('SELECT * FROM llm_providers WHERE rowid = last_insert_rowid()')
.get() as ProviderRow;
return row;
},
/**
* Update an existing provider. Returns the updated row, or null if not found.
*/
update(id: string, input: UpdateProviderInput): ProviderRow | null {
const db = getDatabase();
const existing = this.getById(id);
if (!existing) return null;
const sets: string[] = [];
const values: (string | number | null)[] = [];
if (input.name !== undefined) {
sets.push('name = ?');
values.push(input.name);
}
if (input.baseUrl !== undefined) {
sets.push('base_url = ?');
values.push(input.baseUrl);
}
if (input.defaultModel !== undefined) {
sets.push('default_model = ?');
values.push(input.defaultModel);
}
if (input.isEnabled !== undefined) {
sets.push('is_enabled = ?');
values.push(input.isEnabled ? 1 : 0);
}
if (input.extraConfig !== undefined) {
sets.push('extra_config = ?');
values.push(JSON.stringify(input.extraConfig));
}
if (sets.length === 0) return existing;
sets.push("updated_at = datetime('now')");
values.push(id);
db.query(`UPDATE llm_providers SET ${sets.join(', ')} WHERE id = ?`).run(...values);
return this.getById(id);
},
/**
* Delete a provider by ID. Returns true if deleted.
* CASCADE will also delete the associated secret and role assignments.
*/
delete(id: string): boolean {
const db = getDatabase();
const result = db.query('DELETE FROM llm_providers WHERE id = ?').run(id);
return result.changes > 0;
},
/**
* Check if a provider has an associated API key stored.
*/
hasKey(id: string): boolean {
const db = getDatabase();
const row = db.query('SELECT 1 FROM llm_secrets WHERE provider_id = ?').get(id);
return !!row;
},
};

View File

@@ -0,0 +1,84 @@
/**
* Repository for llm_secrets table.
* Encrypted API key storage using AES-256-GCM.
*/
import { type EncryptedPayload, decrypt, encrypt } from '../../crypto/secrets';
import { getDatabase } from '../database';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface SecretRow {
provider_id: string;
ciphertext: Buffer;
iv: Buffer;
auth_tag: Buffer;
key_version: number;
updated_at: string;
}
// ---------------------------------------------------------------------------
// Repository
// ---------------------------------------------------------------------------
export const secretRepo = {
/**
* Store (or replace) an encrypted API key for a provider.
*/
set(providerId: string, apiKey: string): void {
const db = getDatabase();
const payload = encrypt(apiKey);
db.query(
`INSERT INTO llm_secrets (provider_id, ciphertext, iv, auth_tag, key_version, updated_at)
VALUES (?, ?, ?, ?, 1, datetime('now'))
ON CONFLICT(provider_id) DO UPDATE SET
ciphertext = excluded.ciphertext,
iv = excluded.iv,
auth_tag = excluded.auth_tag,
key_version = excluded.key_version,
updated_at = datetime('now')`
).run(providerId, payload.ciphertext, payload.iv, payload.authTag);
},
/**
* Retrieve and decrypt the API key for a provider.
* Returns null if no key is stored.
*/
get(providerId: string): string | null {
const db = getDatabase();
const row = db
.query('SELECT ciphertext, iv, auth_tag FROM llm_secrets WHERE provider_id = ?')
.get(providerId) as SecretRow | null;
if (!row) return null;
const payload: EncryptedPayload = {
ciphertext: Buffer.from(row.ciphertext),
iv: Buffer.from(row.iv),
authTag: Buffer.from(row.auth_tag),
};
return decrypt(payload);
},
/**
* Check if a provider has a stored API key.
*/
has(providerId: string): boolean {
const db = getDatabase();
const row = db.query('SELECT 1 FROM llm_secrets WHERE provider_id = ?').get(providerId);
return !!row;
},
/**
* Remove the API key for a provider.
*/
delete(providerId: string): boolean {
const db = getDatabase();
const result = db.query('DELETE FROM llm_secrets WHERE provider_id = ?').run(providerId);
return result.changes > 0;
},
};

View File

@@ -0,0 +1,121 @@
/**
* Repository for system_settings table.
* Generic key-value store for non-LLM configuration.
* Sensitive values are encrypted using the same crypto module as API keys.
*/
import { type EncryptedPayload, decrypt, encrypt } from '../../crypto/secrets';
import { getDatabase } from '../database';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SettingRow {
key: string;
value: string;
is_sensitive: number; // 0 or 1
updated_at: string;
}
// ---------------------------------------------------------------------------
// Repository
// ---------------------------------------------------------------------------
export const settingsRepo = {
/**
* Get a setting value by key. Automatically decrypts sensitive values.
* Returns null if not found.
*/
get(key: string): string | null {
const db = getDatabase();
const row = db
.query('SELECT value, is_sensitive FROM system_settings WHERE key = ?')
.get(key) as Pick<SettingRow, 'value' | 'is_sensitive'> | null;
if (!row) return null;
if (row.is_sensitive) {
try {
const parsed = JSON.parse(row.value) as {
ciphertext: string;
iv: string;
authTag: string;
};
const payload: EncryptedPayload = {
ciphertext: Buffer.from(parsed.ciphertext, 'base64'),
iv: Buffer.from(parsed.iv, 'base64'),
authTag: Buffer.from(parsed.authTag, 'base64'),
};
return decrypt(payload);
} catch {
// If decryption fails (e.g. master key changed), return null
return null;
}
}
return row.value;
},
/**
* Set a key-value pair. Encrypts the value if sensitive=true.
*/
set(key: string, value: string, sensitive = false): void {
const db = getDatabase();
let storedValue = value;
if (sensitive) {
const payload = encrypt(value);
storedValue = JSON.stringify({
ciphertext: payload.ciphertext.toString('base64'),
iv: payload.iv.toString('base64'),
authTag: payload.authTag.toString('base64'),
});
}
db.query(
`INSERT INTO system_settings (key, value, is_sensitive, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
is_sensitive = excluded.is_sensitive,
updated_at = datetime('now')`
).run(key, storedValue, sensitive ? 1 : 0);
},
/**
* Delete a setting.
*/
delete(key: string): boolean {
const db = getDatabase();
const result = db.query('DELETE FROM system_settings WHERE key = ?').run(key);
return result.changes > 0;
},
/**
* List all settings. Sensitive values are masked as '••••••••'.
*/
listAll(): Array<{ key: string; value: string; isSensitive: boolean; updatedAt: string }> {
const db = getDatabase();
const rows = db.query('SELECT * FROM system_settings ORDER BY key').all() as SettingRow[];
return rows.map((row) => ({
key: row.key,
value: row.is_sensitive ? '••••••••' : row.value,
isSensitive: row.is_sensitive === 1,
updatedAt: row.updated_at,
}));
},
/**
* Batch update multiple settings at once.
*/
setMany(entries: Array<{ key: string; value: string; sensitive?: boolean }>): void {
const db = getDatabase();
db.transaction(() => {
for (const entry of entries) {
this.set(entry.key, entry.value, entry.sensitive);
}
})();
},
};