QMD Migration: better-sqlite3 + Turso → libSQL Self-hosted¶
Timeline¶
- Date: 2026-06-16 → 2026-06-17
- Scope: QMD database layer, cloud sync, deployment configs
Infrastructure Lessons¶
Know your environment FIRST¶
- Check
hostname+tailscale ipbefore assuming which server is which. Development server (100.126.172.96) IS the local machine where the devcontainer runs. - Don't SSH to yourself.
ssh root@100.126.172.96fails (can't SSH to own Tailscale IP). If SSH fails on a known Tailscale IP, check if it's local. - Use
km-exec -s <server> --hostwhen SSH fails. Komodo can execute commands on any server via the periphery API without SSH.
Server topology (verified)¶
- Physical host: Lenovo ThinkCentre M910X running ESXi
- Development VM (
100.126.172.96): Debian 13, local ext4/mnt/user/appdata/(391GB LVM) - Unraid VM (
100.68.251.84): Ubuntu, unraid array/mnt/user/appdata/ - Both peripheries are Docker containers (
komodo-periphery:2.2), NOT systemd services - Both use local filesystem, NOT NFS. Appdata paths are identical but independent.
- Komodo Core runs as Docker container on development server
Don't guess — verify¶
- Wrong: "development uses NFS" — assumed because
/mnt/user/appdatapath matches unraid convention - Right:
km-exec -s development --host "df -hT /mnt/user/appdata"→ext4(local) - Wrong: "development periphery is systemd" — assumed because SSH failed
- Right:
km list servers+ Komodo API shows both are Docker containers
Phase 1: Initial Setup (Merge-based Cloud Sync)¶
Changes¶
- Rewrote
src/cloud/push.tswith merge-based push strategy for multi-instance Turso sync - Added
test/cloud-push-merge.test.ts— 9 test cases - Collection-aware tables: DELETE WHERE collection IN (local) + INSERT OR REPLACE
- Hash-keyed tables: INSERT OR IGNORE (shared across instances)
- Documents.id excluded from INSERT (autoincrement)
Lessons¶
- Merge push strategy: Different table types need different merge strategies. Collection-aware (documents, store_collections) need scoped DELETE. Hash-keyed (content, content_vectors, llm_cache) are shared — never DELETE, only INSERT OR IGNORE.
- Test data matters: Test DB must include
store_collectionsrows, otherwisegetLocalCollectionNames()returns empty and DELETE path is skipped silently. - FTS5 rowid divergence: Multi-instance autoincrement causes FTS rowids to not match document IDs. FTS rebuild is needed after cross-instance sync.
Phase 2: Cross-Instance Collection Visibility¶
Changes¶
collections.ts: AddedsetDbCollections()+dbExtraCollectionsfallback ingetCollection()andlistCollections()store.ts:syncConfigToDb()preserves cloud-synced collections (only deletes local-owned)cli/qmd.ts:getStore()injects DB collections viasetDbCollections()on startup- Contexts always sourced from DB table, not YAML config
Lessons¶
- Two createStore functions: QMD has TWO
createStore— one instore.ts(sync, CLI) and one inindex.ts(async, SDK). CLI uses the sync version. Changes to DB init must go in BOTH. - Module state injection:
collections.tscan't importstore.ts(circular dependency). Use setter injection pattern:setDbCollections()called by CLI before any command. - Context DB-source: YAML config's
global_contextoverwrites DB value on everysyncConfigToDb(). Remove context from sync to preserve cloud-synced contexts.
Phase 3: libSQL Driver Migration (better-sqlite3 → libsql)¶
Changes¶
src/db.ts: Replacedbetter-sqlite3withlibsql(sync API, drop-in compatible)- Native vector support:
FLOAT32(N)+libsql_vector_idx()replacesvec0extension - Vector search:
vector_top_k('idx', ?, N)+vector_distance_cos()replacesMATCH ? AND k=N - Vector insert:
INSERT OR REPLACEreplaces DELETE+INSERT (vec0 didn't support OR REPLACE) - Migration script:
scripts/migrate-vectors.mjs(vec0 → FLOAT32, 9521 vectors) src/cloud/push.ts,src/cloud/pull.ts,src/cloud/auto-sync.tsdeleted (replaced by embedded replica / direct remote)
Lessons¶
- libsql sync API is identical to better-sqlite3:
prepare().get()/.all()/.run(),exec(),transaction()— zero changes needed in 165 DB call sites. - vector_top_k doesn't return distance: Unlike vec0's
MATCH ... k=N, libSQL'svector_top_k()returns onlyid. Usevector_distance_cos()in JOIN to get distance. - Float32Array accepted directly: libsql accepts
Float32Arrayfor FLOAT32 columns. No need forvector32('[...]')string conversion. BUT only via the nativelibsqlpackage —@libsql/clientHTTP API requires string format. - libsql package vs @libsql/client:
libsql(npm) = sync API, better-sqlite3-compatible, native binary.@libsql/client= async HTTP client. Uselibsqlfor local/embedded,@libsql/clientfor remote HTTP queries. - ca-certificates required: libsql TLS connections need
ca-certificatesinstalled in Docker image. Alpine/slim images may not have it.
Phase 4: Embedded Replica → Self-hosted Direct Remote¶
Changes¶
- Embedded replica mode attempted first:
new Database(path, {syncUrl, authToken, syncPeriod}) - Abandoned due to: Turso free tier 403 (read quota exceeded), WalConflict on local writes, FTS rowid mismatch after sync, corrupted DB on failed syncs
- Final approach: direct remote connection
new Database(url, {authToken}) - Connection auto-reconnect wrapper in
db.ts(STREAM_EXPIRED after 30s inactivity)
Lessons¶
- Turso free tier is very limited: Read operations blocked after quota. Embedded replica syncs every 60s × 2 instances = rapid read consumption. Use self-hosted for unlimited.
- Embedded replica ≠ magic: Multi-writer conflicts (WalConflict), FTS rowid divergence, DB corruption on failed syncs. Not production-ready for QMD's use case.
- Self-hosted libSQL stream expiry: HTTP connections expire after ~30s inactivity. Wrap
prepare()/exec()with retry-on-error pattern. - DROP TABLE + CREATE on replica causes WalConflict: Schema DDL on replica DB conflicts with background sync. Skip
initializeDatabase()for replicas.
Phase 5: FTS Auto-Sync via SQL Triggers¶
Changes¶
- Created
documents_fts_ai/ad/autriggers on libSQL server DB file - INSERT on documents → auto-insert FTS row
- DELETE on documents → auto-delete FTS row
- UPDATE on documents → auto-replace FTS row
- Removed all manual FTS rebuild code from QMD startup
Lessons¶
- FTS rebuild over HTTP is impractical: Self-hosted libSQL HTTP API has response size limits and connection timeouts.
INSERT...SELECTfor 671 docs crashes the server. - Direct file access for heavy operations: Stop libsql container → run sqlite3 via Python container on the data file → restart. This is the only reliable way for heavy DB operations.
- SQL triggers are the right solution: One-time trigger creation = FTS stays in sync forever. No rebuild needed. Zero runtime overhead.
Phase 6: Deployment¶
Changes¶
- Docker compose:
LIBSQL_URL=http://100.68.251.84:8091,LIBSQL_AUTHfrom Komodo variable - Dockerfile: added
ca-certificatesfor TLS file_pathsin Komodo TOML: only["compose.yaml"](non-compose files mounted via volumes, not-foverrides)- Volume mounts: absolute host paths (
/mnt/user/appdata/qmd/...) +pre_deploycopy scripts
Lessons¶
- Komodo file_paths = docker compose -f flags: Don't put non-compose files in
file_paths. They get passed as-foverrides, causing "models.generate must be a mapping" errors. - Docker-in-Docker volume paths: Komodo periphery runs compose inside its container. Relative paths (
./config/index.yml) resolve to periphery container paths, not host paths. Use absolute host paths + pre_deploy copy. - Resource busy error: Komodo stack state can get stuck as "deploying". Restart periphery container to clear.
Current Architecture¶
Nanobot → LiteLLM MCP (4001) → QMD MCP (8183) → libSQL (8091)
↓
data.db (direct file)
FTS triggers (auto-sync)
- Local QMD: stdio MCP, connects to same libSQL server
- Remote QMD: HTTP MCP, connects to same libSQL server
- libSQL server: self-hosted on unraid, 969 docs, 12367 vectors, FTS auto-synced
- No cloud sync needed: both instances use same DB server directly
Key Files Modified¶
src/db.ts— libsql driver, remote connection wrapper, ReplicaOptionssrc/store.ts— vector schema (FLOAT32), remote mode init, FTS rebuild functionsrc/cli/qmd.ts— LIBSQL_URL env var wiring, DB fallback collectionssrc/collections.ts— setDbCollections/setDbGlobalContext for cross-instancesrc/index.ts— replica options in createStoresrc/mcp/server.ts— removed auto-sync startupscripts/migrate-vectors.mjs— one-time vec0 → FLOAT32 migrationDockerfile— ca-certificatesstacks/llm/qmd/compose.yaml— LIBSQL_URL, volume mountskomodo/stacks/llm/qmd.toml— file_paths, pre_deploy
Commits¶
a564554— merge-based cloud push7b2a958— cross-instance collection visibilityda33fd7— context from DB table9bbb536— libsql driver + vector migration + embedded replica64eed35— remove cloud push/pull codefe420e6— self-hosted libSQL direct remote52974a4— auto-reconnect on stream expiry1c3d82c— remove FTS auto-rebuild (triggers handle it)