Skip to main content

oxicode/foundation/
migrate.rs

1//! Migration primitives: legacy memory → Brain (oxibrain).
2//!
3//! The migration is **resumable**, **non-destructive**, and **opt-in**.
4//! It runs only when the user invokes `oxicode migrate brain`. The
5//! Oxi Foundation v1 host does not auto-migrate on startup; the
6//! migration is a one-time user action.
7//!
8//! ## Checkpoint
9//!
10//! `~/.oxicode/migration/brain.json` stores the last successfully
11//! migrated memory ID. On restart, the migration resumes from that
12//! point. The checkpoint file is a single JSON object:
13//!
14//! ```json
15//! { "last_id": "m-1234", "migrated": 42, "skipped": 0, "failed": 0 }
16//! ```
17//!
18//! The file is written atomically (temp + rename) so a crash mid-write
19//! cannot leave it in a partial state. A missing or unreadable
20//! checkpoint file is treated as "no checkpoint".
21//!
22//! ## Legacy store
23//!
24//! The legacy durable memory under `~/.oxicode/memory/` is read
25//! through the `LegacyMemoryReader` fallible iterator. The read path
26//! is **stateless** — the legacy backend is not invoked, mutated, or
27//! deleted. The legacy store is left untouched until the user
28//! explicitly archives it via `oxicode migrate brain --archive-legacy`.
29//!
30//! ## Archive
31//!
32//! Archival moves `~/.oxicode/memory/` to
33//! `~/.oxicode/archive/memory/<timestamp>/`. The archive directory
34//! inherits the original permissions. The legacy store cannot be
35//! re-enabled silently — restoring requires a separate explicit
36//! command (not yet implemented; future work).
37
38use std::path::{Path, PathBuf};
39
40use serde::{Deserialize, Serialize};
41
42/// Default location for the migration checkpoint.
43pub fn default_checkpoint_path() -> PathBuf {
44    crate::foundation::fetch_oxicode_home()
45        .unwrap_or_else(|| PathBuf::from("."))
46        .join("migration")
47        .join("brain.json")
48}
49
50/// Default location of the legacy durable memory store.
51pub fn default_legacy_path() -> PathBuf {
52    crate::foundation::fetch_oxicode_home()
53        .unwrap_or_else(|| PathBuf::from("."))
54        .join("memory")
55}
56
57/// On-disk checkpoint. Atomic-write via temp + rename.
58#[derive(Debug, Clone, Serialize, Deserialize, Default)]
59pub struct Checkpoint {
60    /// Last successfully migrated memory ID. `None` means fresh start.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub last_id: Option<String>,
63    /// Number of legacy items inserted into the brain.
64    #[serde(default)]
65    pub migrated: usize,
66    /// Number of legacy items skipped (already present in brain).
67    #[serde(default)]
68    pub skipped: usize,
69    /// Number of legacy items that failed to migrate.
70    #[serde(default)]
71    pub failed: usize,
72}
73
74impl Checkpoint {
75    /// Load the checkpoint from disk. Missing or unreadable file is
76    /// treated as "no checkpoint" (fresh start).
77    pub fn load(path: &Path) -> Self {
78        match std::fs::read(path) {
79            Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
80            Err(_) => Self::default(),
81        }
82    }
83
84    /// Atomically write the checkpoint. Parent directories are
85    /// created on demand.
86    pub fn save(&self, path: &Path) -> std::io::Result<()> {
87        if let Some(parent) = path.parent() {
88            std::fs::create_dir_all(parent)?;
89        }
90        let tmp = path.with_extension("json.tmp");
91        let bytes = serde_json::to_vec_pretty(self).expect("checkpoint is JSON-serializable");
92        std::fs::write(&tmp, bytes)?;
93        std::fs::rename(&tmp, path)?;
94        Ok(())
95    }
96
97    /// Returns the last migrated ID, if any.
98    pub fn last_id(&self) -> Option<&str> {
99        self.last_id.as_deref()
100    }
101}
102
103/// Outcome of a single migration step.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum MigrationOutcome {
106    /// Item was inserted into the brain. The new ID is returned.
107    Inserted(String),
108    /// Item was already present in the brain (idempotent re-run).
109    Skipped(String),
110}
111
112/// A single legacy memory item to migrate.
113#[derive(Debug, Clone)]
114pub struct LegacyItem {
115    pub content: String,
116    pub kind: String,
117    pub subject: String,
118}
119
120/// Migration driver. Holds the checkpoint so each call can advance
121/// the on-disk state.
122pub struct Migration<'a> {
123    backend: &'a crate::foundation::brain::BrainMemoryBackend,
124    checkpoint_path: &'a Path,
125    state: Checkpoint,
126}
127
128impl<'a> Migration<'a> {
129    pub fn new(
130        backend: &'a crate::foundation::brain::BrainMemoryBackend,
131        checkpoint_path: &'a Path,
132    ) -> Self {
133        let state = Checkpoint::load(checkpoint_path);
134        Self {
135            backend,
136            checkpoint_path,
137            state,
138        }
139    }
140
141    /// Synchronously migrate one legacy item. The current thread
142    /// builds a small tokio runtime and blocks on a single
143    /// `backend.put_sync` call. The migration is intentionally
144    /// single-shot per item so the checkpoint can advance between
145    /// writes.
146    pub fn migrate_one(
147        &mut self,
148        item: LegacyItem,
149    ) -> Result<MigrationOutcome, crate::foundation::brain::MigrationError> {
150        let phase = self.backend.health();
151
152        if matches!(phase, crate::foundation::brain::BrainHealth::Unavailable) {
153            return Err(crate::foundation::brain::MigrationError::BackendOffline);
154        }
155
156        let id = self
157            .backend
158            .put_sync(&item.content, &item.kind, &item.subject)
159            .map_err(crate::foundation::brain::MigrationError::Backend)?;
160
161        self.state.last_id = Some(id.clone());
162        self.state.migrated += 1;
163        self.state
164            .save(self.checkpoint_path)
165            .map_err(|e| crate::foundation::brain::MigrationError::Checkpoint(e.to_string()))?;
166
167        Ok(MigrationOutcome::Inserted(id))
168    }
169
170    /// Current migration state snapshot.
171    pub fn state(&self) -> &Checkpoint {
172        &self.state
173    }
174}
175
176/// Read-only legacy memory reader. Walks the legacy store under
177/// `~/.oxicode/memory/items.jsonl`. The read path is **stateless**
178/// and never mutates the legacy store.
179pub struct LegacyMemoryReader {
180    path: PathBuf,
181}
182
183impl LegacyMemoryReader {
184    pub fn new(path: PathBuf) -> Self {
185        Self { path }
186    }
187
188    /// Convenience constructor pointing at the default legacy home.
189    pub fn for_default_home() -> Self {
190        Self::new(default_legacy_path())
191    }
192
193    /// Iterate the legacy store in batches.
194    ///
195    /// The implementation reads `<legacy>/items.jsonl` (one JSON
196    /// object per line). Legacy data with a different layout is
197    /// reported as an empty iterator: the migration is never lossy,
198    /// and the user can inspect the legacy store manually if the
199    /// format is unrecognized.
200    pub fn batches(&self, size: usize) -> LegacyBatches {
201        LegacyBatches {
202            path: self.path.join("items.jsonl"),
203            batch_size: size.max(1),
204            pending: Vec::new(),
205            exhausted: false,
206            loaded: false,
207        }
208    }
209}
210
211/// Iterator over batches of legacy items. The file is read once;
212/// subsequent calls drain `pending` until exhausted.
213pub struct LegacyBatches {
214    path: PathBuf,
215    batch_size: usize,
216    pending: Vec<LegacyItem>,
217    exhausted: bool,
218    loaded: bool,
219}
220
221impl Iterator for LegacyBatches {
222    type Item = Vec<LegacyItem>;
223
224    fn next(&mut self) -> Option<Self::Item> {
225        if self.pending.is_empty() && self.exhausted {
226            return None;
227        }
228        if !self.pending.is_empty() {
229            return Some(std::mem::take(&mut self.pending));
230        }
231
232        // Already loaded the file; nothing else to do.
233        if self.loaded {
234            self.exhausted = true;
235            return None;
236        }
237        self.loaded = true;
238
239        let contents = match std::fs::read_to_string(&self.path) {
240            Ok(c) => c,
241            Err(_) => {
242                self.exhausted = true;
243                return None;
244            }
245        };
246        let mut acc = Vec::with_capacity(self.batch_size);
247        for line in contents.lines() {
248            let line = line.trim();
249            if line.is_empty() {
250                continue;
251            }
252            if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
253                let content = value
254                    .get("content")
255                    .and_then(|v| v.as_str())
256                    .unwrap_or("")
257                    .to_string();
258                let kind = value
259                    .get("kind")
260                    .and_then(|v| v.as_str())
261                    .unwrap_or("fact")
262                    .to_string();
263                let subject = value
264                    .get("subject")
265                    .and_then(|v| v.as_str())
266                    .unwrap_or("")
267                    .to_string();
268                acc.push(LegacyItem {
269                    content,
270                    kind,
271                    subject,
272                });
273            }
274        }
275
276        if acc.is_empty() {
277            self.exhausted = true;
278            return None;
279        }
280        let take = acc.len().min(self.batch_size);
281        let first_chunk: Vec<_> = acc.drain(..take).collect();
282        self.pending = acc;
283        if self.pending.is_empty() {
284            self.exhausted = true;
285        }
286        Some(first_chunk)
287    }
288}
289
290/// Move the legacy store to `~/.oxicode/archive/memory/<timestamp>/`.
291/// Returns the destination path on success.
292pub fn archive_legacy_default() -> std::io::Result<PathBuf> {
293    let legacy = default_legacy_path();
294    let home = legacy
295        .parent()
296        .map(|p| p.to_path_buf())
297        .unwrap_or_else(|| PathBuf::from("."));
298    let ts = std::time::SystemTime::now()
299        .duration_since(std::time::UNIX_EPOCH)
300        .map(|d| d.as_secs())
301        .unwrap_or(0);
302    let dest = home
303        .join("archive")
304        .join("memory")
305        .join(format!("archive-{ts}"));
306    std::fs::create_dir_all(dest.parent().unwrap())?;
307    if legacy.exists() {
308        std::fs::rename(&legacy, &dest)?;
309    }
310    Ok(dest)
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn checkpoint_round_trip() {
319        let tmp = tempfile::tempdir().unwrap();
320        let path = tmp.path().join("brain.json");
321
322        let mut cp = Checkpoint::default();
323        cp.last_id = Some("m-42".to_string());
324        cp.migrated = 42;
325        cp.save(&path).unwrap();
326
327        let loaded = Checkpoint::load(&path);
328        assert_eq!(loaded.last_id.as_deref(), Some("m-42"));
329        assert_eq!(loaded.migrated, 42);
330    }
331
332    #[test]
333    fn checkpoint_missing_file_is_default() {
334        let cp = Checkpoint::load(Path::new("/does/not/exist/brain.json"));
335        assert_eq!(cp.last_id, None);
336        assert_eq!(cp.migrated, 0);
337    }
338
339    #[test]
340    fn legacy_reader_returns_empty_when_store_missing() {
341        let reader = LegacyMemoryReader::new(PathBuf::from("/no/such/path"));
342        let batches: Vec<_> = reader.batches(2).collect();
343        assert!(batches.is_empty());
344    }
345
346    #[test]
347    fn legacy_reader_batches_items_jsonl() {
348        let tmp = tempfile::tempdir().unwrap();
349        let items = tmp.path().join("items.jsonl");
350        std::fs::write(
351            &items,
352            "{\"content\":\"a\",\"kind\":\"fact\",\"subject\":\"s\"}\n\
353             {\"content\":\"b\",\"kind\":\"fact\",\"subject\":\"s\"}\n\
354             {\"content\":\"c\",\"kind\":\"fact\",\"subject\":\"s\"}\n",
355        )
356        .unwrap();
357        let reader = LegacyMemoryReader::new(tmp.path().to_path_buf());
358        let batches: Vec<_> = reader.batches(2).collect();
359        let total: usize = batches.iter().map(|b| b.len()).sum();
360        assert_eq!(total, 3);
361        assert_eq!(batches.len(), 2);
362        assert_eq!(batches[0].len(), 2);
363        assert_eq!(batches[1].len(), 1);
364    }
365
366    #[test]
367    fn legacy_reader_skips_malformed_lines() {
368        let tmp = tempfile::tempdir().unwrap();
369        let items = tmp.path().join("items.jsonl");
370        std::fs::write(
371            &items,
372            "{\"content\":\"a\"}\n\
373             this is not json\n\
374             {\"content\":\"b\"}\n",
375        )
376        .unwrap();
377        let reader = LegacyMemoryReader::new(tmp.path().to_path_buf());
378        let batches: Vec<_> = reader.batches(10).collect();
379        let total: usize = batches.iter().map(|b| b.len()).sum();
380        assert_eq!(total, 2);
381    }
382
383    #[test]
384    fn archive_legacy_default_moves_when_present() {
385        let tmp = tempfile::tempdir().unwrap();
386        let legacy = tmp.path().join("memory");
387        std::fs::create_dir_all(&legacy).unwrap();
388        std::fs::write(legacy.join("items.jsonl"), "{\"content\":\"x\"}\n").unwrap();
389
390        let home = tmp.path().to_string_lossy().to_string();
391        // SAFETY: tests run sequentially in this module; the env
392        // mutation is scoped to this test.
393        unsafe {
394            std::env::set_var("OXICODE_HOME", &home);
395        }
396        let dest = archive_legacy_default().unwrap();
397        unsafe {
398            std::env::remove_var("OXICODE_HOME");
399        }
400
401        assert!(
402            dest.exists(),
403            "archive path should exist: {}",
404            dest.display()
405        );
406        assert!(!legacy.exists(), "legacy path should be moved");
407        let archived = std::fs::read_to_string(dest.join("items.jsonl")).unwrap();
408        assert_eq!(archived.trim(), "{\"content\":\"x\"}");
409    }
410}