mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-06-02 23:16:43 +00:00
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:
133
src/db/database.ts
Normal file
133
src/db/database.ts
Normal 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);
|
||||
})();
|
||||
}
|
||||
}
|
||||
81
src/db/migrations/001_init.ts
Normal file
81
src/db/migrations/001_init.ts
Normal 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)');
|
||||
},
|
||||
};
|
||||
100
src/db/repositories/model-role-repo.ts
Normal file
100
src/db/repositories/model-role-repo.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
149
src/db/repositories/provider-repo.ts
Normal file
149
src/db/repositories/provider-repo.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
84
src/db/repositories/secret-repo.ts
Normal file
84
src/db/repositories/secret-repo.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
121
src/db/repositories/settings-repo.ts
Normal file
121
src/db/repositories/settings-repo.ts
Normal 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);
|
||||
}
|
||||
})();
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user