Loom uses SQLite with Write-Ahead Logging (WAL) for thread persistence, authentication, analytics, and feature flags. The database is designed for multi-reader, single-writer workloads.
Database migrations run automatically on server startup. You do not need to run migrations manually.
-- Copyright (c) 2025 Geoffrey Huntley <[email protected]>. All rights reserved.-- SPDX-License-Identifier: Proprietary-- Add my_feature tableCREATE TABLE IF NOT EXISTS my_feature ( id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL);CREATE INDEX IF NOT EXISTS idx_my_feature_name ON my_feature(name);
Always use IF NOT EXISTS and IF NOT EXISTS for idempotency. Migrations may be run multiple times.
4
Add the migration to db/mod.rs
crates/loom-server/src/db/mod.rs
let m41 = include_str!("../../migrations/041_my_feature.sql");for stmt in m41.split(';').filter(|s| !s.trim().is_empty()) { if let Err(e) = sqlx::query(stmt).execute(pool).await { let msg = e.to_string(); if !msg.contains("already exists") && !msg.contains("duplicate column") { return Err(e.into()); } }}
5
Test the migration
# Delete test databaserm -f /tmp/loom-test.db# Run server (migrations run on startup)cargo run -p loom-server -- \ LOOM_SERVER_DATABASE_URL=sqlite:/tmp/loom-test.db \ LOOM_SERVER_ANTHROPIC_API_KEY=sk-test# Verify tables existsqlite3 /tmp/loom-test.db ".tables"
CREATE TABLE IF NOT EXISTS scm_repos ( id TEXT PRIMARY KEY NOT NULL, org_id TEXT NOT NULL, provider TEXT NOT NULL, clone_url TEXT NOT NULL, local_path TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_fetched_at TEXT, FOREIGN KEY (org_id) REFERENCES orgs(id));CREATE INDEX IF NOT EXISTS idx_scm_repos_org_id ON scm_repos(org_id);CREATE INDEX IF NOT EXISTS idx_scm_repos_provider ON scm_repos(provider);
CREATE VIRTUAL TABLE IF NOT EXISTS thread_fts USING fts5( thread_id UNINDEXED, title, content, tokenize = 'porter unicode61');CREATE TRIGGER IF NOT EXISTS thread_fts_insertAFTER INSERT ON threadsBEGIN INSERT INTO thread_fts(thread_id, title, content) VALUES (NEW.id, NEW.title, json_extract(NEW.full_json, '$.conversation'));END;CREATE TRIGGER IF NOT EXISTS thread_fts_updateAFTER UPDATE ON threadsBEGIN DELETE FROM thread_fts WHERE thread_id = OLD.id; INSERT INTO thread_fts(thread_id, title, content) VALUES (NEW.id, NEW.title, json_extract(NEW.full_json, '$.conversation'));END;CREATE TRIGGER IF NOT EXISTS thread_fts_deleteAFTER DELETE ON threadsBEGIN DELETE FROM thread_fts WHERE thread_id = OLD.id;END;
CREATE TABLE IF NOT EXISTS flags ( id TEXT PRIMARY KEY NOT NULL, key TEXT NOT NULL UNIQUE, name TEXT NOT NULL, description TEXT, -- JSON column for conditions conditions JSON, -- JSON column for variants variants JSON NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);-- Query JSON columnsSELECT key, json_extract(variants, '$.control.value') as control_valueFROM flagsWHERE json_extract(conditions, '$.enabled') = 1;
-- Add username column (nullable first, then backfill)ALTER TABLE users ADD COLUMN username TEXT;-- Create unique indexCREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username) WHERE username IS NOT NULL;
-- Show all tablesSELECT name FROM sqlite_master WHERE type='table';-- Show all indexesSELECT name, tbl_name FROM sqlite_master WHERE type='index';-- Show FTS tablesSELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%_fts';-- Show triggersSELECT name, tbl_name FROM sqlite_master WHERE type='trigger';