| 3313 | } |
| 3314 | |
| 3315 | pub fn update_memory_content( |
| 3316 | conn: &mut Connection, |
| 3317 | memory_id: i64, |
| 3318 | new_content: &str, |
| 3319 | ) -> Result<(), rusqlite::Error> { |
| 3320 | // Phase A: resolve the target row before opening a write transaction. |
| 3321 | let target = lookup_memory_mutation_target(conn, memory_id)?; |
| 3322 | let new_hash = normalize_hash(new_content); |
| 3323 | |
| 3324 | // Phase B: re-check the target row, mutate, clear stale embeddings, and queue once. |
| 3325 | let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; |
| 3326 | verify_memory_project_path_unchanged(&tx, memory_id, &target.project_path)?; |
| 3327 | |
| 3328 | // Pre-check the UNIQUE(project_path, category, normalized_hash) constraint |
| 3329 | // INSIDE the Immediate transaction (no TOCTOU). If editing this memory's |
| 3330 | // content makes its hash match another memory in the same project+category, |
| 3331 | // a plain UPDATE aborts with a raw `UNIQUE constraint failed: ...` string |
| 3332 | // that surfaces verbatim in the dashboard toast. Mirror the TS plugin's |
| 3333 | // friendly message so the user knows to merge/archive the duplicate. |
| 3334 | // `category = ?2` (not `IS`): SQLite's UNIQUE treats NULL categories as |
| 3335 | // distinct (NULLs never collide), and `category = NULL` is never true — so |
| 3336 | // a NULL category yields no match here, exactly mirroring the constraint |
| 3337 | // the real UPDATE would (not) violate. Avoids a false rejection on NULL. |
| 3338 | let collision_id: Option<i64> = tx |
| 3339 | .query_row( |
| 3340 | "SELECT id FROM memories |
| 3341 | WHERE project_path = ?1 AND category = ?2 AND normalized_hash = ?3 AND id != ?4 |
| 3342 | LIMIT 1", |
| 3343 | params![target.project_path, target.category, new_hash, memory_id], |
| 3344 | |row| row.get(0), |
| 3345 | ) |
| 3346 | .optional()?; |
| 3347 | if let Some(existing_id) = collision_id { |
| 3348 | // SqliteFailure(_, Some(msg)) Displays as exactly `msg`, so commands.rs's |
| 3349 | // `.to_string()` surfaces this friendly text (not a raw SQLite error) |
| 3350 | // in the dashboard toast. rusqlite 0.31 has no ModuleError variant. |
| 3351 | return Err(rusqlite::Error::SqliteFailure( |
| 3352 | rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT), |
| 3353 | Some(format!( |
| 3354 | "Memory content already exists as ID {existing_id} in this category; merge or archive the duplicate first." |
| 3355 | )), |
| 3356 | )); |
| 3357 | } |
| 3358 | |
| 3359 | tx.execute( |
| 3360 | "UPDATE memories SET content = ?1, normalized_hash = ?2, updated_at = ?3 WHERE id = ?4", |
| 3361 | params![new_content, new_hash, now_millis(), memory_id], |
| 3362 | )?; |
| 3363 | // The classify `shareable` verdict was scored against the OLD content; a |
| 3364 | // dashboard content edit invalidates it. Fail closed → private; the dreamer |
| 3365 | // re-scores later. Mirrors the plugin's updateMemoryContent. Column-guarded |
| 3366 | // for pre-v44 DBs. |
| 3367 | if memories_has_classify_columns(&tx) { |
| 3368 | tx.execute( |
| 3369 | "UPDATE memories SET shareable = 0 WHERE id = ?1", |
| 3370 | params![memory_id], |
| 3371 | )?; |
| 3372 | } |