Skip to main content

Database

Elftia uses better-sqlite3 + Drizzle ORM as its local database solution. All database operations execute asynchronously through a Worker Thread.

Technology Choices

ItemChoiceReason
Database enginebetter-sqlite3Embedded, zero-config, high performance
ORMDrizzle ORMType-safe, zero runtime overhead, SQL-like API
ModeWAL (Write-Ahead Logging)Supports concurrent reads; writes do not block reads
Access patternWorker Thread RPCAvoids blocking the main thread

Database Location

{userData}/elftia.db # Main database file
{userData}/elftia.db-wal # WAL log
{userData}/elftia.db-shm # Shared memory file

{userData} paths by platform:

  • Windows: %APPDATA%/elftia/
  • macOS: ~/Library/Application Support/elftia/
  • Linux: ~/.config/elftia/

Worker Thread Architecture

All database operations execute through the DbClient -> db.worker.ts RPC pattern:

sequenceDiagram
participant S as Service Layer
participant C as DbClient (main thread)
participant W as db.worker.ts (Worker Thread)
participant DB as SQLite (WAL)

Note over S,DB: Initialization
C->>W: Create Worker({dbPath})
W->>DB: new Database(dbPath)
W->>DB: PRAGMA journal_mode = WAL
W->>DB: drizzle(db) + migrate()
W-->>C: ready signal

Note over S,DB: Normal operation
S->>C: db.chatSessionsList()
C->>C: id = ++seq
C->>W: postMessage({id, method: 'chatSessions:list', params})
W->>DB: SELECT * FROM chat_sessions ...
DB-->>W: Result rows
W-->>C: postMessage({id, result: rows})
C->>C: pending[id].resolve(rows)
C-->>S: Promise<Session[]>

DbClient Core Implementation

{/* packages/desktop/app/main/workers/DbClient.ts */}
class DbClient extends EventEmitter implements DbRpc {
private worker: Worker;
private seq = 0;
private pending = Map<number, { resolve, reject }>;

constructor(dbPath: string, options?: DbClientOptions) {
this.worker = new Worker(workerPath, {
workerData: { dbPath, diagnosticsDir },
});
this.worker.on('message', (msg) => this.handleMessage(msg));
}

async ready(): Promise<void>;

private async send(method: string, params: unknown): Promise<unknown> {
const id = ++this.seq;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.worker.postMessage({ id, method, params });
});
}

// 100+ typed methods
chatSessionsList(): Promise<ChatSession[]>;
chatMessagesInsert(msg: ChatMessageInput): Promise<void>;
auditLogInsert(entry: AuditLogEntry): Promise<void>;
}

RPC Type Definitions

{/* packages/desktop/app/main/workers/types.ts */}
interface DbRequest {
id: number;
method: string;
params: unknown;
}

interface DbResponse {
id: number;
result?: unknown;
error?: { message: string; stack?: string };
}

interface DbRpc {
chatSessionsList(): Promise<ChatSession[]>;
chatSessionsGet(id: string): Promise<ChatSession | null>;
chatSessionsCreate(input: ChatSessionInput): Promise<ChatSession>;
chatSessionsUpdate(id: string, data: Partial<ChatSession>): Promise<void>;
chatSessionsDelete(id: string): Promise<void>;
// ... 100+ methods
}

Schema Organization

The database schema is defined using Drizzle ORM, located in packages/desktop/app/main/db/schema/:

packages/desktop/app/main/db/schema/
├── index.ts # Unified export
├── accounts.ts # User accounts
├── chat.ts # Chat messages
├── sessions.ts # Chat sessions
├── llm.ts # LLM providers/models/keys
├── projects.ts # Projects
├── settings.ts # Application settings
├── attachments.ts # Attachments
├── media.ts # Media resources
└── templates.ts # Templates

Core Data Tables

{/* Simplified table structure definitions */}

// Chat session
interface ChatSession {
id: string; // UUID
title: string; // Session title
type: 'chat' | 'roleplay' | 'agent';
agentId?: string; // Associated Agent ID
personaId?: string; // Associated Persona ID
providerId?: string; // LLM provider ID
modelId?: string; // Model ID
rpConfig?: RPConfig; // RP config (JSON)
createdAt: number;
updatedAt: number;
lastMessageAt?: number;
messageCount: number;
pinned: boolean;
folderId?: string;
}

// Chat message
interface ChatMessage {
id: string; // UUID
sessionId: string; // Owning session ID
role: 'user' | 'assistant' | 'system';
content: string; // Message content
parentId?: string; // Parent message ID (branch support)
reasoning?: string; // Reasoning content (thinking)
toolCalls?: ToolCall[]; // Tool calls (JSON)
meta?: MessageMeta; // Metadata (providerId, modelId, tokenCount)
createdAt: number;
}
// User account
interface Account {
id: string;
email: string;
displayName?: string;
avatarUrl?: string;
createdAt: number;
}

// Account token
interface AccountToken {
id: string;
accountId: string;
provider: string; // OAuth provider
accessToken: string; // Encrypted at rest
refreshToken?: string;
expiresAt?: number;
}

LLM Configuration

// LLM provider
interface LLMProvider {
id: string;
name: string;
type: string; // openai, anthropic, google, etc.
apiKey?: string; // Encrypted at rest
baseUrl?: string; // Custom API endpoint
enabled: boolean;
models: string[]; // Available model list
}

// API key pool (multi-key support)
interface ApiKeyEntry {
id: string;
providerId: string;
label?: string;
apiKey: string; // Encrypted at rest
weight: number; // Round-robin weight
enabled: boolean;
createdAt: number;
}
// Project
interface Project {
id: string;
name: string;
path: string; // Filesystem path
description?: string;
createdAt: number;
updatedAt: number;
}

Security Audit

// Audit log entry
interface AuditLogEntry {
id: string;
timestamp: number;
eventType: string; // tool_executed, injection_detected, etc.
severity: 'info' | 'warning' | 'critical';
channelId?: string;
userId?: string;
toolName?: string;
details: string; // JSON-serialized details
}

Media and Resources

// Media resource
interface MediaResource {
id: string;
sessionId: string;
messageId: string;
filePath: string;
mimeType: string;
thumbnailPath?: string;
size: number;
createdAt: number;
}

// Attachment
interface Attachment {
id: string;
sessionId: string;
messageId: string;
name: string;
type: string;
url?: string;
size: number;
}

Other Core Tables

TablePurposeSchema File
settingsApplication settings (key-value)schema/settings.ts
templatesImage templatesschema/templates.ts
channel_instancesChannel instancesWorker db/channels.ts
channel_usersChannel usersWorker db/channelUsers.ts
character_cardsCharacter cardsWorker db/characterCards.ts
world_info_booksWorld info booksWorker db/worldInfo.ts
world_info_entriesWorld info entriesWorker db/worldInfo.ts
custom_agentsCustom AgentsWorker db/customAgents.ts
personasPersona definitionsWorker db/personas.ts
user_skillsUser SkillsWorker db/userSkills.ts
tagsTagsWorker db/tags.ts
notesNote indexWorker db/notes.ts
note_foldersNote foldersWorker db/noteFolders.ts
cron_jobsScheduled tasksCronService filesystem

DB Worker Modules

Concrete database operation implementations are spread across multiple modules under packages/desktop/app/main/workers/db/:

workers/db/
├── index.ts # Main dispatcher; routes by method prefix to the appropriate module
├── chatSessions.ts # chatSessions:* methods
├── chatMessages.ts # chatMessages:* methods
├── customAgents.ts # customAgents:* methods
├── personas.ts # personas:* methods
├── characterCards.ts # characterCards:* methods
├── worldInfo.ts # worldInfo:* methods
├── groupChat.ts # groupChat:* methods
├── channels.ts # channels:* methods
├── channelUsers.ts # channelUsers:* methods
├── auditLog.ts # auditLog:* methods
├── apiKeys.ts # apiKeys:* methods
├── tags.ts # tags:* methods
├── notes.ts # notes:* methods
├── noteFolders.ts # noteFolders:* methods
├── userSkills.ts # userSkills:* methods
├── rpDefaults.ts # rpDefaults:* methods
└── characterSprites.ts # sprites:* methods

Migration System

Drizzle Migrations

Schema changes are applied automatically via Drizzle's migrate():

{/* packages/desktop/app/main/db/index.ts */}
function initDatabase() {
const sqlite = new Database(dbPath);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
const db = drizzle(sqlite);
migrate(db, { migrationsFolder: 'drizzle' });
}

Custom Migrations

Complex data migration logic is implemented in migrations.ts:

{/* Custom migration example */}
async function migrateApiKeyPool(db: DbRpc) {
const providers = await db.llmProvidersList();
for (const p of providers) {
if (p.apiKey) {
await db.apiKeysInsert({
providerId: p.id,
apiKey: p.apiKey,
weight: 1,
enabled: true,
});
}
}
}

Initialization Sequence

sequenceDiagram
participant M as Main Process
participant C as DbClient
participant W as db.worker.ts
participant D as SQLite

M->>C: new DbClient(dbPath)
C->>W: Create Worker Thread
W->>D: new Database(dbPath)
W->>D: PRAGMA journal_mode = WAL
W->>D: PRAGMA foreign_keys = ON
W->>W: Register all RPC methods
W-->>C: ready signal

M->>C: await db.ready()
Note over M: Worker initialization complete; continue

M->>M: initDatabase()
Note over M: Drizzle ORM initialization + migrate

M->>M: Create services that depend on db

Key point: await db.ready() must complete before creating other services, to prevent SQLITE_BUSY errors during Worker initialization.

Database Optimization

The DatabaseOptimizer service periodically performs maintenance operations:

OperationDescriptionFrequency
PRAGMA optimizeUpdate statisticsOn app close
VACUUMReclaim spaceManual trigger
ANALYZEUpdate query planPeriodic
WAL checkpointMerge WAL into main fileSQLite automatic
FileDescription
packages/desktop/app/main/db/index.tsDatabase initialization (WAL + Drizzle + migrate)
packages/desktop/app/main/db/schema/Drizzle ORM schema definition directory
packages/desktop/app/main/workers/DbClient.tsDatabase Worker client
packages/desktop/app/main/workers/db.worker.tsDatabase Worker implementation
packages/desktop/app/main/workers/db/Domain-split DB operation modules
packages/desktop/app/main/workers/types.tsDbRpc interface definitions
packages/desktop/app/main/services/persistence/db-optimizer/DatabaseOptimizer.tsDatabase optimization service
packages/desktop/app/main/services/platform/migration/MigrationService.tsCustom migration service