crtx-store 0.1.1

SQLite persistence: migrations, repositories, transactions.
Documentation
//! Integration tests for Phase 4.C foundation: migration `007_embeddings`
//! plus the `EmbeddingRepo` CRUD surface.

use chrono::{TimeZone, Utc};
use cortex_core::MemoryId;
use cortex_store::migrate::apply_pending;
use cortex_store::repo::{EmbedRecord, EmbeddingRepo, EMBEDDING_ENCRYPTION_KIND_NONE};
use cortex_store::Pool;
use rusqlite::{params, Connection};

fn test_pool() -> Pool {
    let pool = Connection::open_in_memory().expect("open in-memory sqlite");
    apply_pending(&pool).expect("apply migrations");
    pool
}

fn at(second: u32) -> chrono::DateTime<Utc> {
    Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, second).unwrap()
}

fn mem(id: &str) -> MemoryId {
    id.parse().expect("valid memory id")
}

fn record(id: &str, backend: &str, vector: Vec<f32>, second: u32) -> EmbedRecord {
    EmbedRecord::new(mem(id), backend, vector, at(second)).expect("valid record")
}

/// Seed a memory row directly through SQL so the embedding side table's
/// `FOREIGN KEY (memory_id) REFERENCES memories(id)` is satisfied.
///
/// The integration tests deliberately bypass `MemoryRepo::insert_candidate`
/// (which requires policy contributions) because the embedding repo is
/// derived state and the test scope is the storage substrate, not the
/// policy lattice that gates the parent memory.
fn seed_memory(pool: &Pool, id: &str) {
    pool.execute(
        "INSERT OR IGNORE INTO memories (
             id, memory_type, status, claim, source_episodes_json, source_events_json,
             domains_json, salience_json, confidence, authority, applies_when_json,
             does_not_apply_when_json, created_at, updated_at
         ) VALUES (?1, ?2, 'candidate', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13);",
        params![
            id,
            "semantic",
            "test claim",
            "[]",
            r#"["evt_01ARZ3NDEKTSV4RRFFQ69G5FAV"]"#,
            r#"["store"]"#,
            r#"{"score":0.5}"#,
            0.8,
            "candidate",
            "{}",
            "{}",
            at(0).to_rfc3339(),
            at(0).to_rfc3339(),
        ],
    )
    .expect("seed parent memory row");
}

#[test]
fn migration_007_creates_memory_embeddings_table() {
    let pool = test_pool();

    let names: Vec<String> = pool
        .prepare("SELECT name FROM _migrations WHERE name = ?1;")
        .unwrap()
        .query_map(["007_embeddings"], |row| row.get(0))
        .unwrap()
        .collect::<Result<_, _>>()
        .unwrap();
    assert_eq!(names, vec!["007_embeddings".to_string()]);

    // Table exists and has the expected columns.
    let columns: Vec<String> = pool
        .prepare("PRAGMA table_info(memory_embeddings);")
        .unwrap()
        .query_map([], |row| row.get::<_, String>(1))
        .unwrap()
        .collect::<Result<_, _>>()
        .unwrap();
    assert_eq!(
        columns,
        vec![
            "memory_id",
            "backend_id",
            "dim",
            "vector_blob",
            "encryption_kind",
            "encryption_key_id",
            "computed_at",
        ]
    );

    // Backend index exists.
    let indexes: Vec<String> = pool
        .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'memory_embeddings' ORDER BY name;")
        .unwrap()
        .query_map([], |row| row.get(0))
        .unwrap()
        .collect::<Result<_, _>>()
        .unwrap();
    assert!(
        indexes.iter().any(|i| i == "idx_memory_embeddings_backend"),
        "expected backend index, got {indexes:?}"
    );
}

#[test]
fn migration_007_is_idempotent() {
    // First open + apply uses test_pool, which already invoked apply_pending.
    let pool = test_pool();

    // Re-applying migrations on the same connection returns 0 newly applied.
    let applied = apply_pending(&pool).expect("re-apply migrations");
    assert_eq!(
        applied, 0,
        "migrations must be idempotent after first apply"
    );

    // The migration record is still present exactly once.
    let count: i64 = pool
        .query_row(
            "SELECT COUNT(*) FROM _migrations WHERE name = '007_embeddings';",
            [],
            |row| row.get(0),
        )
        .expect("count migration rows");
    assert_eq!(count, 1);
}

#[test]
fn embedding_repo_write_read_round_trip() {
    let pool = test_pool();
    seed_memory(&pool, "mem_01ARZ3NDEKTSV4RRFFQ69G5FAV");
    let repo = EmbeddingRepo::new(&pool);
    let rec = record(
        "mem_01ARZ3NDEKTSV4RRFFQ69G5FAV",
        "stub:v1",
        vec![0.1, -0.2, 0.3, 0.4, -0.5],
        7,
    );

    repo.write(&rec).expect("write embedding");

    let fetched = repo
        .read(&rec.memory_id, &rec.backend_id)
        .expect("read embedding")
        .expect("row present after write");

    assert_eq!(fetched, rec);

    // encryption_kind defaults to 'none'.
    let encryption_kind: String = pool
        .query_row(
            "SELECT encryption_kind FROM memory_embeddings WHERE memory_id = ?1 AND backend_id = ?2;",
            [rec.memory_id.to_string(), rec.backend_id.clone()],
            |row| row.get(0),
        )
        .expect("read encryption_kind");
    assert_eq!(encryption_kind, EMBEDDING_ENCRYPTION_KIND_NONE);

    // Reading an absent (memory, backend) pair returns Ok(None).
    let absent = repo
        .read(&mem("mem_01ARZ3NDEKTSV4RRFFQ69G5FAW"), "stub:v1")
        .expect("read absent");
    assert!(absent.is_none());

    // Re-writing the same (memory, backend) replaces the row (upsert
    // semantics keep one row per pair).
    let rec_updated = EmbedRecord::new(
        rec.memory_id,
        &rec.backend_id,
        vec![1.0, 2.0, 3.0, 4.0, 5.0],
        at(9),
    )
    .expect("valid updated record");
    repo.write(&rec_updated).expect("upsert embedding");

    let fetched_updated = repo
        .read(&rec.memory_id, &rec.backend_id)
        .expect("read after upsert")
        .expect("row present after upsert");
    assert_eq!(fetched_updated, rec_updated);

    let count: i64 = pool
        .query_row(
            "SELECT COUNT(*) FROM memory_embeddings WHERE memory_id = ?1;",
            [rec.memory_id.to_string()],
            |row| row.get(0),
        )
        .expect("count rows");
    assert_eq!(count, 1, "upsert must not duplicate rows");
}

#[test]
fn embedding_repo_list_by_backend_returns_records() {
    let pool = test_pool();
    seed_memory(&pool, "mem_01ARZ3NDEKTSV4RRFFQ69G5FAV");
    seed_memory(&pool, "mem_01ARZ3NDEKTSV4RRFFQ69G5FAW");
    let repo = EmbeddingRepo::new(&pool);

    let stub_a = record(
        "mem_01ARZ3NDEKTSV4RRFFQ69G5FAV",
        "stub:v1",
        vec![0.1, 0.2, 0.3],
        1,
    );
    let stub_b = record(
        "mem_01ARZ3NDEKTSV4RRFFQ69G5FAW",
        "stub:v1",
        vec![-0.1, -0.2, -0.3],
        2,
    );
    // Different backend for the same memory as stub_a — must not appear in
    // a list filtered to stub:v1.
    let other_backend = record(
        "mem_01ARZ3NDEKTSV4RRFFQ69G5FAV",
        "onnx:minilm-l6",
        vec![0.4; 6],
        3,
    );

    repo.write(&stub_a).expect("write stub a");
    repo.write(&stub_b).expect("write stub b");
    repo.write(&other_backend).expect("write other backend");

    let stub_listing = repo.list_by_backend("stub:v1").expect("list by stub:v1");
    assert_eq!(stub_listing.len(), 2);
    // Ordered by memory_id, lexicographic on the `mem_` string.
    assert_eq!(stub_listing[0], stub_a);
    assert_eq!(stub_listing[1], stub_b);

    let other_listing = repo
        .list_by_backend("onnx:minilm-l6")
        .expect("list by onnx:minilm-l6");
    assert_eq!(other_listing, vec![other_backend]);

    // Filtering to an unknown backend returns an empty Vec, not an error.
    let empty = repo
        .list_by_backend("never-registered")
        .expect("list by unknown backend");
    assert!(empty.is_empty());
}

#[test]
fn embedding_repo_delete_removes_row() {
    let pool = test_pool();
    seed_memory(&pool, "mem_01ARZ3NDEKTSV4RRFFQ69G5FAV");
    let repo = EmbeddingRepo::new(&pool);
    let rec = record(
        "mem_01ARZ3NDEKTSV4RRFFQ69G5FAV",
        "stub:v1",
        vec![0.5, 0.5, 0.5, 0.5],
        4,
    );
    repo.write(&rec).expect("write embedding");

    repo.delete(&rec.memory_id, &rec.backend_id)
        .expect("delete embedding");

    let after_delete = repo
        .read(&rec.memory_id, &rec.backend_id)
        .expect("read after delete");
    assert!(after_delete.is_none());

    // Idempotent: deleting an already-deleted row is a no-op.
    repo.delete(&rec.memory_id, &rec.backend_id)
        .expect("re-delete is a no-op");

    // Delete is scoped to (memory_id, backend_id): other backends for the
    // same memory survive.
    let other_backend = record(
        "mem_01ARZ3NDEKTSV4RRFFQ69G5FAV",
        "onnx:minilm-l6",
        vec![0.1; 8],
        5,
    );
    repo.write(&other_backend).expect("write other backend");

    repo.delete(&other_backend.memory_id, "stub:v1")
        .expect("delete unrelated row");

    let survived = repo
        .read(&other_backend.memory_id, "onnx:minilm-l6")
        .expect("read survived row")
        .expect("survived row present");
    assert_eq!(survived, other_backend);
}