* fix: restore libSQL vector search with dynamic embedding dimensions (#655) The V9 migration dropped the libsql_vector_idx and changed memory_chunks.embedding from F32_BLOB(1536) to BLOB, but the documented brute-force cosine fallback was never implemented. hybrid_search silently returned empty vector results — search was FTS5-only on libSQL. Add ensure_vector_index() which dynamically creates the vector index with the correct F32_BLOB(N) dimension, inferred from EMBEDDING_DIMENSION / EMBEDDING_MODEL env vars during run_migrations(). Uses _migrations version=0 as a metadata row to track the current dimension (no-op if unchanged, rebuilds table on dimension change). Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: move safety comments above multi-line assertions for rustfmt stability Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor: remove unnecessary safety comments from test code Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review comments from PR #1393 [skip-regression-check] - Share model→dimension mapping via config::embeddings::default_dimension_for_model() instead of duplicating the match table (zmanian, Copilot) - Add dimension bounds check (1..=65536) to prevent overflow (zmanian, Copilot) - DROP stale memory_chunks_new before CREATE to handle crashed previous attempts (zmanian, Copilot) - Use plain INSERT instead of INSERT OR IGNORE to surface constraint errors (Copilot) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: add missing builder field to AgentDeps in telegram routing test [skip-regression-check] The self-repair builder field was added to AgentDeps in #712 but this test was not updated. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address zmanian's second review on PR #1393 - Add tracing::info when resolve_embedding_dimension returns None (#2) - Document connection scoping for transaction safety (#1) - Document _rowid preservation for FTS5 consistency (#4) - Document precondition that migrations must run first (#5) - Note F32_BLOB dimension enforcement in insert_chunk (#3) - Add unit tests for resolve_embedding_dimension (#6) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
9.3 KiB
Database Module
Dual-backend persistence layer. All new persistence features must support both backends.
Quick Reference
# Default build (PostgreSQL)
cargo build
# libSQL/Turso build
cargo build --no-default-features --features libsql
# Both backends
cargo build --features "postgres,libsql"
# Test each backend in isolation
cargo check # postgres (default)
cargo check --no-default-features --features libsql # libsql only
cargo check --all-features # both
Files
| File | Role |
|---|---|
mod.rs |
Database supertrait + 7 sub-traits (~78 async methods total) — add new ops here first |
postgres.rs |
PostgreSQL backend — delegates to Store + Repository in history/ |
libsql/mod.rs |
libSQL/Turso backend struct, connection helpers, row parsing utilities |
libsql/conversations.rs |
ConversationStore impl |
libsql/jobs.rs |
JobStore impl |
libsql/sandbox.rs |
SandboxStore impl |
libsql/routines.rs |
RoutineStore impl |
libsql/settings.rs |
SettingsStore impl |
libsql/tool_failures.rs |
ToolFailureStore impl |
libsql/workspace.rs |
WorkspaceStore impl (FTS5 + vector search) |
libsql_migrations.rs |
Consolidated libSQL schema (CREATE IF NOT EXISTS, no ALTER TABLE) |
tls.rs |
TLS connector factory for PostgreSQL (rustls + system root certs) |
PostgreSQL schema: migrations/V1__initial.sql through V9__flexible_embedding_dimension.sql (managed by refinery). V1 is the base schema; later migrations add tables, columns, and rename claude_code_events → job_events.
Trait Structure
The Database supertrait is composed of seven sub-traits. Leaf consumers can depend on the narrowest sub-trait they need rather than the full Database:
| Sub-trait | Methods | Covers |
|---|---|---|
ConversationStore |
12 | Conversations, messages |
JobStore |
13 | Agent jobs, actions, LLM calls, estimation |
SandboxStore |
13 | Sandbox jobs, job events |
RoutineStore |
15 | Routines, routine runs |
ToolFailureStore |
4 | Self-repair tracking |
SettingsStore |
8 | Per-user key-value settings |
WorkspaceStore |
13 | Memory documents, chunks, hybrid search |
Database adds run_migrations() and combines all sub-traits.
Adding a New Persistence Operation
- Decide which sub-trait the method belongs to, or create a new sub-trait
- Add the async method signature to that sub-trait in
mod.rs - Implement in
postgres.rs(delegate toStoreorRepository) - Implement in
libsql/<module>.rs(SQLite-dialect SQL, useself.connect().await?per operation) - Add migration if needed:
- PostgreSQL: new
migrations/VN__description.sql - libSQL: add
CREATE TABLE IF NOT EXISTStolibsql_migrations.rs
- PostgreSQL: new
SQL Dialect Differences
| Feature | PostgreSQL | libSQL |
|---|---|---|
| UUIDs | UUID type |
TEXT |
| Timestamps | TIMESTAMPTZ |
TEXT (ISO-8601 RFC 3339 with ms precision) |
| JSON | JSONB |
TEXT |
| Numeric/Decimal | NUMERIC |
TEXT (preserves rust_decimal precision) |
| Arrays | TEXT[] |
TEXT (JSON-encoded array) |
| Booleans | BOOLEAN |
INTEGER (0/1) |
| Vector embeddings | VECTOR (any dim, V9 removed fixed 1536) |
F32_BLOB(N) via libsql_vector_idx (dimension set dynamically by ensure_vector_index) |
| Full-text search | tsvector + ts_rank_cd |
FTS5 virtual table + sync triggers |
| JSON path update | jsonb_set(col, '{key}', val) |
json_patch(col, '{"key": val}') |
| PL/pgSQL | Functions | Triggers (no stored procs in SQLite) |
| Connection model | deadpool-postgres connection pool |
New connection per operation (self.connect()) |
| Concurrency | Pool-based, fully concurrent | WAL mode + 5 s busy timeout; write serialized |
| Auto-timestamp | DEFAULT NOW() |
DEFAULT (datetime('now')) |
| Timestamp parsing | Native type | Multi-format fallback in parse_timestamp() |
JSON merge patch gotcha: libSQL uses RFC 7396 JSON Merge Patch (json_patch) for metadata updates. This replaces top-level keys entirely — it cannot do partial nested updates. PostgreSQL uses jsonb_set which is path-targeted. Don't rely on partial nested metadata updates if you need libSQL compat.
Boolean storage: libSQL stores booleans as integers. When reading, use get_i64(row, idx) != 0; when writing, pass 1i64/0i64. Never pass a Rust bool directly.
Timestamp write format: Always write timestamps with fmt_ts(dt) (RFC 3339, millisecond precision). Read with get_ts() / get_opt_ts() which handle legacy naive formats too.
Vector dimension: PostgreSQL V9 migration changed the column to unbounded vector (removing the HNSW index). libSQL dynamically creates F32_BLOB(N) with the correct dimension via ensure_vector_index() during run_migrations(), reading EMBEDDING_DIMENSION / EMBEDDING_MODEL from env vars.
Connection per operation: LibSqlBackend::connect() creates a fresh connection for every operation, sets PRAGMA busy_timeout = 5000, and closes it when the Connection is dropped. This is intentional — the libSQL SDK does not offer a pool. Avoid holding connections open across await points.
Schema: Key Tables
Core:
conversations— multi-channel conversation trackingconversation_messages— individual messages within a conversationagent_jobs— job metadata and statusjob_actions— event-sourced tool executionsjob_events— sandbox job streaming events (renamed fromclaude_code_eventsin V7)dynamic_tools— agent-built toolsllm_calls— cost/token trackingestimation_snapshots— learning datarepair_attempts— self-repair action log (not exposed via Database trait yet)
Workspace/Memory:
memory_documents— flexible path-based filesmemory_chunks— chunked content with FTS + vector indexesmemory_chunks_fts— FTS5 virtual table (libSQL) /tsvectorcolumn (PostgreSQL)heartbeat_state— periodic execution tracking
Security/Extensions:
secrets— AES-256-GCM encrypted credentialswasm_tools— installed WASM tool binariestool_capabilities— per-tool HTTP allowlist, secret access, rate limitsleak_detection_patterns— secret regex patterns (seed data in both backends)leak_detection_events— audit log of detected leakssecret_usage_log— per-request credential injection audit trailtool_rate_limit_state— sliding window rate limit counters
Other:
routines,routine_runs— scheduled/reactive executionsettings— per-user key-valuetool_failures— broken tool tracking for self-repair_migrations— libSQL-only internal migration version tracking
libSQL Current Limitations
- Secrets store — still requires
PostgresSecretsStore;LibSqlSecretsStoreexists but is not plumbed through the main startup path - Settings reload —
Config::from_dbskipped (requiresStore) - No incremental migrations — schema is idempotent CREATE IF NOT EXISTS; no ALTER TABLE support; column additions require a new versioned approach
- No encryption at rest — only secrets (API tokens) are AES-256-GCM encrypted; all other data is plaintext SQLite
- Hybrid search — both FTS5 and vector search (
libsql_vector_idx) are implemented;ensure_vector_index()dynamically creates the index with the correctF32_BLOB(N)dimension from env vars duringrun_migrations() - Write serialization — WAL mode allows concurrent readers but only one writer at a time; busy timeout is 5 s, which may cause timeouts under high write concurrency
Running Locally with libSQL
# Use local SQLite file (default)
DATABASE_BACKEND=libsql LIBSQL_PATH=~/.ironclaw/test.db cargo run
# Use Turso cloud (embedded replica syncs local file to cloud)
DATABASE_BACKEND=libsql LIBSQL_URL=libsql://xxx.turso.io LIBSQL_AUTH_TOKEN=xxx cargo run
# In-memory (tests only — data is lost when the process exits)
# Use LibSqlBackend::new_memory() directly in test code
Testing the libSQL Backend
Use LibSqlBackend::new_memory() in unit tests — no files, no cleanup required:
#[tokio::test]
async fn test_my_feature() {
let backend = LibSqlBackend::new_memory().await.unwrap();
backend.run_migrations().await.unwrap();
// backend implements Database — call any trait method
}
For concurrency tests that require multiple connections sharing state, use LibSqlBackend::new_local(&tmp_path) with a tempfile::tempdir(). In-memory databases do not share state between connections.
Sharing the libSQL Database Handle
LibSqlBackend::shared_db() returns an Arc<LibSqlDatabase> for passing to satellite stores (e.g., LibSqlSecretsStore, LibSqlWasmToolStore) that need their own connections per-operation but should share the same underlying database file. These stores call .connect() on the shared handle themselves. This is the correct pattern — do not pass a live Connection to satellite stores.
Pattern: Fix the Pattern, Not the Instance
When fixing a bug in one backend's SQL, always grep for the same pattern in the other backend. A fix to postgres.rs that doesn't also fix the libSQL module (e.g., libsql/jobs.rs) is half a fix. The same applies to satellite types like LibSqlSecretsStore or LibSqlWasmToolStore.