1use std::path::PathBuf;
27use web_time::Duration;
28
29use serde::{Deserialize, Serialize};
30
31use crate::types::Children;
32
33#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55pub enum Backend {
56 Redb,
58 Persy,
60}
61
62impl Backend {
63 pub fn as_str(&self) -> &'static str {
65 match self {
66 Backend::Redb => "redb",
67 Backend::Persy => "persy",
68 }
69 }
70}
71
72impl std::fmt::Display for Backend {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 f.write_str(self.as_str())
75 }
76}
77
78#[derive(Debug, Clone)]
80pub struct MigrateOpts {
81 pub from: Backend,
83 pub to: Backend,
85 pub source_path: PathBuf,
87 pub target_path: PathBuf,
89 pub batch_size: usize,
91 pub force: bool,
93 pub dry_run: bool,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct MigrationReport {
100 pub records_migrated: usize,
102 pub source_count: usize,
104 pub target_count_after: usize,
106 pub elapsed: Duration,
108 pub dry_run: bool,
110}
111
112#[derive(thiserror::Error, Debug)]
117pub enum MigrateError {
118 #[error("redb error at {path}: {source}")]
119 Redb {
120 path: PathBuf,
121 #[source]
122 source: redb::Error,
123 },
124
125 #[error("redb transaction error at {path}: {source}")]
126 RedbTx {
127 path: PathBuf,
128 #[source]
129 source: redb::TransactionError,
130 },
131
132 #[error("redb table error at {path}: {source}")]
133 RedbTable {
134 path: PathBuf,
135 #[source]
136 source: redb::TableError,
137 },
138
139 #[error("redb commit error at {path}: {source}")]
140 RedbCommit {
141 path: PathBuf,
142 #[source]
143 source: redb::CommitError,
144 },
145
146 #[error("persy error: {0}")]
147 Persy(String),
148
149 #[error("postcard error: {0}")]
150 Postcard(#[from] postcard::Error),
151
152 #[error("io error: {0}")]
153 Io(#[from] std::io::Error),
154
155 #[error("json error: {0}")]
156 Json(#[from] serde_json::Error),
157
158 #[error("target already exists at {0} (use --force to overwrite)")]
159 TargetExists(PathBuf),
160
161 #[error("unsupported migration: {from} -> {to} (use --from and --to with different backends)")]
162 Unsupported { from: Backend, to: Backend },
163
164 #[error("invalid backend string: {0} (expected 'redb' or 'persy')")]
165 InvalidBackend(String),
166}
167
168impl MigrateError {
169 pub fn parse_backend(s: &str) -> Result<Backend, MigrateError> {
172 match s.to_lowercase().as_str() {
173 "redb" => Ok(Backend::Redb),
174 "persy" => Ok(Backend::Persy),
175 _ => Err(MigrateError::InvalidBackend(s.to_string())),
176 }
177 }
178}
179
180pub fn redb_to_persy_payload(key: &str, value: &[u8]) -> Result<Vec<u8>, MigrateError> {
195 let children: Children = postcard::from_bytes(value)?;
196 let record = NodeRecord {
197 node_id: key.to_string(),
198 children,
199 };
200 Ok(postcard::to_allocvec(&record)?)
201}
202
203pub fn persy_to_redb_record(payload: &[u8]) -> Result<(String, Vec<u8>), MigrateError> {
213 let record: NodeRecord = postcard::from_bytes(payload)?;
214 let children_bytes = postcard::to_allocvec(&record.children)?;
215 Ok((record.node_id, children_bytes))
216}
217
218#[cfg(feature = "persy")]
223pub(crate) mod io {
224 use super::*;
225 use web_time::Instant;
226
227 use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
228
229 use crate::adapters::persy_storage::BEAM_NODES as PERSY_BEAM_NODES;
230
231 const REDB_BEAM_NODES: TableDefinition<&str, &[u8]> = TableDefinition::new("beam_nodes_v1");
232
233 pub fn migrate(opts: &MigrateOpts) -> Result<MigrationReport, MigrateError> {
240 if opts.from == opts.to {
241 return Err(MigrateError::Unsupported {
242 from: opts.from,
243 to: opts.to,
244 });
245 }
246
247 if !opts.dry_run && opts.target_path.exists() && !opts.force {
248 return Err(MigrateError::TargetExists(opts.target_path.clone()));
249 }
250
251 let start = Instant::now();
252
253 let (source_count, migrated) = match (opts.from, opts.to) {
254 (Backend::Redb, Backend::Persy) => migrate_redb_to_persy(opts)?,
255 (Backend::Persy, Backend::Redb) => migrate_persy_to_redb(opts)?,
256 (Backend::Redb, Backend::Redb) | (Backend::Persy, Backend::Persy) => {
257 unreachable!("from == to caught above")
258 }
259 };
260
261 Ok(MigrationReport {
262 records_migrated: if opts.dry_run { source_count } else { migrated },
266 source_count,
267 target_count_after: if opts.dry_run { 0 } else { migrated },
268 elapsed: start.elapsed(),
269 dry_run: opts.dry_run,
270 })
271 }
272
273 fn migrate_redb_to_persy(opts: &MigrateOpts) -> Result<(usize, usize), MigrateError> {
274 let src_db = Database::open(&opts.source_path).map_err(|source| MigrateError::Redb {
282 path: opts.source_path.clone(),
283 source: source.into(),
284 })?;
285 let src_tx = src_db.begin_read().map_err(|source| MigrateError::RedbTx {
286 path: opts.source_path.clone(),
287 source,
288 })?;
289 let src_table = match src_tx.open_table(REDB_BEAM_NODES) {
293 Ok(t) => t,
294 Err(e) => {
295 use redb::TableError;
296 if matches!(e, TableError::TableDoesNotExist { .. }) {
297 return Ok((0, 0));
299 }
300 return Err(MigrateError::RedbTable {
301 path: opts.source_path.clone(),
302 source: e,
303 });
304 }
305 };
306
307 let mut payloads: Vec<Vec<u8>> = Vec::new();
308 {
309 let iter = src_table.iter().map_err(|source| MigrateError::RedbTable {
310 path: opts.source_path.clone(),
311 source: redb::TableError::Storage(source),
312 })?;
313 for entry in iter {
314 let (key_guard, value_guard) = entry.map_err(|source| MigrateError::RedbTable {
315 path: opts.source_path.clone(),
316 source: redb::TableError::Storage(source),
317 })?;
318 let key = key_guard.value();
319 let value = value_guard.value();
320 payloads.push(redb_to_persy_payload(key, value)?);
321 }
322 }
323 let source_count = payloads.len();
324 drop(src_table);
325 drop(src_tx);
326 drop(src_db);
327
328 if opts.dry_run {
330 return Ok((source_count, 0));
331 }
332
333 let target_db = persy::Persy::open_or_create_with(
357 opts.target_path.to_string_lossy().as_ref(),
358 persy::Config::new(),
359 |persy_db| -> Result<(), Box<dyn std::error::Error>> {
360 let mut create_tx = persy_db
361 .begin()
362 .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
363 create_tx
364 .create_segment(PERSY_BEAM_NODES)
365 .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
366 create_tx
367 .prepare()
368 .map_err(Box::<dyn std::error::Error>::from)?
369 .commit()
370 .map_err(Box::<dyn std::error::Error>::from)?;
371 Ok(())
372 },
373 )
374 .map_err(|source| {
375 MigrateError::Persy(format!(
376 "{}: {}\nhelp: ensure the target directory is writable",
377 opts.target_path.display(),
378 source
379 ))
380 })?;
381
382 let target_seg = target_db.solve_segment_id(PERSY_BEAM_NODES).map_err(|e| {
383 MigrateError::Persy(format!(
384 "{}: solve_segment_id: {}",
385 opts.target_path.display(),
386 e
387 ))
388 })?;
389
390 let mut tx = target_db.begin().map_err(|source| {
391 MigrateError::Persy(format!("{}: begin: {}", opts.target_path.display(), source))
392 })?;
393
394 for payload in &payloads {
395 tx.insert(target_seg, payload.as_slice())
396 .map_err(|source| {
397 MigrateError::Persy(format!(
398 "{}: insert: {}",
399 opts.target_path.display(),
400 source
401 ))
402 })?;
403 }
404
405 tx.prepare()
406 .map_err(|source| {
407 MigrateError::Persy(format!(
408 "{}: prepare: {}",
409 opts.target_path.display(),
410 source
411 ))
412 })?
413 .commit()
414 .map_err(|source| {
415 MigrateError::Persy(format!(
416 "{}: commit: {}",
417 opts.target_path.display(),
418 source
419 ))
420 })?;
421
422 drop(target_db);
425
426 Ok((source_count, payloads.len()))
427 }
428
429 fn migrate_persy_to_redb(opts: &MigrateOpts) -> Result<(usize, usize), MigrateError> {
430 let src_db = persy::Persy::open(
431 opts.source_path.to_string_lossy().as_ref(),
432 persy::Config::new(),
433 )
434 .map_err(|source| {
435 MigrateError::Persy(format!(
436 "{}: {}",
437 opts.source_path.clone().display(),
438 source
439 ))
440 })?;
441 let src_seg = src_db.solve_segment_id(PERSY_BEAM_NODES).map_err(|e| {
442 MigrateError::Persy(format!(
443 "{}: solve_segment_id failed: {}",
444 opts.source_path.display(),
445 e
446 ))
447 })?;
448
449 let target_db =
450 Database::create(&opts.target_path).map_err(|source| MigrateError::Redb {
451 path: opts.target_path.clone(),
452 source: source.into(),
453 })?;
454 let target_tx = target_db
455 .begin_write()
456 .map_err(|source| MigrateError::RedbTx {
457 path: opts.target_path.clone(),
458 source,
459 })?;
460 let mut target_table =
461 target_tx
462 .open_table(REDB_BEAM_NODES)
463 .map_err(|source| MigrateError::RedbTable {
464 path: opts.target_path.clone(),
465 source,
466 })?;
467
468 let scan = src_db.scan(src_seg).map_err(|source| {
469 MigrateError::Persy(format!(
470 "{}: {}",
471 opts.source_path.clone().display(),
472 source
473 ))
474 })?;
475
476 let mut source_count = 0;
477 let mut migrated = 0;
478 let mut batch: Vec<(String, Vec<u8>)> = Vec::with_capacity(opts.batch_size);
479
480 for entry in scan {
481 let (_id, bytes) = entry;
482 let (key, value_bytes) = persy_to_redb_record(&bytes)?;
483 source_count += 1;
484
485 if !opts.dry_run {
486 batch.push((key, value_bytes));
487 if batch.len() >= opts.batch_size {
488 for (k, v) in batch.drain(..) {
489 target_table
490 .insert(k.as_str(), v.as_slice())
491 .map_err(|source| MigrateError::RedbTable {
492 path: opts.target_path.clone(),
493 source: redb::TableError::Storage(source),
494 })?;
495 migrated += 1;
496 }
497 }
498 }
499 }
500
501 if !batch.is_empty() && !opts.dry_run {
502 for (k, v) in batch.drain(..) {
503 target_table
504 .insert(k.as_str(), v.as_slice())
505 .map_err(|source| MigrateError::RedbTable {
506 path: opts.target_path.clone(),
507 source: redb::TableError::Storage(source),
508 })?;
509 migrated += 1;
510 }
511 }
512
513 drop(target_table);
514 target_tx
515 .commit()
516 .map_err(|source| MigrateError::RedbCommit {
517 path: opts.target_path.clone(),
518 source,
519 })?;
520
521 Ok((source_count, migrated))
523 }
524}
525#[cfg(feature = "persy")]
526pub use io::migrate;
527
528#[cfg(test)]
533mod tests {
534 use super::*;
535 use crate::types::{NodeData, Value};
536 use std::collections::BTreeMap;
537
538 fn make_test_children() -> Children {
540 let mut children = BTreeMap::new();
541 children.insert(
542 "greeting".to_string(),
543 NodeData {
544 value: Value::Text("hello".to_string()),
545 updated_at: 12345.0,
546 },
547 );
548 children.insert(
549 "count".to_string(),
550 NodeData {
551 value: Value::Number(42.0),
552 updated_at: 67890.0,
553 },
554 );
555 children.insert(
556 "flag".to_string(),
557 NodeData {
558 value: Value::Bit(true),
559 updated_at: 11111.0,
560 },
561 );
562 children
563 }
564
565 #[test]
566 fn redb_to_persy_roundtrips_children() {
567 let children = make_test_children();
568 let original_bytes = postcard::to_allocvec(&children).unwrap();
569
570 let translated = redb_to_persy_payload("test-node", &original_bytes).unwrap();
571 let record: NodeRecord = postcard::from_bytes(&translated).unwrap();
572
573 assert_eq!(record.node_id, "test-node");
574 assert_eq!(record.children, children);
575 }
576
577 #[test]
578 fn persy_to_redb_roundtrips_children() {
579 let children = make_test_children();
580 let record = NodeRecord {
581 node_id: "test-node".to_string(),
582 children: children.clone(),
583 };
584 let payload = postcard::to_allocvec(&record).unwrap();
585
586 let (key, value_bytes) = persy_to_redb_record(&payload).unwrap();
587
588 assert_eq!(key, "test-node");
589 let recovered: Children = postcard::from_bytes(&value_bytes).unwrap();
590 assert_eq!(recovered, children);
591 }
592
593 #[test]
594 fn translation_is_pure_and_deterministic() {
595 let children = make_test_children();
596 let bytes = postcard::to_allocvec(&children).unwrap();
597
598 let result1 = redb_to_persy_payload("k", &bytes).unwrap();
599 let result2 = redb_to_persy_payload("k", &bytes).unwrap();
600
601 assert_eq!(result1, result2, "same input must produce same output");
602 }
603
604 #[test]
605 fn empty_children_translates_cleanly() {
606 let empty: Children = BTreeMap::new();
607 let bytes = postcard::to_allocvec(&empty).unwrap();
608
609 let translated = redb_to_persy_payload("empty-node", &bytes).unwrap();
610 let record: NodeRecord = postcard::from_bytes(&translated).unwrap();
611
612 assert_eq!(record.node_id, "empty-node");
613 assert!(record.children.is_empty());
614 }
615
616 #[test]
617 fn all_value_variants_preserved() {
618 let mut children = BTreeMap::new();
620 children.insert(
621 "null".to_string(),
622 NodeData {
623 value: Value::Null,
624 updated_at: 1.0,
625 },
626 );
627 children.insert(
628 "bit".to_string(),
629 NodeData {
630 value: Value::Bit(false),
631 updated_at: 2.0,
632 },
633 );
634 children.insert(
635 "num".to_string(),
636 NodeData {
637 value: Value::Number(-3.15),
638 updated_at: 3.0,
639 },
640 );
641 children.insert(
642 "text".to_string(),
643 NodeData {
644 value: Value::Text("unicode: ☃ snowman".to_string()),
645 updated_at: 4.0,
646 },
647 );
648 children.insert(
649 "link".to_string(),
650 NodeData {
651 value: Value::Link("node/abc".to_string()),
652 updated_at: 5.0,
653 },
654 );
655
656 let bytes = postcard::to_allocvec(&children).unwrap();
657 let translated = redb_to_persy_payload("root", &bytes).unwrap();
658 let record: NodeRecord = postcard::from_bytes(&translated).unwrap();
659
660 assert_eq!(record.children, children);
661 if let Value::Link(ref s) = record.children.get("link").unwrap().value {
663 assert_eq!(s, "node/abc");
664 } else {
665 panic!("link value not preserved");
666 }
667 }
668
669 #[test]
670 fn backend_parse_accepts_lowercase() {
671 assert_eq!(MigrateError::parse_backend("redb").unwrap(), Backend::Redb);
672 assert_eq!(
673 MigrateError::parse_backend("persy").unwrap(),
674 Backend::Persy
675 );
676 }
677
678 #[test]
679 fn backend_parse_accepts_mixed_case() {
680 assert_eq!(MigrateError::parse_backend("Redb").unwrap(), Backend::Redb);
681 assert_eq!(
682 MigrateError::parse_backend("PERSY").unwrap(),
683 Backend::Persy
684 );
685 }
686
687 #[test]
688 fn backend_parse_rejects_unknown() {
689 assert!(matches!(
690 MigrateError::parse_backend("sqlite"),
691 Err(MigrateError::InvalidBackend(_))
692 ));
693 }
694
695 #[test]
696 fn backend_as_str_roundtrips() {
697 assert_eq!(Backend::Redb.as_str(), "redb");
698 assert_eq!(Backend::Persy.as_str(), "persy");
699 }
700
701 #[test]
702 fn unsorted_keys_preserved_after_roundtrip() {
703 let mut children = BTreeMap::new();
706 children.insert(
707 "z".to_string(),
708 NodeData {
709 value: Value::Text("last".to_string()),
710 updated_at: 1.0,
711 },
712 );
713 children.insert(
714 "a".to_string(),
715 NodeData {
716 value: Value::Text("first".to_string()),
717 updated_at: 2.0,
718 },
719 );
720 children.insert(
721 "m".to_string(),
722 NodeData {
723 value: Value::Text("middle".to_string()),
724 updated_at: 3.0,
725 },
726 );
727
728 let bytes = postcard::to_allocvec(&children).unwrap();
729 let translated = redb_to_persy_payload("k", &bytes).unwrap();
730 let record: NodeRecord = postcard::from_bytes(&translated).unwrap();
731
732 let keys: Vec<&String> = record.children.keys().collect();
733 assert_eq!(keys, vec!["a", "m", "z"]); }
735}