---
tags: [sqlalchemy, async, database, models, queries, savepoint, pgvector]
modules: [src/arandu/write/, src/arandu/read/, src/arandu/background/]
applies_to: [models, services, handlers]
confidence: inferred
---
# Pattern: Async SQLAlchemy Data Access

<!-- vibeflow:auto:start -->
## What
All database operations use async SQLAlchemy with savepoint isolation. Models use `Mapped` type annotations. Vector similarity uses raw SQL with pgvector operators.

## Where
- `src/arandu/db.py` — Engine, session factory, Base class
- `src/arandu/models.py` — 10 ORM models
- All pipeline modules (write/read/background) that query the database

## The Pattern
1. **Session management**: `MemoryClient` creates `async_sessionmaker`. Pipeline functions receive `AsyncSession` as parameter.
2. **Transaction boundary**: `MemoryClient.write()` wraps pipeline in `async with self._session_factory() as session:` and calls `session.commit()`.
3. **Savepoint isolation**: Individual operations use `async with session.begin_nested():` to isolate failures.
4. **Model style**: SQLAlchemy 2.0 declarative with `Mapped[T]` and `mapped_column()`.
5. **Vector queries**: Raw SQL via `text()` with pgvector `<=>` operator for cosine distance.
6. **Query patterns**: `select(Model).where(...).order_by(...).limit(N)` with `result.scalars().all()`.

## Rules
- Pipeline functions do NOT commit — the caller manages the transaction.
- Use `session.begin_nested()` (savepoint) for operations that might fail independently.
- Use `session.flush()` after `session.add()` when you need the ID immediately.
- Use `defer(Model.embedding)` to exclude large columns from queries when not needed.
- Vector operations use raw `text()` SQL because SQLAlchemy ORM doesn't natively support pgvector operators.
- `user_id` is `Integer` in models but passed as `str` in pipeline functions (cast happens implicitly).

## Examples from this codebase
File: src/arandu/write/reconcile.py
```python
async def _fetch_entity_facts(session, user_id, entity_key):
    stmt = (
        select(MemoryFact)
        .where(
            MemoryFact.user_id == user_id,
            MemoryFact.entity_key == entity_key,
            MemoryFact.valid_to.is_(None),
        )
        .order_by(MemoryFact.valid_from.desc())
        .limit(50)
    )
    result = await session.execute(stmt)
    return list(result.scalars().all())
```

File: src/arandu/write/reconcile.py (pgvector query)
```python
query = text("""
    SELECT id,
           1 - (embedding_vec <=> CAST(:embedding AS vector)) as similarity
    FROM memory_facts
    WHERE user_id = :user_id AND entity_key = :entity_key
      AND valid_to IS NULL AND embedding_vec IS NOT NULL
    ORDER BY embedding_vec <=> CAST(:embedding AS vector)
    LIMIT 3
""")
async with session.begin_nested():
    result = await session.execute(query, {"user_id": ..., "embedding": str(embedding)})
```

File: src/arandu/write/upsert.py (savepoint per operation)
```python
for decision in decisions:
    try:
        if decision.action == "ADD":
            async with session.begin_nested():
                await _add_fact(session, ...)
            result.added += 1
    except Exception as e:
        logger.warning("execute_upsert: failed to execute %s: %s", decision.action, e)
```
<!-- vibeflow:auto:end -->

## Querying MemoryFact — use FactQuery, never raw `select`

**Architectural invariant (2026-04-09).** All read queries against `MemoryFact` in `src/arandu/` MUST go through the `FactQuery` builder in `src/arandu/_fact_query.py`. There is no `select(MemoryFact).where(...)` pattern allowed outside the builder — any such line would cause `tests/test_fact_query_invariant.py` to fail.

### Why

Between March and April 2026 the SDK shipped two production bugs with the same root cause: query sites constructing `select(MemoryFact).order_by(...)` manually without stable tiebreakers. A survey found 26 such sites spread across 7 files, 25 of them lacking `ORDER BY id` as tiebreaker. The structural fix was `FactQuery` — a fluent builder where:

1. The base filter (`agent_id == X AND valid_to IS NULL`) lives in exactly one place (the `active()` factory).
2. Every ordering method (`order_by_recency`, `order_by_created`, `order_by_confidence`, `order_by_importance`, `order_by_similarity`) embeds `MemoryFact.id` as the final tiebreaker **by construction** — there is no raw `order_by()` method on `FactQuery`, so it is impossible to build an unstable query through this API.
3. Adding new filters (entity, cluster, temporal window, text match) is a matter of extending the builder once, not patching 14 call sites.

### How to use it

```python
from arandu._fact_query import FactQuery

# Fetch active facts for an entity, most recent first
stmt = (
    FactQuery.active(agent_id)
    .for_entity("person:pedro")
    .with_min_confidence(config.min_confidence)
    .with_deferred_embedding()
    .order_by_recency()
    .limit(20)
    .build()
)
result = await session.execute(stmt)
facts = list(result.scalars().all())

# Semantic search — ``build()`` returns ``Select[tuple[MemoryFact, float]]``
stmt = (
    FactQuery.active(agent_id)
    .with_embedding()
    .with_deferred_embedding()
    .order_by_similarity(query_embedding)
    .limit(pool_size)
    .build()
)
result = await session.execute(stmt)
for fact, similarity in result.all():
    ...

# Multi-entity filter with OR logic (entity_keys resolve automatically in
# the upstream ``_resolve_entity_keys`` — pass the resolved list here)
stmt = (
    FactQuery.active(agent_id)
    .for_entities(resolved_keys)
    .order_by_recency()
    .limit(10)
    .build()
)
```

### Exceptions — whitelisted in the invariant test

Two categories of queries legitimately bypass the builder, listed explicitly in `tests/test_fact_query_invariant.py::WHITELIST`:

1. **Column projections** (`select(MemoryFact.entity_key).distinct()`): the builder is focused on full-row fetches. Projections that return only specific columns are small, stable, and not ordering-sensitive.
2. **Point-lookups by UUID** (`select(MemoryFact).where(id == known_uuid)`): these always return 0 or 1 rows. Ordering is trivial, tiebreaker is meaningless.

If a new query outside these two categories is needed, **add a method to `FactQuery`**, don't add a new exception. The whitelist is for existing stable patterns, not a backdoor.

### Related

- Full migration plan: `.vibeflow/prds/fact-query-abstraction.md`
- Source code: `src/arandu/_fact_query.py`
- Architectural regression test: `tests/test_fact_query_invariant.py`
- Decision log: `.vibeflow/decisions.md` (2026-04-09 — "FactQuery as single source of read queries for MemoryFact")
