database-async-patterns
SQLite and async database patterns for VoxAgent's persistent storage. Use when writing database access code, managing connections, designing schemas, running migrations, or testing data layers. Trigge
SKILL.md.claude/.claude/skills/database-async-patterns/SKILL.md
GitHubDatabase & Async SQLite Patterns for VoxAgent
Guidelines for writing correct, performant async database code in VoxAgent. Covers connection lifecycle, query safety, schema management, and test isolation using aiosqlite.
When to Apply
Reference these guidelines when:
- Reading or writing to VoxAgent's conversation memory (
core/memory.py) - Adding new persistent storage (settings, skill history, analytics)
- Writing migrations or schema changes
- Building database fixtures for tests
- Debugging connection or locking issues
Rule Categories by Priority
| Priority | Category | Impact | Applies to |
|---|---|---|---|
| 1 | Connection Management | CRITICAL | All DB code |
| 2 | Query Safety | HIGH | All DB code |
| 3 | Schema & Migrations | HIGH | Setup/deploy |
| 4 | Performance | MEDIUM | All DB code |
| 5 | Testing | MEDIUM | tests/ |
Quick Reference
1. Connection Management (CRITICAL)
db-async-context— ALWAYS useasync withfor database connections. Never hold connections open across request boundaries:# Good: scoped connection async with aiosqlite.connect(db_path) as db: await db.execute("INSERT INTO logs VALUES (?)", (entry,)) await db.commit() # Bad: connection leaked db = await aiosqlite.connect(db_path) await db.execute(...) # connection never closed on errordb-single-writer— SQLite allows only one writer at a time. Useasyncio.Lock()to serialize writes:_write_lock = asyncio.Lock() async def save_entry(self, entry: ConversationEntry) -> None: async with self._write_lock: async with aiosqlite.connect(self._db_path) as db: await db.execute("INSERT INTO conversations ...", (...)) await db.commit()db-wal-mode— Enable WAL mode for concurrent reads during writes. Set once on first connection:async with aiosqlite.connect(db_path) as db: await db.execute("PRAGMA journal_mode=WAL") await db.execute("PRAGMA busy_timeout=5000")db-connection-factory— Centralize connection creation in a factory function with standard PRAGMAs:async def create_connection(db_path: Path) -> aiosqlite.Connection: db = await aiosqlite.connect(db_path) await db.execute("PRAGMA journal_mode=WAL") await db.execute("PRAGMA foreign_keys=ON") await db.execute("PRAGMA busy_timeout=5000") db.row_factory = aiosqlite.Row return dbdb-no-blocking— Never use synchronoussqlite3in async code. Always useaiosqliteor wrap withasyncio.to_thread().
2. Query Safety (HIGH)
db-parameterized— ALWAYS use parameterized queries. Never use f-strings or string concatenation for SQL:# Good: parameterized await db.execute("SELECT * FROM skills WHERE name = ?", (skill_name,)) # Bad: SQL injection risk await db.execute(f"SELECT * FROM skills WHERE name = '{skill_name}'")db-row-factory— Setrow_factory = aiosqlite.Rowfor dict-like access instead of tuple indexing:db.row_factory = aiosqlite.Row async with db.execute("SELECT id, name FROM skills") as cursor: async for row in cursor: print(row["name"]) # not row[1]db-fetch-bounded— Always limit result sets. Neverfetchall()without a LIMIT clause on unbounded tables:# Good: bounded await db.execute("SELECT * FROM logs ORDER BY ts DESC LIMIT ?", (max_rows,)) # Bad: unbounded — OOM risk on large tables rows = await cursor.fetchall()db-dataclass-mapping— Map rows to dataclasses/Pydantic models at the boundary, not raw dicts through the codebase:@dataclass class ConversationEntry: id: str user_input: str response: str timestamp: float def _row_to_entry(row: aiosqlite.Row) -> ConversationEntry: return ConversationEntry(**dict(row))
3. Schema & Migrations (HIGH)
db-versioned-schema— Track schema version in aschema_versiontable. Check on startup:CURRENT_SCHEMA_VERSION = 3 async def check_schema(db: aiosqlite.Connection) -> int: await db.execute("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER)") cursor = await db.execute("SELECT version FROM schema_version") row = await cursor.fetchone() return row["version"] if row else 0db-idempotent-init— All CREATE statements must useIF NOT EXISTS. Schema init must be safe to run multiple times.db-no-destructive-migration— Never DROP columns or tables in migrations. Add new columns with defaults, deprecate old ones.db-migration-transaction— Wrap each migration step in a transaction. Rollback on failure:async def migrate_v2_to_v3(db: aiosqlite.Connection) -> None: async with db.execute("BEGIN"): await db.execute("ALTER TABLE conversations ADD COLUMN model TEXT DEFAULT 'unknown'") await db.execute("UPDATE schema_version SET version = 3") await db.commit()
4. Performance (MEDIUM)
db-index-queries— Add indexes for columns used in WHERE, ORDER BY, or JOIN. Name them clearly:CREATE INDEX IF NOT EXISTS idx_conversations_timestamp ON conversations(timestamp); CREATE INDEX IF NOT EXISTS idx_skills_name ON skills(name);db-batch-inserts— Useexecutemany()for bulk inserts instead of individual INSERT loops:entries = [(e.id, e.text, e.ts) for e in batch] await db.executemany("INSERT INTO logs VALUES (?, ?, ?)", entries) await db.commit()db-read-only-connection— Use separate read-only connections for queries that don't mutate:async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as db: cursor = await db.execute("SELECT * FROM conversations LIMIT 100")db-vacuum-schedule— Schedule periodic VACUUM for databases with frequent deletes. Never VACUUM during active usage.
5. Testing (MEDIUM)
db-test-in-memory— Use:memory:databases for unit tests. Fast and isolated:@pytest.fixture async def memory(): mem = Memory(db_path=":memory:") await mem.initialize() return memdb-test-fixture-data— Use factory functions for test data, not raw SQL inserts:def make_conversation(user_input: str = "test", response: str = "ok") -> ConversationEntry: return ConversationEntry( id=str(uuid4()), user_input=user_input, response=response, timestamp=time.time(), )db-test-isolation— Each test gets its own database instance. Never share state between tests.db-test-migration— Test migrations by creating a database at version N, migrating to N+1, and verifying data integrity.
Operational Procedure
Before coding
- Check if
core/memory.pyalready handles the storage need - Identify if the operation is a read or write (affects locking strategy)
- Determine if a new table or migration is needed
During coding
- Use the connection factory for all new connections
- Apply parameterized queries everywhere — no exceptions
- Set
row_factoryfor readable code - Use
asyncio.Lock()for write operations
After coding
- Verify no raw
sqlite3calls exist in async code paths - Check that all connections use
async with(no leaks) - Run tests with
:memory:database - Verify schema migrations are idempotent