docs/ops/DATABASE_GUIDE.md
TL;DR: OmniRoute uses SQLite with WAL journaling as its primary store, with AES-256-GCM encryption at rest for sensitive fields. This guide covers the schema, migrations, backup/recovery, and operational runbooks.
Sources:
src/lib/db/core.ts — singleton + SCHEMA_SQL (17 base tables)src/lib/db/migrationRunner.ts — versioned migrationssrc/lib/db/migrations/ — 106 versioned SQL filessrc/lib/db/encryption.ts — encryption helperssrc/lib/db/backup.ts — backup export/importsrc/lib/db/healthCheck.ts — health diagnosticsOmniRoute chose SQLite over PostgreSQL/MySQL for several reasons:
| Factor | SQLite | PostgreSQL |
|---|---|---|
| Deployment | Embedded — no separate server | Requires server setup |
| Encryption | Application-layer (AES-256-GCM) | Built-in TDE |
| Performance | Faster for small/medium workloads | Better for huge concurrent writes |
| Concurrency | WAL mode allows concurrent reads | Full MVCC |
| Backup | Single-file copy | pg_dump or filesystem snapshot |
| Use case | Per-user install, embedded | Multi-tenant SaaS |
For single-user, single-instance deployments (the primary OmniRoute use case), SQLite is simpler and faster.
core.ts opens the database with WAL (Write-Ahead Logging) mode:
// src/lib/db/core.ts
db.pragma("journal_mode = WAL");
db.pragma("busy_timeout = 2000");
db.pragma("synchronous = NORMAL");
// Settings > System & Storage > Cache Size is applied as KiB.
db.pragma("cache_size = -16384");
WAL allows concurrent reads during writes — important for the dashboard, which queries while requests are being recorded.
The SQLite file is stored at:
| OS | Path |
|---|---|
| Linux | ~/.omniroute/storage.sqlite |
| macOS | ~/.omniroute/storage.sqlite |
| Windows | %USERPROFILE%\.omniroute\storage.sqlite |
| Docker | /app/data/storage.sqlite (configurable via DATA_DIR) |
Companion files:
storage.sqlite-wal — write-ahead logstorage.sqlite-shm — shared memory filecall_logs/ — request payload artifacts (if enabled)Override the location:
DATA_DIR=/custom/path omniroute
OmniRoute's database has 110 top-level TypeScript modules in src/lib/db/. Each domain module:
getDbInstance() from core.ts to access the DBOmniRoute has 110 top-level TypeScript files in src/lib/db/. Below is a sampling of core modules; see the directory listing for the complete list:
| Module | Tables | Responsibility |
|---|---|---|
providers.ts | provider_connections | OAuth/API key provider registration and credentials |
models.ts | key_value (model data) | Model definitions, capabilities, pricing |
combos.ts | combos | Combo routing configs and ordering |
apiKeys.ts | api_keys | API key lifecycle, scopes, quota tracking |
settings.ts | key_value, api_keys, combos | System configuration and shared KV store |
backup.ts | — | Backup export/import operations |
proxies.ts | proxy_registry, proxy_assignments, provider_connections | Proxy configs and routing rules |
prompts.ts | prompt_templates | Reusable prompt templates, versioning |
webhooks.ts | webhooks | Event-driven webhook subscriptions and logs |
detailedLogs.ts | request_detail_logs | Per-request audit logging (optional, high volume) |
domainState.ts | domain_* (5 tables) | Domain budgets, circuit breakers, lockouts, fallback chains, cost history |
registeredKeys.ts | registered_keys, account_key_limits, provider_key_limits | Whitelisted API keys for MCP/A2A |
quotaSnapshots.ts | quota_snapshots | Historical quota usage |
modelComboMappings.ts | model_combo_mappings | Map models to combo defaults |
cliToolState.ts | cli_tool_state | CLI-specific persistent state |
encryption.ts | — | Helpers for encrypting/decrypting fields |
readCache.ts | — | In-memory cache for read-heavy ops |
secrets.ts | key_value (encrypted entries) | Encrypted secret storage |
stateReset.ts | — | Wipe/reset DB state for testing |
contextHandoffs.ts | context_handoffs | Session context for agent handoff |
usage*.ts | usage_history, call_logs, proxy_logs | Usage tracking |
compression*.ts | compression_settings, compression_combos | Compression config |
A core architectural rule: modules don't access each other's tables directly. To work with another module's data, import the function from that module.
// ❌ WRONG: direct SQL from another module
db.prepare("SELECT * FROM provider_connections").all();
// ✅ RIGHT: use the providers module function
import { listProviders } from "@/lib/db/providers";
const providers = await listProviders();
This rule is enforced by code review — there's no static check, but violations are flagged.
core.ts defines the 17 base tables in SCHEMA_SQL. These are created by migration 001_initial_schema.sql and form the core schema.
| Table | Purpose | Key columns |
|---|---|---|
provider_connections | Provider credentials (encrypted) | id, provider, auth_type, api_key, is_active |
provider_nodes | Provider node routing info | id, type, name, base_url, created_at |
key_value | General KV store | namespace, key, value |
combos | Routing combo definitions | id, name, data, sort_order |
api_keys | API keys for the gateway | id, name, key, machine_id, allowed_models |
db_meta | Database metadata | key, value |
usage_history | Request usage records | id, provider, model, tokens_input, tokens_output, timestamp |
call_logs | Request payloads & responses | id, timestamp, status, model, provider, latency_ms |
proxy_logs | Proxy request logs | id, timestamp, proxy_type, status, provider |
domain_fallback_chains | Model-to-provider chains | model, chain |
domain_budgets | Per-domain spend budgets | api_key_id, daily_limit_usd, warning_threshold, reset_interval |
domain_budget_reset_logs | Budget reset history | id, api_key_id, reset_interval, previous_spend, reset_at |
domain_cost_history | Per-domain cost tracking | id, api_key_id, cost, timestamp |
domain_lockout_state | Domain rate-limit state | identifier, attempts, locked_until |
domain_circuit_breakers | Circuit breaker state per domain | name, state, failure_count, last_failure_time |
semantic_cache | LLM response cache | id, signature, model, prompt_hash, response |
quota_snapshots | Historical quota snapshots | id, provider, connection_id, window_key, remaining_percentage |
Subsequent migrations add tables such as:
cli_tool_state (migration 011) — CLI tool statemcp_* tables — MCP server audita2a_* tables — A2A task stateusage_* tables — usage trackingplugin_* tables — plugin systemskill_executions — skill execution historymemory_* tables — memory systemcompression_* tables — compression systemwebhook_* tables — webhook delivery logacp_* tables — Agent Client Protocoloneproxy_* tables — 1proxy marketplaceproxy_assignments — proxy scope bindingsdetailed_call_artifacts — call log artifacts metadataquota_alert_history — quota alert auditcommand_code_auth_sessions — Command Code OAuth sessionsThe full list of ~30+ tables is in src/lib/db/migrations/.
OmniRoute uses versioned, idempotent migrations in src/lib/db/migrations/. Each migration is a single SQL file named NNN_description.sql.
001_initial_schema.sql
002_mcp_a2a_tables.sql
003_provider_node_custom_paths.sql
...
021_combo_call_log_targets.sql
At startup, migrationRunner.ts:
_omniroute_migrations table if not exists// src/lib/db/migrationRunner.ts (simplified)
export async function runMigrations(db: SqliteDatabase, migrationsDir: string) {
const applied = getAppliedMigrations(db);
const available = readMigrationFiles(migrationsDir);
for (const migration of available) {
if (applied.includes(migration.id)) continue;
db.transaction(() => {
db.exec(migration.sql);
recordAppliedMigration(db, migration.id);
})();
}
}
Migrations must be idempotent — running them twice should be a no-op:
-- 004_proxy_registry.sql
CREATE TABLE IF NOT EXISTS proxy_registry (
id TEXT PRIMARY KEY,
host TEXT NOT NULL,
port INTEGER NOT NULL,
...
);
Use IF NOT EXISTS, IF EXISTS, and OR IGNORE / OR REPLACE clauses liberally.
ls src/lib/db/migrations/ | tail -1NNN_my_change.sqlCREATE TABLE IF NOT EXISTS, ALTER TABLE ... ADD COLUMNUPDATE ... WHERE ... to handle existing rowsExample:
-- 022_add_combo_priority.sql
ALTER TABLE combos ADD COLUMN priority INTEGER DEFAULT 100;
UPDATE combos SET priority = 100 WHERE priority IS NULL;
CREATE INDEX IF NOT EXISTS idx_combos_priority ON combos(priority);
Backwards-incompatible changes (e.g., dropping columns) are tricky. OmniRoute does NOT support downgrade — once a migration is applied, the schema change is permanent. Plan accordingly.
Sensitive fields (API keys, OAuth tokens, connection strings) are encrypted at rest using AES-256-GCM.
// src/lib/db/encryption.ts (simplified)
const key = deriveKeyFromPassphrase(passphrase, salt);
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const authTag = cipher.getAuthTag();
return { encrypted, iv, authTag };
provider_connections.api_key — encrypted at application levelprovider_connections.access_token, refresh_token, id_token — encrypted at application levelkey_value entries with namespace = "secrets" — encrypted at application levelproxy_registry.auth — encrypted at application level (if present)The encryption key is derived from a passphrase (set via STORAGE_ENCRYPTION_KEY env var) and a salt (stored in the DB). Both are required to decrypt data.
# Generate a secure passphrase
openssl rand -hex 32
# Set in .env
STORAGE_ENCRYPTION_KEY=<your-key>
Critical: Losing the encryption key means losing access to all encrypted data. Back up the key separately from the database.
For performance reasons, the following are stored in plaintext:
OmniRoute uses migrateLegacyEncryptedString() to handle two encryption schemes transparently:
The migration helper detects the legacy format and re-encrypts with the new scheme on first read. This means you can upgrade an old database without losing credentials.
For frequently-read data (models, providers, settings), readCache.ts provides an in-memory cache:
// Cached at startup, invalidated on write
const providers = await getCachedProviders(); // Fast, in-memory
const fresh = await listProviders(); // Slow, hits DB
| Cached entity | Cache key | TTL |
|---|---|---|
models | models:v1 | Until write |
provider_connections | providers:v1 | Until write |
settings | settings:v1 | Until write |
combos | combos:v1 | Until write |
Cache is invalidated on every write to the corresponding table.
# Use the CLI to create a local backup
omniroute backup create --name pre-migration
# Or via the API
curl -X PUT http://localhost:20128/api/db-backups \
-H "Authorization: Bearer $MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "pre-migration"}'
The backup file includes:
# Via CLI
omniroute restore pre-migration
# Via API
curl -X POST http://localhost:20128/api/db-backups/restore \
-H "Authorization: Bearer $MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "pre-migration"}'
Warning: Restore overwrites the entire DB. Stop all clients first.
# Enable automated daily backups via CLI
omniroute backup auto enable --cron "0 2 * * *" --retention 7
The schedule is executed server-side by a background job that ticks every 30 seconds (default) and evaluates the cron expression against local server time.
| Variable | Default | Description |
|---|---|---|
OMNIROUTE_BACKUP_SCHEDULE_JOB_INTERVAL_MS | 30000 | Tick interval in ms (min 5000). Must be shorter than 60 s to reliably land inside the matching cron minute. |
For zero-downtime backup of a live DB:
sqlite3 ~/.omniroute/storage.sqlite ".backup /backups/omniroute-hot.db"
This uses SQLite's online backup API — safe to run while OmniRoute is running.
WAL is enabled by default. For high-write workloads, consider:
PRAGMA wal_autocheckpoint = 1000; -- Checkpoint every 1000 pages
PRAGMA journal_size_limit = 67108864; -- 64MB WAL cap
Key indexes for performance (auto-created by migrations):
idx_models_provider — model lookups by provideridx_combo_targets_combo_id — combo target expansionidx_usage_history_api_key_timestamp — usage analyticsidx_quota_snapshots_api_key_window — quota trackingidx_call_logs_timestamp — call log queriesTo add a new index, create a migration:
-- 023_add_my_index.sql
CREATE INDEX IF NOT EXISTS idx_my_table_my_column ON my_table(my_column);
For very large databases (>10GB), memory mapping can be adjusted via SQLite pragma:
-- Set via SQLite pragma (adjust in core.ts or runtime)
PRAGMA mmap_size = 268435456; -- 256MB
Long-running OmniRoute instances benefit from occasional VACUUM:
sqlite3 ~/.omniroute/storage.sqlite "VACUUM;"
Run monthly during low-traffic windows. (WAL mode reduces the need, but doesn't eliminate it.)
src/lib/db/healthCheck.ts provides DB-level health diagnostics:
GET /api/db/health
Returns:
```json
{
"status": "healthy",
"checks": {
"writable": { "status": "pass" },
"integrity": { "status": "pass", "result": "ok" },
"foreign_keys": { "status": "pass", "violations": 0 },
"orphaned_artifacts": { "status": "warn", "count": 12 },
"table_sizes": {
"usage_history": { "rows": 12345, "size_mb": 12.3 },
"call_logs": { "rows": 567, "size_mb": 2.1 }
}
}
}
Run PRAGMA integrity_check to detect corruption:
sqlite3 ~/.omniroute/storage.sqlite "PRAGMA integrity_check;"
# Should print: ok
If it returns anything other than ok, stop using the database immediately and restore from backup.
The -wal file is missing but -shm and main DB are intact:
# Recovers automatically on next open
omniroute
If SQLite can't auto-recover:
sqlite3 ~/.omniroute/storage.sqlite ".recover" > recovered.sql
sqlite3 recovered.db < recovered.sql
mv recovered.db ~/.omniroute/storage.sqlite
Restore from backup:
omniroute sync pull --merge # or: omniroute backup restore <backup-id>
No recovery possible without the key. The encrypted fields are unreadable. Re-add all providers manually with new credentials.
Mitigation: Always back up the encryption key separately, ideally in a password manager or KMS.
SQLite will return SQLITE_FULL errors. Free disk space, then:
# Checkpoint WAL to free up space
sqlite3 ~/.omniroute/storage.sqlite "PRAGMA wal_checkpoint(TRUNCATE);"
sqlite3 ~/.omniroute/storage.sqlite "SELECT * FROM api_keys LIMIT 5;"
sqlite3 ~/.omniroute/storage.sqlite <<EOF
SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';
EOF
# Stop OmniRoute first
omniroute stop
# Delete the DB file
rm ~/.omniroute/storage.sqlite*
# Restart (will recreate empty DB)
omniroute
For a selective reset (keep providers, wipe usage):
DELETE FROM usage_history WHERE timestamp < datetime('now', '-30 day');
DELETE FROM call_logs WHERE timestamp < datetime('now', '-30 day');
DELETE FROM proxy_logs WHERE timestamp < datetime('now', '-30 day');
sqlite3 ~/.omniroute/storage.sqlite <<EOF
.mode csv
.output api_keys.csv
SELECT * FROM api_keys;
EOF
Another process is holding a write lock. Either:
lsof | grep storage.sqlite)A domain module is violating referential integrity. Check:
Run PRAGMA foreign_key_check; to find violations.
SQLite's memory-mapped I/O is exceeding the OS limit. Reduce via SQLite pragma:
PRAGMA mmap_size = 134217728; -- 128MB instead of 256MB
Or disable:
PRAGMA mmap_size = 0;
The migration ran in a transaction, so it should have rolled back. If not:
sqlite3To prevent this, always test migrations on a copy first.
src/lib/db/ (80+ files, ~25K LOC)