use anyhow::{anyhow, Result};
use rusqlite::{params, Connection};
use crate::memory::state_key::StateKeyDecision;
pub const SHORT_CURRENT_TTL_SECONDS: i64 = 24 * 60 * 60;
pub const BRANCH_SNAPSHOT_TTL_SECONDS: i64 = 7 * 24 * 60 * 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemoryLifecycleOp {
Add,
Update,
Invalidate,
Noop,
Defer,
Conflict,
}
impl MemoryLifecycleOp {
pub fn as_str(self) -> &'static str {
match self {
Self::Add => "add",
Self::Update => "update",
Self::Invalidate => "invalidate",
Self::Noop => "noop",
Self::Defer => "defer",
Self::Conflict => "conflict",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LifecycleOutcome {
pub op: MemoryLifecycleOp,
pub memory_id: Option<i64>,
pub superseded: usize,
pub noop: bool,
pub deferred: bool,
pub reason: Option<String>,
}
#[allow(clippy::too_many_arguments)]
pub fn apply_add(
conn: &Connection,
session_id: Option<&str>,
project: &str,
topic_key: Option<&str>,
title: &str,
content: &str,
memory_type: &str,
files: Option<&str>,
branch: Option<&str>,
scope: &str,
) -> Result<LifecycleOutcome> {
let memory_id = crate::memory::insert_memory_full(
conn,
session_id,
project,
topic_key,
title,
content,
memory_type,
files,
branch,
scope,
None,
)?;
Ok(LifecycleOutcome {
op: MemoryLifecycleOp::Add,
memory_id: Some(memory_id),
superseded: 0,
noop: false,
deferred: false,
reason: None,
})
}
#[allow(clippy::too_many_arguments)]
pub fn apply_update(
conn: &Connection,
session_id: Option<&str>,
project: &str,
topic_key: &str,
title: &str,
content: &str,
memory_type: &str,
files: Option<&str>,
branch: Option<&str>,
scope: &str,
superseded_ids: &[i64],
) -> Result<LifecycleOutcome> {
let tx = conn.unchecked_transaction()?;
let ownership = lifecycle_ownership(project, scope);
let state_key =
crate::memory::state_key::derive_state_key(memory_type, Some(topic_key), title, content);
let mut superseded_targets = superseded_ids.to_vec();
superseded_targets.extend(find_active_same_state_or_topic(
&tx,
&ownership,
memory_type,
topic_key,
state_key.as_ref(),
)?);
let memory_id = insert_replacement_memory(
&tx,
session_id,
project,
topic_key,
title,
content,
memory_type,
files,
branch,
scope,
&ownership,
state_key.as_ref(),
)?;
let superseded = soft_supersede(&tx, project, &superseded_targets, Some(memory_id))?;
crate::memory::edge::insert_supersedes_edges(
&tx,
&superseded_targets,
memory_id,
crate::memory::edge::MemoryEdgeWriteContext {
reason: Some("lifecycle update supersedes old memory"),
..Default::default()
},
)?;
tx.commit()?;
Ok(LifecycleOutcome {
op: MemoryLifecycleOp::Update,
memory_id: Some(memory_id),
superseded,
noop: false,
deferred: false,
reason: None,
})
}
pub fn apply_invalidate(
conn: &Connection,
project: &str,
memory_ids: &[i64],
reason: Option<&str>,
) -> Result<LifecycleOutcome> {
let tx = conn.unchecked_transaction()?;
let superseded = soft_supersede(&tx, project, memory_ids, None)?;
tx.commit()?;
Ok(LifecycleOutcome {
op: MemoryLifecycleOp::Invalidate,
memory_id: None,
superseded,
noop: false,
deferred: false,
reason: reason.map(str::to_string),
})
}
#[allow(clippy::too_many_arguments)]
fn insert_replacement_memory(
conn: &Connection,
session_id: Option<&str>,
project: &str,
topic_key: &str,
title: &str,
content: &str,
memory_type: &str,
files: Option<&str>,
branch: Option<&str>,
scope: &str,
ownership: &LifecycleOwnership<'_>,
state_key: Option<&StateKeyDecision>,
) -> Result<i64> {
let now = chrono::Utc::now().timestamp();
let (expires_at_epoch, valid_from_epoch) =
ttl_metadata(memory_type, Some(topic_key), content, now);
let search_context = crate::memory::search_context::build_search_context(
memory_type,
Some(topic_key),
content,
files,
);
let fallback_source_hash = crate::memory::retrieval_enrichment::enrichment_source_hash(
title,
content,
memory_type,
Some(topic_key),
files,
);
conn.execute(
"INSERT INTO memories
(session_id, project, topic_key, title, content, memory_type, files, search_context,
search_context_fallback_source_hash,
created_at_epoch, updated_at_epoch, status, branch, scope,
source_project, target_project, owner_scope, owner_key, context_class,
expires_at_epoch, valid_from_epoch)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?18,
?9, ?9, 'active', ?10, ?11,
?12, ?13, ?14, ?15, 'startup_core',
?16, ?17)",
params![
session_id,
project,
topic_key,
title,
content,
memory_type,
files,
search_context,
now,
branch,
scope,
ownership.source_project,
ownership.target_project,
ownership.owner_scope,
ownership.owner_key,
expires_at_epoch,
valid_from_epoch,
fallback_source_hash
],
)?;
let memory_id = conn.last_insert_rowid();
if let Some(state_key) = state_key {
crate::memory::state_key::attach_current_memory(
conn,
memory_id,
ownership.owner_scope,
ownership.owner_key,
memory_type,
state_key,
now,
)?;
}
crate::retrieval::vector::upsert_memory_embedding(
conn,
memory_id,
title,
content,
memory_type,
Some(topic_key),
"",
)?;
Ok(memory_id)
}
struct LifecycleOwnership<'a> {
source_project: &'a str,
target_project: Option<&'a str>,
owner_scope: &'static str,
owner_key: &'a str,
}
fn lifecycle_ownership<'a>(project: &'a str, scope: &str) -> LifecycleOwnership<'a> {
if scope == "global" {
LifecycleOwnership {
source_project: project,
target_project: None,
owner_scope: "user",
owner_key: "user:default",
}
} else {
LifecycleOwnership {
source_project: project,
target_project: Some(project),
owner_scope: "repo",
owner_key: project,
}
}
}
fn find_active_same_state_or_topic(
conn: &Connection,
ownership: &LifecycleOwnership<'_>,
memory_type: &str,
topic_key: &str,
state_key: Option<&StateKeyDecision>,
) -> Result<Vec<i64>> {
let mut ids = Vec::new();
if let Some(state_key) = state_key {
ids.extend(crate::memory::state_key::active_memory_ids(
conn,
ownership.owner_scope,
ownership.owner_key,
memory_type,
&state_key.state_key,
chrono::Utc::now().timestamp(),
false,
)?);
}
ids.extend(find_active_same_topic_key(
conn,
ownership,
memory_type,
topic_key,
)?);
Ok(ids)
}
fn find_active_same_topic_key(
conn: &Connection,
ownership: &LifecycleOwnership<'_>,
memory_type: &str,
topic_key: &str,
) -> Result<Vec<i64>> {
let mut stmt = conn.prepare(
"SELECT id FROM memories
WHERE memory_type = ?1
AND topic_key = ?2
AND COALESCE(
owner_scope,
CASE WHEN COALESCE(scope, 'project') = 'global' THEN 'user' ELSE 'repo' END
) = ?3
AND COALESCE(
owner_key,
CASE WHEN COALESCE(scope, 'project') = 'global' THEN 'user:default' ELSE project END
) = ?4
AND status = 'active'",
)?;
let rows = stmt.query_map(
params![
memory_type,
topic_key,
ownership.owner_scope,
ownership.owner_key
],
|row| row.get(0),
)?;
crate::db::query::collect_rows(rows)
}
pub fn noop(reason: impl Into<String>) -> LifecycleOutcome {
LifecycleOutcome {
op: MemoryLifecycleOp::Noop,
memory_id: None,
superseded: 0,
noop: true,
deferred: false,
reason: Some(reason.into()),
}
}
pub fn defer(reason: impl Into<String>) -> LifecycleOutcome {
LifecycleOutcome {
op: MemoryLifecycleOp::Defer,
memory_id: None,
superseded: 0,
noop: false,
deferred: true,
reason: Some(reason.into()),
}
}
pub fn default_ttl_seconds(
memory_type: &str,
topic_key: Option<&str>,
content: &str,
) -> Option<i64> {
let topic_key = topic_key.unwrap_or_default().to_ascii_lowercase();
let content = content.to_ascii_lowercase();
if has_any(&topic_key, short_current_needles()) {
return Some(SHORT_CURRENT_TTL_SECONDS);
}
if has_any(&topic_key, branch_snapshot_needles()) {
return Some(BRANCH_SNAPSHOT_TTL_SECONDS);
}
if durable_type_has_no_content_ttl(memory_type) {
return None;
}
if has_any(&content, short_current_needles()) {
return Some(SHORT_CURRENT_TTL_SECONDS);
}
if has_any(&content, branch_snapshot_needles()) {
return Some(BRANCH_SNAPSHOT_TTL_SECONDS);
}
None
}
pub fn expires_at_epoch(
memory_type: &str,
topic_key: Option<&str>,
content: &str,
now_epoch: i64,
) -> Option<i64> {
default_ttl_seconds(memory_type, topic_key, content).map(|ttl| now_epoch + ttl)
}
pub fn ttl_metadata(
memory_type: &str,
topic_key: Option<&str>,
content: &str,
now_epoch: i64,
) -> (Option<i64>, Option<i64>) {
let expires_at_epoch = expires_at_epoch(memory_type, topic_key, content, now_epoch);
let valid_from_epoch = expires_at_epoch.map(|_| now_epoch);
(expires_at_epoch, valid_from_epoch)
}
pub fn expire_active_memories(conn: &Connection, now_epoch: i64) -> Result<usize> {
if !conn.is_autocommit() {
return expire_active_memories_in_transaction(conn, now_epoch);
}
let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)?;
let changed = expire_active_memories_in_transaction(&tx, now_epoch)?;
tx.commit()?;
Ok(changed)
}
fn expire_active_memories_in_transaction(conn: &Connection, now_epoch: i64) -> Result<usize> {
let mut stmt = conn.prepare(
"SELECT id FROM memories
WHERE status = 'active'
AND memory_type = 'preference'
AND expires_at_epoch IS NOT NULL
AND expires_at_epoch <= ?1",
)?;
let rows = stmt.query_map(params![now_epoch], |row| row.get::<_, i64>(0))?;
let expiring_preference_ids = crate::db::query::collect_rows(rows)?;
drop(stmt);
let changed = conn.execute(
"UPDATE memories
SET status = 'stale',
valid_to_epoch = COALESCE(valid_to_epoch, ?1),
updated_at_epoch = ?1
WHERE status = 'active'
AND expires_at_epoch IS NOT NULL
AND expires_at_epoch <= ?1",
params![now_epoch],
)?;
crate::memory::preference::compilation::enqueue_for_memory_ids(conn, &expiring_preference_ids)?;
Ok(changed)
}
pub fn count_expired_active_memories(conn: &Connection, now_epoch: i64) -> Result<usize> {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM memories
WHERE status = 'active'
AND expires_at_epoch IS NOT NULL
AND expires_at_epoch <= ?1",
params![now_epoch],
|row| row.get(0),
)?;
Ok(count as usize)
}
pub fn soft_supersede(
conn: &Connection,
project: &str,
memory_ids: &[i64],
replacement_id: Option<i64>,
) -> Result<usize> {
let mut seen = std::collections::HashSet::with_capacity(memory_ids.len());
let targets = memory_ids
.iter()
.copied()
.filter(|id| Some(*id) != replacement_id && seen.insert(*id))
.collect::<Vec<_>>();
for id in &targets {
let exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM memories WHERE id = ?1 AND project = ?2)",
params![id, project],
|row| row.get(0),
)?;
if !exists {
return Err(anyhow!(
"failed to mark superseded memory stale: id={} project={}",
id,
project
));
}
}
crate::memory::preference::compilation::enqueue_for_memory_ids(conn, &targets)?;
let mut changed = 0usize;
let now = chrono::Utc::now().timestamp();
for id in targets {
let updated = conn.execute(
"UPDATE memories
SET status = 'stale',
valid_to_epoch = COALESCE(valid_to_epoch, ?3)
WHERE id = ?1 AND project = ?2",
params![id, project, now],
)?;
if updated != 1 {
return Err(anyhow!(
"failed to mark superseded memory stale: id={} project={}",
id,
project
));
}
changed += updated;
}
Ok(changed)
}
fn has_any(haystack: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| haystack.contains(needle))
}
fn short_current_needles() -> &'static [&'static str] {
&[
"dev-server",
"dev server",
"localhost",
"127.0.0.1",
"port occupied",
"port is occupied",
"currently running",
"server running",
"local url",
"url healthy",
"healthy at",
"mergeability",
"mergeable",
"review status",
"review-status",
"ci state",
"ci status",
"ci-status",
"github actions",
"pull request",
"pull-request",
"pr #",
]
}
fn branch_snapshot_needles() -> &'static [&'static str] {
&[
"git-divergence",
"branch-divergence",
"branch divergence",
"current branch",
"git status",
"ahead of",
"behind origin",
"diverged",
"dirty worktree",
]
}
fn durable_type_has_no_content_ttl(memory_type: &str) -> bool {
matches!(
memory_type,
"architecture" | "bugfix" | "lesson" | "preference" | "procedure"
)
}
#[cfg(test)]
mod ttl_tests;
#[cfg(test)]
mod vector_tests;
#[cfg(test)]
mod tests {
use rusqlite::Connection;
use super::*;
use crate::memory::insert_memory;
use crate::memory::tests_helper::setup_memory_schema;
use crate::retrieval::search::search_with_branch;
#[test]
fn update_preserves_superseded_memory_but_default_search_returns_current_fact() -> Result<()> {
let conn = Connection::open_in_memory()?;
setup_memory_schema(&conn);
let project = "test-lifecycle";
let old_id = insert_memory(
&conn,
Some("s1"),
project,
Some("deploy-target"),
"Deploy target",
"Deploy target is staging.",
"decision",
None,
)?;
let outcome = apply_update(
&conn,
Some("s2"),
project,
"deploy-target-current",
"Deploy target corrected",
"Deploy target is production.",
"decision",
None,
None,
"project",
&[old_id],
)?;
assert_eq!(outcome.op, MemoryLifecycleOp::Update);
assert_eq!(outcome.superseded, 1);
let old_status: String = conn.query_row(
"SELECT status FROM memories WHERE id = ?1",
[old_id],
|row| row.get(0),
)?;
assert_eq!(old_status, "stale");
let results = search_with_branch(
&conn,
Some("deploy target"),
Some(project),
None,
10,
0,
false,
None,
)?;
assert_eq!(results.len(), 1);
assert_eq!(results[0].text, "Deploy target is production.");
Ok(())
}
#[test]
fn update_records_supersedes_edge_in_same_transaction() -> Result<()> {
let conn = Connection::open_in_memory()?;
setup_memory_schema(&conn);
let project = "test-lifecycle-edge";
let old_id = insert_memory(
&conn,
Some("s1"),
project,
Some("deploy-target"),
"Deploy target",
"Deploy target is staging.",
"decision",
None,
)?;
let outcome = apply_update(
&conn,
Some("s2"),
project,
"deploy-target",
"Deploy target",
"Deploy target is production.",
"decision",
None,
None,
"project",
&[old_id],
)?;
let Some(new_id) = outcome.memory_id else {
anyhow::bail!("update should create replacement");
};
let edge: (String, i64, i64) = conn.query_row(
"SELECT edge_type, from_memory_id, to_memory_id
FROM memory_edges
WHERE from_memory_id = ?1 AND to_memory_id = ?2",
[old_id, new_id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
assert_eq!(edge, ("supersedes".to_string(), old_id, new_id));
Ok(())
}
#[test]
fn invalidate_marks_memory_stale_without_deleting_it() -> Result<()> {
let conn = Connection::open_in_memory()?;
setup_memory_schema(&conn);
let project = "test-lifecycle";
let id = insert_memory(
&conn,
Some("s1"),
project,
Some("old-fact"),
"Old fact",
"This fact is no longer valid.",
"discovery",
None,
)?;
let outcome = apply_invalidate(&conn, project, &[id], Some("contradicted"))?;
assert_eq!(outcome.op, MemoryLifecycleOp::Invalidate);
assert_eq!(outcome.superseded, 1);
let (status, content): (String, String) = conn.query_row(
"SELECT status, content FROM memories WHERE id = ?1",
[id],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
assert_eq!(status, "stale");
assert_eq!(content, "This fact is no longer valid.");
Ok(())
}
#[test]
fn update_rolls_back_insert_when_superseded_id_is_invalid() -> Result<()> {
let conn = Connection::open_in_memory()?;
setup_memory_schema(&conn);
let project = "test-lifecycle";
let old_id = insert_memory(
&conn,
Some("s1"),
project,
Some("old-fact"),
"Old fact",
"Old value.",
"decision",
None,
)?;
let err = apply_update(
&conn,
Some("s2"),
project,
"new-fact",
"New fact",
"New value.",
"decision",
None,
None,
"project",
&[old_id, 999_999],
)
.expect_err("invalid superseded id should fail");
assert!(err.to_string().contains("999999") || err.to_string().contains("999_999"));
let active_new_count: i64 = conn.query_row(
"SELECT COUNT(*) FROM memories WHERE project = ?1 AND topic_key = 'new-fact'",
[project],
|row| row.get(0),
)?;
let old_status: String = conn.query_row(
"SELECT status FROM memories WHERE id = ?1",
[old_id],
|row| row.get(0),
)?;
assert_eq!(active_new_count, 0);
assert_eq!(old_status, "active");
let edge_count: i64 =
conn.query_row("SELECT COUNT(*) FROM memory_edges", [], |row| row.get(0))?;
assert_eq!(edge_count, 0);
Ok(())
}
#[test]
fn invalidate_rolls_back_when_any_memory_id_is_invalid() -> Result<()> {
let conn = Connection::open_in_memory()?;
setup_memory_schema(&conn);
let project = "test-lifecycle";
let first_id = insert_memory(
&conn,
Some("s1"),
project,
Some("first"),
"First",
"First value.",
"discovery",
None,
)?;
let second_id = insert_memory(
&conn,
Some("s1"),
project,
Some("second"),
"Second",
"Second value.",
"discovery",
None,
)?;
apply_invalidate(
&conn,
project,
&[first_id, 999_999, second_id],
Some("bad id"),
)
.expect_err("mixed-validity invalidation should fail");
let statuses = conn
.prepare("SELECT status FROM memories WHERE id IN (?1, ?2) ORDER BY id ASC")?
.query_map([first_id, second_id], |row| row.get::<_, String>(0))?
.collect::<std::result::Result<Vec<_>, _>>()?;
assert_eq!(statuses, vec!["active".to_string(), "active".to_string()]);
Ok(())
}
#[test]
fn noop_and_defer_are_explicit_outcomes() {
assert!(noop("duplicate").noop);
assert!(defer("ambiguous conflict").deferred);
}
}