| 3243 | } |
| 3244 | |
| 3245 | pub fn update_memory_status( |
| 3246 | conn: &mut Connection, |
| 3247 | memory_id: i64, |
| 3248 | new_status: &str, |
| 3249 | ) -> Result<(), rusqlite::Error> { |
| 3250 | // Reject any status outside the canonical set before touching the DB. A |
| 3251 | // malformed call setting status="archive" (vs "archived") or any free string |
| 3252 | // would make the memory vanish from active/permanent/archived logic with no |
| 3253 | // valid epoch/delta interpretation. |
| 3254 | if !matches!(new_status, "active" | "permanent" | "archived") { |
| 3255 | return Err(rusqlite::Error::SqliteFailure( |
| 3256 | rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT), |
| 3257 | Some(format!( |
| 3258 | "Invalid memory status '{new_status}' (expected active, permanent, or archived)." |
| 3259 | )), |
| 3260 | )); |
| 3261 | } |
| 3262 | // Phase A: resolve the target row before opening a write transaction. |
| 3263 | let target = lookup_memory_mutation_target(conn, memory_id)?; |
| 3264 | // Any transition that CHANGES which memories enter or how they rank in the |
| 3265 | // m[0] baseline must bump the epoch to invalidate cached m[0]. That covers |
| 3266 | // every status change into an injectable status (`active`/`permanent`) from a |
| 3267 | // different status — including archived->active/permanent (restore) AND |
| 3268 | // active<->permanent (pin/unpin), since memory selection is permanent-first |
| 3269 | // under budget pressure, so pinning reorders the rendered set. Gating only on |
| 3270 | // archived-origin left active<->permanent invalidating nothing. |
| 3271 | let prior_status = target.status.as_deref(); |
| 3272 | let into_injectable = new_status == "active" || new_status == "permanent"; |
| 3273 | let needs_epoch_bump = into_injectable && prior_status != Some(new_status); |
| 3274 | let project_identity = if needs_epoch_bump { |
| 3275 | Some(normalize_stored_project_path(&target.project_path)) |
| 3276 | } else { |
| 3277 | None |
| 3278 | }; |
| 3279 | let is_archive = new_status == "archived" && prior_status != Some("archived"); |
| 3280 | |
| 3281 | // Phase B: re-check the target row, mutate, and queue/bump in one tx. |
| 3282 | let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; |
| 3283 | verify_memory_project_path_unchanged(&tx, memory_id, &target.project_path)?; |
| 3284 | tx.execute( |
| 3285 | "UPDATE memories SET status = ?1, updated_at = ?2 WHERE id = ?3", |
| 3286 | params![new_status, now_millis(), memory_id], |
| 3287 | )?; |
| 3288 | // Resolve workspace fan-out INSIDE the write transaction so a concurrent |
| 3289 | // add-member committing between Phase A and here can't leave a new member's |
| 3290 | // epoch un-bumped (it would then miss this restored memory). |
| 3291 | let epoch_bump_identities = if let Some(ref id) = project_identity { |
| 3292 | Some(crate::workspaces::workspace_member_identities_for_project( |
| 3293 | &tx, id, |
| 3294 | )?) |
| 3295 | } else { |
| 3296 | None |
| 3297 | }; |
| 3298 | if let Some(identities) = epoch_bump_identities.as_ref() { |
| 3299 | crate::workspaces::bump_epochs_for_identities(&tx, identities)?; |
| 3300 | } else if is_archive { |
| 3301 | queue_memory_mutation( |
| 3302 | &tx, |