Skip to main content

beam/
migration.rs

1//! Storage backend migration: redb ↔ Persy.
2//!
3//! Provides one-shot batch migration between BEAM's two storage backends.
4//! The translation is format-only: the inner `Children` data is byte-identical
5//! between redb and Persy; only the wrapper struct ([`NodeRecord`]) differs.
6//!
7//! # On-disk formats
8//!
9//! **redb**: `TableDefinition<&str, &[u8]>` in the `beam_nodes_v1` table.
10//! Key is the node_id directly; value is `postcard(Children)` (no wrapper).
11//!
12//! **Persy**: A segment named `beam_nodes_v1` containing opaque records.
13//! Each record is `postcard(NodeRecord { node_id, children })`.
14//!
15//! # CLI
16//!
17//! ```text
18//! beam migrate --from <redb|persy> --to <redb|persy> \
19//!     --source <path> --target <path> \
20//!     [--batch-size 1000] [--force] [--dry-run]
21//! ```
22//!
23//! See [the migration plan](../docs/plans/PERSY-STORAGE-ADAPTER.md) for the
24//! full design rationale.
25
26use std::path::PathBuf;
27use web_time::Duration;
28
29use serde::{Deserialize, Serialize};
30
31use crate::types::Children;
32
33/// On-disk record format used by the Persy adapter.
34///
35/// Mirrors `crate::adapters::persy_storage::NodeRecord` (which lives behind the
36/// `persy` feature flag). Defined here as a local copy so the migration
37/// translation logic can be unit-tested without enabling the `persy` feature.
38///
39/// At runtime, the I/O module (gated on `persy`) uses the canonical definition
40/// from `persy_storage`. The two are structurally identical and serialize to
41/// the same postcard bytes, but live in separate compilation units so the
42/// migration library compiles without the Persy dependency.
43#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
44pub(crate) struct NodeRecord {
45    pub(crate) node_id: String,
46    pub(crate) children: Children,
47}
48
49// ─────────────────────────────────────────────────────────────────────────────
50// Public types
51// ─────────────────────────────────────────────────────────────────────────────
52
53/// Source/target backend selector.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55pub enum Backend {
56    /// redb backend (single-file embedded B+tree database, default)
57    Redb,
58    /// Persy backend (single-file embedded database with MVCC)
59    Persy,
60    /// fjall backend (LSM-tree, directory-based, high write throughput)
61    Fjall,
62}
63
64impl Backend {
65    /// Returns the canonical lowercase string used in CLI args and logs.
66    pub fn as_str(&self) -> &'static str {
67        match self {
68            Backend::Redb => "redb",
69            Backend::Persy => "persy",
70            Backend::Fjall => "fjall",
71        }
72    }
73}
74
75impl std::fmt::Display for Backend {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.write_str(self.as_str())
78    }
79}
80
81/// Migration options, typically parsed from CLI arguments.
82#[derive(Debug, Clone)]
83pub struct MigrateOpts {
84    /// Source backend format
85    pub from: Backend,
86    /// Target backend format
87    pub to: Backend,
88    /// Path to source database (file for redb, file for Persy)
89    pub source_path: PathBuf,
90    /// Path to target database (will be created)
91    pub target_path: PathBuf,
92    /// Records per write batch (default: 1000)
93    pub batch_size: usize,
94    /// Overwrite target if it already exists
95    pub force: bool,
96    /// Preview the migration without writing
97    pub dry_run: bool,
98}
99
100/// Result of a completed migration, returned to the caller for reporting.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct MigrationReport {
103    /// Number of records successfully written to target
104    pub records_migrated: usize,
105    /// Number of records found in source
106    pub source_count: usize,
107    /// Number of records in target after migration (== `records_migrated` unless partial)
108    pub target_count_after: usize,
109    /// Total wall-clock duration
110    pub elapsed: Duration,
111    /// Whether this was a dry run
112    pub dry_run: bool,
113}
114
115/// All migration error variants.
116///
117/// Uses [`thiserror`] for idiomatic error definitions. Each variant carries
118/// enough context to be useful in CLI output (path, backend, underlying error).
119#[derive(thiserror::Error, Debug)]
120pub enum MigrateError {
121    #[error("redb error at {path}: {source}")]
122    Redb {
123        path: PathBuf,
124        #[source]
125        source: redb::Error,
126    },
127
128    #[error("redb transaction error at {path}: {source}")]
129    RedbTx {
130        path: PathBuf,
131        #[source]
132        source: redb::TransactionError,
133    },
134
135    #[error("redb table error at {path}: {source}")]
136    RedbTable {
137        path: PathBuf,
138        #[source]
139        source: redb::TableError,
140    },
141
142    #[error("redb commit error at {path}: {source}")]
143    RedbCommit {
144        path: PathBuf,
145        #[source]
146        source: redb::CommitError,
147    },
148
149    #[error("persy error: {0}")]
150    Persy(String),
151
152    #[error("fjall error: {0}")]
153    Fjall(String),
154
155    #[error("postcard error: {0}")]
156    Postcard(#[from] postcard::Error),
157
158    #[error("io error: {0}")]
159    Io(#[from] std::io::Error),
160
161    #[error("json error: {0}")]
162    Json(#[from] serde_json::Error),
163
164    #[error("target already exists at {0} (use --force to overwrite)")]
165    TargetExists(PathBuf),
166
167    #[error("unsupported migration: {from} -> {to} (use --from and --to with different backends)")]
168    Unsupported { from: Backend, to: Backend },
169
170    #[error("invalid backend string: {0} (expected 'redb', 'persy', or 'fjall')")]
171    InvalidBackend(String),
172}
173
174impl MigrateError {
175    /// Parse a backend name from a CLI string. Returns [`MigrateError::InvalidBackend`]
176    /// if the string is not recognized.
177    pub fn parse_backend(s: &str) -> Result<Backend, MigrateError> {
178        match s.to_lowercase().as_str() {
179            "redb" => Ok(Backend::Redb),
180            "persy" => Ok(Backend::Persy),
181            "fjall" => Ok(Backend::Fjall),
182            _ => Err(MigrateError::InvalidBackend(s.to_string())),
183        }
184    }
185}
186
187// ─────────────────────────────────────────────────────────────────────────────
188// Pure translation functions (no I/O — trivially testable)
189// ─────────────────────────────────────────────────────────────────────────────
190
191/// Translates a redb record (node_id + raw value bytes) into a Persy record payload.
192///
193/// The inner `Children` data is deserialized from the redb format and re-serialized
194/// inside a [`NodeRecord`] wrapper for Persy. Both formats use postcard on `Children`,
195/// so the inner bytes are preserved exactly.
196///
197/// # Errors
198///
199/// Returns [`MigrateError::Postcard`] if the input bytes are not a valid postcard-encoded
200/// `Children` map.
201pub fn redb_to_persy_payload(key: &str, value: &[u8]) -> Result<Vec<u8>, MigrateError> {
202    let children: Children = postcard::from_bytes(value)?;
203    let record = NodeRecord {
204        node_id: key.to_string(),
205        children,
206    };
207    Ok(postcard::to_allocvec(&record)?)
208}
209
210/// Translates a Persy record payload into a redb (node_id, value_bytes) pair.
211///
212/// Counterpart to [`redb_to_persy_payload`]: unwraps the [`NodeRecord`], re-serializes
213/// the `Children` map in redb's bare-bytes format.
214///
215/// # Errors
216///
217/// Returns [`MigrateError::Postcard`] if the input bytes are not a valid postcard-encoded
218/// `NodeRecord`.
219pub fn persy_to_redb_record(payload: &[u8]) -> Result<(String, Vec<u8>), MigrateError> {
220    let record: NodeRecord = postcard::from_bytes(payload)?;
221    let children_bytes = postcard::to_allocvec(&record.children)?;
222    Ok((record.node_id, children_bytes))
223}
224
225// ─────────────────────────────────────────────────────────────────────────────
226// Canonical intermediate format
227// ─────────────────────────────────────────────────────────────────────────────
228
229/// A single graph node record in the canonical migration format.
230///
231/// All BEAM storage backends store the same logical data — a `node_id`
232/// mapped to a [`Children`] map. The on-disk encoding varies (bare bytes
233/// vs. `NodeRecord`-wrapped, string keys vs. prefixed keys), but the
234/// semantic content is identical.
235///
236/// This struct is the "lingua franca": every reader produces
237/// `Vec<MigrationRecord>`, every writer accepts `&[MigrationRecord]`.
238/// The `children_bytes` field is always `postcard(Children)` — the bare
239/// serialized form that redb and fjall store directly, and that persy
240/// wraps in [`NodeRecord`].
241#[derive(Debug, Clone)]
242pub struct MigrationRecord {
243    /// The graph node identifier (e.g. `"users/alice"`, `""` for root).
244    pub node_id: String,
245    /// Bare `postcard(Children)` bytes — the universal value format.
246    pub children_bytes: Vec<u8>,
247}
248
249// ─────────────────────────────────────────────────────────────────────────────
250// Fjall key translation — pure functions (no feature gate needed)
251// ─────────────────────────────────────────────────────────────────────────────
252
253/// Fjall key prefix (mirrors `fjall_storage::KEY_PREFIX`).
254///
255/// Local copy so the translation functions compile without the `fjall`
256/// feature, following the same pattern as [`NodeRecord`].
257const FJALL_KEY_PREFIX: u8 = 0x00;
258
259/// Encodes a node_id string as a fjall keyspace key.
260///
261/// Prepends [`FJALL_KEY_PREFIX`] to avoid fjall's LSM-tree panic on empty
262/// keys. The value bytes are identical between redb and fjall (both bare
263/// `postcard(Children)`), so only the key needs encoding.
264pub fn redb_to_fjall_key(node_id: &str) -> Vec<u8> {
265    let mut key = vec![FJALL_KEY_PREFIX];
266    key.extend_from_slice(node_id.as_bytes());
267    key
268}
269
270/// Decodes a fjall keyspace key back to a node_id string.
271///
272/// Strips the [`FJALL_KEY_PREFIX`] byte and interprets the remaining bytes
273/// as UTF-8. Returns [`MigrateError::Fjall`] if the key is malformed.
274pub fn fjall_key_to_node_id(key: &[u8]) -> Result<String, MigrateError> {
275    if key.is_empty() || key[0] != FJALL_KEY_PREFIX {
276        return Err(MigrateError::Fjall(format!("invalid fjall key: {:?}", key)));
277    }
278    std::str::from_utf8(&key[1..])
279        .map(|s| s.to_string())
280        .map_err(|e| MigrateError::Fjall(format!("fjall key UTF-8 decode: {:?}", e)))
281}
282
283// ─────────────────────────────────────────────────────────────────────────────
284// I/O orchestration — requires at least one non-redb backend feature
285// ─────────────────────────────────────────────────────────────────────────────
286
287#[cfg(any(feature = "persy", feature = "fjall"))]
288pub(crate) mod io {
289    use super::*;
290    use web_time::Instant;
291
292    use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
293
294    const REDB_BEAM_NODES: TableDefinition<&str, &[u8]> = TableDefinition::new("beam_nodes_v1");
295
296    // ───────────────────────────────────────────────────────────────────────
297    // Readers — one per backend, each produces Vec<MigrationRecord>
298    // ───────────────────────────────────────────────────────────────────────
299
300    /// Reads all records from a redb database file.
301    ///
302    /// redb stores keys as `&str` (node_id) and values as `&[u8]`
303    /// (`postcard(Children)` bare bytes) — already canonical.
304    fn read_redb(path: &std::path::Path) -> Result<Vec<MigrationRecord>, MigrateError> {
305        let db = Database::open(path).map_err(|source| MigrateError::Redb {
306            path: path.to_path_buf(),
307            source: source.into(),
308        })?;
309        let tx = db.begin_read().map_err(|source| MigrateError::RedbTx {
310            path: path.to_path_buf(),
311            source,
312        })?;
313
314        // Empty source DB (no table yet) → zero records. This is valid.
315        let table = match tx.open_table(REDB_BEAM_NODES) {
316            Ok(t) => t,
317            Err(e) => {
318                use redb::TableError;
319                if matches!(e, TableError::TableDoesNotExist { .. }) {
320                    return Ok(Vec::new());
321                }
322                return Err(MigrateError::RedbTable {
323                    path: path.to_path_buf(),
324                    source: e,
325                });
326            }
327        };
328
329        let mut records = Vec::new();
330        let iter = table.iter().map_err(|source| MigrateError::RedbTable {
331            path: path.to_path_buf(),
332            source: redb::TableError::Storage(source),
333        })?;
334
335        for entry in iter {
336            let (key_guard, value_guard) = entry.map_err(|source| MigrateError::RedbTable {
337                path: path.to_path_buf(),
338                source: redb::TableError::Storage(source),
339            })?;
340            records.push(MigrationRecord {
341                node_id: key_guard.value().to_string(),
342                children_bytes: value_guard.value().to_vec(),
343            });
344        }
345
346        Ok(records)
347    }
348
349    /// Reads all records from a Persy database file.
350    ///
351    /// Persy stores records as `postcard(NodeRecord { node_id, children })`
352    /// — the reader unwraps each into canonical `(node_id, children_bytes)`.
353    #[cfg(feature = "persy")]
354    fn read_persy(path: &std::path::Path) -> Result<Vec<MigrationRecord>, MigrateError> {
355        use crate::adapters::persy_storage::BEAM_NODES as PERSY_BEAM_NODES;
356
357        let db = persy::Persy::open(path, persy::Config::new())
358            .map_err(|source| MigrateError::Persy(format!("{}: {}", path.display(), source)))?;
359        let segment_id = db
360            .solve_segment_id(PERSY_BEAM_NODES)
361            .map_err(|e| MigrateError::Persy(format!("solve_segment_id: {}", e)))?;
362
363        let scan = db.scan(segment_id).map_err(|source| {
364            MigrateError::Persy(format!("{}: scan: {}", path.display(), source))
365        })?;
366
367        let mut records = Vec::new();
368        for (_id, bytes) in scan {
369            let (node_id, children_bytes) = persy_to_redb_record(&bytes)?;
370            records.push(MigrationRecord {
371                node_id,
372                children_bytes,
373            });
374        }
375
376        Ok(records)
377    }
378
379    /// Reads all records from a fjall database directory.
380    ///
381    /// Fjall stores keys as `[0x00] ++ node_id_bytes` and values as
382    /// `postcard(Children)` bare bytes. The reader strips the key prefix
383    /// and yields canonical `(node_id, children_bytes)`.
384    #[cfg(feature = "fjall")]
385    fn read_fjall(path: &std::path::Path) -> Result<Vec<MigrationRecord>, MigrateError> {
386        let db = fjall::Database::builder(path)
387            .open()
388            .map_err(|e| MigrateError::Fjall(format!("{}: {}", path.display(), e)))?;
389        let keyspace = db
390            .keyspace("beam_nodes_v1", fjall::KeyspaceCreateOptions::default)
391            .map_err(|e| MigrateError::Fjall(format!("keyspace: {}", e)))?;
392
393        let mut records = Vec::new();
394        for item in keyspace.iter() {
395            // Guard::into_inner() returns Result<KvPair> = Result<(Vec<u8>, Slice)>
396            let (key, value) = item
397                .into_inner()
398                .map_err(|e| MigrateError::Fjall(format!("iter: {}", e)))?;
399            let node_id = fjall_key_to_node_id(&key)?;
400            records.push(MigrationRecord {
401                node_id,
402                children_bytes: value.to_vec(),
403            });
404        }
405
406        Ok(records)
407    }
408
409    // ───────────────────────────────────────────────────────────────────────
410    // Writers — one per backend, each accepts &[MigrationRecord]
411    // ───────────────────────────────────────────────────────────────────────
412
413    /// Writes records to a redb database file.
414    ///
415    /// redb stores keys as `&str` and values as `&[u8]` — canonical format
416    /// maps directly. Commits in batches of `batch_size` to bound memory
417    /// usage for large migrations.
418    fn write_redb(
419        path: &std::path::Path,
420        records: &[MigrationRecord],
421        batch_size: usize,
422    ) -> Result<usize, MigrateError> {
423        let db = Database::create(path).map_err(|source| MigrateError::Redb {
424            path: path.to_path_buf(),
425            source: source.into(),
426        })?;
427
428        let mut migrated = 0usize;
429        let mut batch: Vec<(&str, &[u8])> = Vec::with_capacity(batch_size);
430
431        for record in records {
432            batch.push((&record.node_id, &record.children_bytes));
433
434            if batch.len() >= batch_size {
435                let txn = db.begin_write().map_err(|source| MigrateError::RedbTx {
436                    path: path.to_path_buf(),
437                    source,
438                })?;
439                {
440                    let mut table = txn.open_table(REDB_BEAM_NODES).map_err(|source| {
441                        MigrateError::RedbTable {
442                            path: path.to_path_buf(),
443                            source,
444                        }
445                    })?;
446                    for (k, v) in &batch {
447                        table
448                            .insert(*k, *v)
449                            .map_err(|source| MigrateError::RedbTable {
450                                path: path.to_path_buf(),
451                                source: redb::TableError::Storage(source),
452                            })?;
453                    }
454                }
455                txn.commit().map_err(|source| MigrateError::RedbCommit {
456                    path: path.to_path_buf(),
457                    source,
458                })?;
459                migrated += batch.len();
460                batch.clear();
461            }
462        }
463
464        // Flush remaining records
465        if !batch.is_empty() {
466            let txn = db.begin_write().map_err(|source| MigrateError::RedbTx {
467                path: path.to_path_buf(),
468                source,
469            })?;
470            {
471                let mut table =
472                    txn.open_table(REDB_BEAM_NODES)
473                        .map_err(|source| MigrateError::RedbTable {
474                            path: path.to_path_buf(),
475                            source,
476                        })?;
477                for (k, v) in &batch {
478                    table
479                        .insert(*k, *v)
480                        .map_err(|source| MigrateError::RedbTable {
481                            path: path.to_path_buf(),
482                            source: redb::TableError::Storage(source),
483                        })?;
484                }
485            }
486            txn.commit().map_err(|source| MigrateError::RedbCommit {
487                path: path.to_path_buf(),
488                source,
489            })?;
490            migrated += batch.len();
491        }
492
493        Ok(migrated)
494    }
495
496    /// Writes records to a Persy database file.
497    ///
498    /// Persy stores records as `postcard(NodeRecord { node_id, children })`.
499    /// The writer wraps each canonical record via `redb_to_persy_payload()`.
500    #[cfg(feature = "persy")]
501    fn write_persy(
502        path: &std::path::Path,
503        records: &[MigrationRecord],
504    ) -> Result<usize, MigrateError> {
505        use crate::adapters::persy_storage::BEAM_NODES as PERSY_BEAM_NODES;
506
507        // Materialize all payloads before opening the target — same pattern
508        // as the original migrate_redb_to_persy.
509        let payloads: Vec<Vec<u8>> = records
510            .iter()
511            .map(|r| redb_to_persy_payload(&r.node_id, &r.children_bytes))
512            .collect::<Result<_, _>>()?;
513
514        let target_db = persy::Persy::open_or_create_with(
515            path.to_string_lossy().as_ref(),
516            persy::Config::new(),
517            |persy_db| -> Result<(), Box<dyn std::error::Error>> {
518                let mut create_tx = persy_db.begin()?;
519                create_tx.create_segment(PERSY_BEAM_NODES)?;
520                create_tx.prepare()?.commit()?;
521                Ok(())
522            },
523        )
524        .map_err(|source| MigrateError::Persy(format!("{}: {}", path.display(), source)))?;
525
526        let target_seg = target_db
527            .solve_segment_id(PERSY_BEAM_NODES)
528            .map_err(|e| MigrateError::Persy(format!("solve_segment_id: {}", e)))?;
529
530        let mut tx = target_db
531            .begin()
532            .map_err(|e| MigrateError::Persy(format!("begin: {}", e)))?;
533
534        for payload in &payloads {
535            tx.insert(target_seg, payload.as_slice())
536                .map_err(|e| MigrateError::Persy(format!("insert: {}", e)))?;
537        }
538
539        tx.prepare()
540            .map_err(|e| MigrateError::Persy(format!("prepare: {}", e)))?
541            .commit()
542            .map_err(|e| MigrateError::Persy(format!("commit: {}", e)))?;
543
544        drop(target_db);
545        Ok(payloads.len())
546    }
547
548    /// Writes records to a fjall database directory.
549    ///
550    /// Fjall stores keys as `[0x00] ++ node_id_bytes` and values as
551    /// `postcard(Children)` bare bytes. The writer encodes keys via
552    /// `redb_to_fjall_key()` and passes values through unchanged.
553    #[cfg(feature = "fjall")]
554    fn write_fjall(
555        path: &std::path::Path,
556        records: &[MigrationRecord],
557    ) -> Result<usize, MigrateError> {
558        let db = fjall::Database::builder(path)
559            .open()
560            .map_err(|e| MigrateError::Fjall(format!("{}: {}", path.display(), e)))?;
561        let keyspace = db
562            .keyspace("beam_nodes_v1", fjall::KeyspaceCreateOptions::default)
563            .map_err(|e| MigrateError::Fjall(format!("keyspace: {}", e)))?;
564
565        for record in records {
566            let key = redb_to_fjall_key(&record.node_id);
567            keyspace
568                .insert(key, &record.children_bytes)
569                .map_err(|e| MigrateError::Fjall(format!("insert: {}", e)))?;
570        }
571
572        // Explicit fsync for durability — the target is a fresh database
573        // and the migration is complete.
574        db.persist(fjall::PersistMode::SyncAll)
575            .map_err(|e| MigrateError::Fjall(format!("persist: {}", e)))?;
576
577        Ok(records.len())
578    }
579
580    // ───────────────────────────────────────────────────────────────────────
581    // Dispatcher — read source, write target (reader/writer pattern)
582    // ───────────────────────────────────────────────────────────────────────
583
584    /// Run a storage migration.
585    ///
586    /// Reads all records from the source backend into canonical
587    /// [`MigrationRecord`] format, then writes them to the target backend.
588    /// The reader/writer pattern means adding a new backend requires only
589    /// one reader and one writer — O(N) functions, not O(N²) pairwise.
590    ///
591    /// # Errors
592    ///
593    /// Returns [`MigrateError::Unsupported`] if `from == to`, or any backend
594    /// I/O error if the source can't be read or the target can't be written.
595    pub fn migrate(opts: &MigrateOpts) -> Result<MigrationReport, MigrateError> {
596        if opts.from == opts.to {
597            return Err(MigrateError::Unsupported {
598                from: opts.from,
599                to: opts.to,
600            });
601        }
602
603        if !opts.dry_run && opts.target_path.exists() && !opts.force {
604            return Err(MigrateError::TargetExists(opts.target_path.clone()));
605        }
606
607        let start = Instant::now();
608
609        // Read all records from source into canonical format.
610        // Each arm is feature-gated via block-level cfg so the match is
611        // exhaustive under any combination of backend features.
612        let records = match opts.from {
613            Backend::Redb => read_redb(&opts.source_path)?,
614            Backend::Persy => {
615                #[cfg(feature = "persy")]
616                {
617                    read_persy(&opts.source_path)?
618                }
619                #[cfg(not(feature = "persy"))]
620                {
621                    return Err(MigrateError::Unsupported {
622                        from: opts.from,
623                        to: opts.to,
624                    });
625                }
626            }
627            Backend::Fjall => {
628                #[cfg(feature = "fjall")]
629                {
630                    read_fjall(&opts.source_path)?
631                }
632                #[cfg(not(feature = "fjall"))]
633                {
634                    return Err(MigrateError::Unsupported {
635                        from: opts.from,
636                        to: opts.to,
637                    });
638                }
639            }
640        };
641        let source_count = records.len();
642
643        // Dry-run: count only, no writes
644        if opts.dry_run {
645            return Ok(MigrationReport {
646                records_migrated: source_count,
647                source_count,
648                target_count_after: 0,
649                elapsed: start.elapsed(),
650                dry_run: true,
651            });
652        }
653
654        // Write all records to target
655        let migrated = match opts.to {
656            Backend::Redb => write_redb(&opts.target_path, &records, opts.batch_size)?,
657            Backend::Persy => {
658                #[cfg(feature = "persy")]
659                {
660                    write_persy(&opts.target_path, &records)?
661                }
662                #[cfg(not(feature = "persy"))]
663                {
664                    return Err(MigrateError::Unsupported {
665                        from: opts.from,
666                        to: opts.to,
667                    });
668                }
669            }
670            Backend::Fjall => {
671                #[cfg(feature = "fjall")]
672                {
673                    write_fjall(&opts.target_path, &records)?
674                }
675                #[cfg(not(feature = "fjall"))]
676                {
677                    return Err(MigrateError::Unsupported {
678                        from: opts.from,
679                        to: opts.to,
680                    });
681                }
682            }
683        };
684
685        Ok(MigrationReport {
686            records_migrated: migrated,
687            source_count,
688            target_count_after: migrated,
689            elapsed: start.elapsed(),
690            dry_run: false,
691        })
692    }
693}
694
695#[cfg(any(feature = "persy", feature = "fjall"))]
696pub use io::migrate;
697
698// ─────────────────────────────────────────────────────────────────────────────
699// Tests
700// ─────────────────────────────────────────────────────────────────────────────
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705    use crate::types::{NodeData, Value};
706    use arena_btreemap::BTreeMap;
707
708    /// Helper: build a representative `Children` map using the real BEAM value types.
709    fn make_test_children() -> Children {
710        let mut children = BTreeMap::default();
711        children.insert(
712            "greeting".to_string(),
713            NodeData {
714                value: Value::Text("hello".to_string()),
715                updated_at: 12345.0,
716            },
717        );
718        children.insert(
719            "count".to_string(),
720            NodeData {
721                value: Value::Number(42.0),
722                updated_at: 67890.0,
723            },
724        );
725        children.insert(
726            "flag".to_string(),
727            NodeData {
728                value: Value::Bit(true),
729                updated_at: 11111.0,
730            },
731        );
732        children
733    }
734
735    #[test]
736    fn redb_to_persy_roundtrips_children() {
737        let children = make_test_children();
738        let original_bytes = postcard::to_allocvec(&children).unwrap();
739
740        let translated = redb_to_persy_payload("test-node", &original_bytes).unwrap();
741        let record: NodeRecord = postcard::from_bytes(&translated).unwrap();
742
743        assert_eq!(record.node_id, "test-node");
744        assert_eq!(record.children, children);
745    }
746
747    #[test]
748    fn persy_to_redb_roundtrips_children() {
749        let children = make_test_children();
750        let record = NodeRecord {
751            node_id: "test-node".to_string(),
752            children: children.clone(),
753        };
754        let payload = postcard::to_allocvec(&record).unwrap();
755
756        let (key, value_bytes) = persy_to_redb_record(&payload).unwrap();
757
758        assert_eq!(key, "test-node");
759        let recovered: Children = postcard::from_bytes(&value_bytes).unwrap();
760        assert_eq!(recovered, children);
761    }
762
763    #[test]
764    fn translation_is_pure_and_deterministic() {
765        let children = make_test_children();
766        let bytes = postcard::to_allocvec(&children).unwrap();
767
768        let result1 = redb_to_persy_payload("k", &bytes).unwrap();
769        let result2 = redb_to_persy_payload("k", &bytes).unwrap();
770
771        assert_eq!(result1, result2, "same input must produce same output");
772    }
773
774    #[test]
775    fn empty_children_translates_cleanly() {
776        let empty: Children = BTreeMap::default();
777        let bytes = postcard::to_allocvec(&empty).unwrap();
778
779        let translated = redb_to_persy_payload("empty-node", &bytes).unwrap();
780        let record: NodeRecord = postcard::from_bytes(&translated).unwrap();
781
782        assert_eq!(record.node_id, "empty-node");
783        assert!(record.children.is_empty());
784    }
785
786    #[test]
787    fn all_value_variants_preserved() {
788        // Build children using every Value variant to confirm roundtrip fidelity.
789        let mut children = BTreeMap::default();
790        children.insert(
791            "null".to_string(),
792            NodeData {
793                value: Value::Null,
794                updated_at: 1.0,
795            },
796        );
797        children.insert(
798            "bit".to_string(),
799            NodeData {
800                value: Value::Bit(false),
801                updated_at: 2.0,
802            },
803        );
804        children.insert(
805            "num".to_string(),
806            NodeData {
807                value: Value::Number(-3.15),
808                updated_at: 3.0,
809            },
810        );
811        children.insert(
812            "text".to_string(),
813            NodeData {
814                value: Value::Text("unicode: ☃ snowman".to_string()),
815                updated_at: 4.0,
816            },
817        );
818        children.insert(
819            "link".to_string(),
820            NodeData {
821                value: Value::Link("node/abc".to_string()),
822                updated_at: 5.0,
823            },
824        );
825
826        let bytes = postcard::to_allocvec(&children).unwrap();
827        let translated = redb_to_persy_payload("root", &bytes).unwrap();
828        let record: NodeRecord = postcard::from_bytes(&translated).unwrap();
829
830        assert_eq!(record.children, children);
831        // Spot-check the Link variant — it's the one most likely to silently corrupt.
832        if let Value::Link(ref s) = record.children.get("link").unwrap().value {
833            assert_eq!(s, "node/abc");
834        } else {
835            panic!("link value not preserved");
836        }
837    }
838
839    #[test]
840    fn backend_parse_accepts_lowercase() {
841        assert_eq!(MigrateError::parse_backend("redb").unwrap(), Backend::Redb);
842        assert_eq!(
843            MigrateError::parse_backend("persy").unwrap(),
844            Backend::Persy
845        );
846    }
847
848    #[test]
849    fn backend_parse_accepts_mixed_case() {
850        assert_eq!(MigrateError::parse_backend("Redb").unwrap(), Backend::Redb);
851        assert_eq!(
852            MigrateError::parse_backend("PERSY").unwrap(),
853            Backend::Persy
854        );
855    }
856
857    #[test]
858    fn backend_parse_rejects_unknown() {
859        assert!(matches!(
860            MigrateError::parse_backend("sqlite"),
861            Err(MigrateError::InvalidBackend(_))
862        ));
863    }
864
865    #[test]
866    fn backend_as_str_roundtrips() {
867        assert_eq!(Backend::Redb.as_str(), "redb");
868        assert_eq!(Backend::Persy.as_str(), "persy");
869    }
870
871    #[test]
872    fn unsorted_keys_preserved_after_roundtrip() {
873        // BTreeMap sorts by key, so insertion order doesn't matter —
874        // but verify the roundtrip doesn't somehow scramble the key set.
875        let mut children: Children = BTreeMap::default();
876        children.insert(
877            "z".to_string(),
878            NodeData {
879                value: Value::Text("last".to_string()),
880                updated_at: 1.0,
881            },
882        );
883        children.insert(
884            "a".to_string(),
885            NodeData {
886                value: Value::Text("first".to_string()),
887                updated_at: 2.0,
888            },
889        );
890        children.insert(
891            "m".to_string(),
892            NodeData {
893                value: Value::Text("middle".to_string()),
894                updated_at: 3.0,
895            },
896        );
897
898        let bytes = postcard::to_allocvec(&children).unwrap();
899        let translated = redb_to_persy_payload("k", &bytes).unwrap();
900        let record: NodeRecord = postcard::from_bytes(&translated).unwrap();
901
902        let keys: Vec<&String> = record.children.keys().collect();
903        assert_eq!(keys, vec!["a", "m", "z"]); // BTreeMap ordering
904    }
905
906    // ───────────────────────────────────────────────────────────────────────
907    // Fjall key translation tests — compile without any feature gate
908    // ───────────────────────────────────────────────────────────────────────
909
910    #[test]
911    fn redb_to_fjall_key_adds_prefix() {
912        // Empty string (root soul) gets a single prefix byte
913        let key = redb_to_fjall_key("");
914        assert_eq!(key, vec![0x00]);
915
916        // Non-empty string gets prefix + bytes
917        let key = redb_to_fjall_key("abc");
918        assert_eq!(key, vec![0x00, b'a', b'b', b'c']);
919    }
920
921    #[test]
922    fn fjall_key_to_node_id_strips_prefix() {
923        assert_eq!(fjall_key_to_node_id(&[0x00]).unwrap(), "");
924        assert_eq!(
925            fjall_key_to_node_id(&[0x00, b'a', b'b', b'c']).unwrap(),
926            "abc"
927        );
928    }
929
930    #[test]
931    fn fjall_key_roundtrip() {
932        for node_id in &["", "root", "users/alice", "unicode/☃"] {
933            let key = redb_to_fjall_key(node_id);
934            let decoded = fjall_key_to_node_id(&key).unwrap();
935            assert_eq!(decoded, *node_id);
936        }
937    }
938
939    #[test]
940    fn fjall_key_to_node_id_rejects_empty() {
941        assert!(fjall_key_to_node_id(&[]).is_err());
942    }
943
944    #[test]
945    fn fjall_key_to_node_id_rejects_bad_prefix() {
946        assert!(fjall_key_to_node_id(&[0x01, b'a']).is_err());
947        assert!(fjall_key_to_node_id(&[0xFF]).is_err());
948    }
949
950    #[test]
951    fn fjall_key_to_node_id_rejects_invalid_utf8() {
952        // 0xFF 0xFE is not valid UTF-8
953        assert!(fjall_key_to_node_id(&[0x00, 0xFF, 0xFE]).is_err());
954    }
955
956    #[test]
957    fn backend_parse_accepts_fjall() {
958        assert_eq!(
959            MigrateError::parse_backend("fjall").unwrap(),
960            Backend::Fjall
961        );
962        assert_eq!(
963            MigrateError::parse_backend("FJALL").unwrap(),
964            Backend::Fjall
965        );
966    }
967
968    #[test]
969    fn backend_as_str_fjall() {
970        assert_eq!(Backend::Fjall.as_str(), "fjall");
971    }
972}