engram_core/migrate_handler/
logic.rs1use serde_json::{Value, json};
6
7use engram_storage::{Database, Memory, StorageError};
8
9use crate::error::CoreError;
10
11#[derive(Debug, Default, Clone, PartialEq, Eq)]
12pub struct MigrationStats {
13 pub read: usize,
14 pub matched: usize,
15 pub migrated: usize,
16 pub skipped_duplicate: usize,
17 pub skipped_null_project: usize,
18 pub skipped_other_project: usize,
19}
20
21impl MigrationStats {
22 pub fn to_json(&self, dry_run: bool, all: bool, project_hint: Option<&str>) -> Value {
23 json!({
24 "read": self.read,
25 "matched": self.matched,
26 "migrated": self.migrated,
27 "skipped_duplicate": self.skipped_duplicate,
28 "skipped_null_project": self.skipped_null_project,
29 "skipped_other_project": self.skipped_other_project,
30 "dry_run": dry_run,
31 "all": all,
32 "project_hint": project_hint,
33 })
34 }
35}
36
37pub fn perform_migration_impl(
44 source: &Database,
45 dest: &Database,
46 project_hint: Option<&str>,
47 all: bool,
48 dry_run: bool,
49) -> Result<MigrationStats, CoreError> {
50 let memories = source.list_all_memories()?;
51 let mut stats = MigrationStats {
52 read: memories.len(),
53 ..MigrationStats::default()
54 };
55
56 let mut to_insert: Vec<Memory> = Vec::new();
57 for memory in memories {
58 if !memory_matches_filter(&memory, project_hint, all, &mut stats) {
59 continue;
60 }
61 stats.matched += 1;
62 if dest_contains_memory(dest, &memory.id)? {
63 stats.skipped_duplicate += 1;
64 continue;
65 }
66 let mut copy = memory;
67 copy.indexed = false;
69 to_insert.push(copy);
70 }
71
72 if !dry_run && !to_insert.is_empty() {
73 dest.bulk_insert_memories(&to_insert)
74 .map_err(|error| CoreError::MigrationFailed(error.to_string()))?;
75 }
76 stats.migrated = if dry_run { 0 } else { to_insert.len() };
77 Ok(stats)
78}
79
80fn memory_matches_filter(
81 memory: &Memory,
82 project_hint: Option<&str>,
83 all: bool,
84 stats: &mut MigrationStats,
85) -> bool {
86 if all {
87 return true;
88 }
89 match (project_hint, memory.project.as_deref()) {
90 (None, _) => true,
91 (Some(_), None) => {
92 stats.skipped_null_project += 1;
93 false
94 }
95 (Some(hint), Some(project)) if project == hint => true,
96 (Some(_), Some(_)) => {
97 stats.skipped_other_project += 1;
98 false
99 }
100 }
101}
102
103fn dest_contains_memory(dest: &Database, id: &str) -> Result<bool, CoreError> {
104 match dest.get_memory(id) {
105 Ok(_) => Ok(true),
106 Err(StorageError::NotFound(_)) => Ok(false),
107 Err(error) => Err(CoreError::MigrationFailed(error.to_string())),
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use tempfile::NamedTempFile;
115
116 fn sample_memory(id: &str, project: Option<&str>) -> Memory {
117 Memory {
118 id: id.to_string(),
119 memory_type: "decision".into(),
120 context: "context".into(),
121 action: "action".into(),
122 result: "result".into(),
123 score: 0.5,
124 embedding_context: None,
125 embedding_action: None,
126 embedding_result: None,
127 indexed: true,
128 tags: None,
129 project: project.map(|value| value.to_string()),
130 parent_id: None,
131 source_ids: None,
132 insight_type: None,
133 created_at: "2025-01-01T00:00:00Z".into(),
134 updated_at: "2025-01-01T00:00:00Z".into(),
135 used_count: 0,
136 last_used_at: None,
137 superseded_by: None,
138 }
139 }
140
141 fn insert_sample(database: &Database, memory: &Memory) {
142 database.insert_memory(memory).expect("seed insert");
143 }
144
145 #[test]
146 fn empty_source_returns_zero_stats() {
147 let source = Database::in_memory().expect("source");
148 let dest = Database::in_memory().expect("dest");
149 let stats = perform_migration_impl(&source, &dest, Some("engram"), false, false)
150 .expect("migration");
151 assert_eq!(stats, MigrationStats::default());
152 }
153
154 #[test]
155 fn all_flag_copies_all_rows_including_null_project() {
156 let source = Database::in_memory().expect("source");
157 let dest = Database::in_memory().expect("dest");
158 insert_sample(&source, &sample_memory("a", Some("engram")));
159 insert_sample(&source, &sample_memory("b", None));
160 insert_sample(&source, &sample_memory("c", Some("other")));
161
162 let stats = perform_migration_impl(&source, &dest, None, true, false).expect("migration");
163
164 assert_eq!(stats.read, 3);
165 assert_eq!(stats.matched, 3);
166 assert_eq!(stats.migrated, 3);
167 assert_eq!(stats.skipped_null_project, 0);
168 assert_eq!(stats.skipped_other_project, 0);
169 for id in ["a", "b", "c"] {
170 dest.get_memory(id).expect("row migrated");
171 }
172 }
173
174 #[test]
175 fn default_filter_matches_project_hint_exactly() {
176 let source = Database::in_memory().expect("source");
177 let dest = Database::in_memory().expect("dest");
178 insert_sample(&source, &sample_memory("match", Some("engram")));
179 insert_sample(&source, &sample_memory("null", None));
180 insert_sample(&source, &sample_memory("other", Some("different")));
181
182 let stats = perform_migration_impl(&source, &dest, Some("engram"), false, false)
183 .expect("migration");
184
185 assert_eq!(stats.read, 3);
186 assert_eq!(stats.matched, 1);
187 assert_eq!(stats.migrated, 1);
188 assert_eq!(stats.skipped_null_project, 1);
189 assert_eq!(stats.skipped_other_project, 1);
190 dest.get_memory("match").expect("match migrated");
191 assert!(matches!(
192 dest.get_memory("null"),
193 Err(StorageError::NotFound(_))
194 ));
195 assert!(matches!(
196 dest.get_memory("other"),
197 Err(StorageError::NotFound(_))
198 ));
199 }
200
201 #[test]
202 fn skips_duplicate_ids_without_rolling_back_batch() {
203 let source = Database::in_memory().expect("source");
204 let dest = Database::in_memory().expect("dest");
205 insert_sample(&source, &sample_memory("dup", Some("engram")));
206 insert_sample(&source, &sample_memory("new", Some("engram")));
207 insert_sample(&dest, &sample_memory("dup", Some("engram")));
208
209 let stats = perform_migration_impl(&source, &dest, Some("engram"), false, false)
210 .expect("migration");
211
212 assert_eq!(stats.read, 2);
213 assert_eq!(stats.matched, 2);
214 assert_eq!(stats.migrated, 1);
215 assert_eq!(stats.skipped_duplicate, 1);
216 dest.get_memory("new").expect("new migrated");
217 }
218
219 #[test]
220 fn dry_run_counts_without_writing() {
221 let source = Database::in_memory().expect("source");
222 let dest = Database::in_memory().expect("dest");
223 insert_sample(&source, &sample_memory("a", Some("engram")));
224 insert_sample(&source, &sample_memory("b", Some("engram")));
225
226 let stats =
227 perform_migration_impl(&source, &dest, Some("engram"), false, true).expect("migration");
228
229 assert_eq!(stats.read, 2);
230 assert_eq!(stats.matched, 2);
231 assert_eq!(stats.migrated, 0);
232 assert_eq!(stats.skipped_duplicate, 0);
233 assert!(matches!(
234 dest.get_memory("a"),
235 Err(StorageError::NotFound(_))
236 ));
237 assert!(matches!(
238 dest.get_memory("b"),
239 Err(StorageError::NotFound(_))
240 ));
241 }
242
243 #[test]
244 fn migrated_row_has_indexed_false_for_hnsw_rebuild() {
245 let source = Database::in_memory().expect("source");
246 let dest = Database::in_memory().expect("dest");
247 let mut memory = sample_memory("x", Some("engram"));
248 memory.indexed = true;
249 insert_sample(&source, &memory);
250
251 let stats = perform_migration_impl(&source, &dest, Some("engram"), false, false)
252 .expect("migration");
253 assert_eq!(stats.migrated, 1);
254
255 let migrated = dest.get_memory("x").expect("migrated row");
256 assert!(
257 !migrated.indexed,
258 "migrated rows must be unindexed so server rebuilds HNSW"
259 );
260 }
261
262 #[test]
263 fn migrate_reports_6019_when_dest_write_fails() {
264 let source = Database::in_memory().expect("source");
265 insert_sample(&source, &sample_memory("id-1", Some("proj")));
266
267 let dest_file = NamedTempFile::new().expect("temp dest file");
268 let dest_path = dest_file.path().to_str().expect("utf-8");
269 {
271 let _schema = Database::open(dest_path).expect("init schema");
272 }
273 let dest = Database::open_read_only(dest_path).expect("reopen dest read-only");
274
275 let result = perform_migration_impl(&source, &dest, Some("proj"), false, false);
276
277 let error = result.expect_err("write to read-only dest must fail");
278 assert!(matches!(error, CoreError::MigrationFailed(_)));
279 let message = error.to_string();
280 assert!(
281 message.contains("[6019]"),
282 "expected 6019 migration failure, got: {message}"
283 );
284 }
285}