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 Fjall,
62}
63
64impl Backend {
65 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#[derive(Debug, Clone)]
83pub struct MigrateOpts {
84 pub from: Backend,
86 pub to: Backend,
88 pub source_path: PathBuf,
90 pub target_path: PathBuf,
92 pub batch_size: usize,
94 pub force: bool,
96 pub dry_run: bool,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct MigrationReport {
103 pub records_migrated: usize,
105 pub source_count: usize,
107 pub target_count_after: usize,
109 pub elapsed: Duration,
111 pub dry_run: bool,
113}
114
115#[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 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
187pub 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
210pub 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#[derive(Debug, Clone)]
242pub struct MigrationRecord {
243 pub node_id: String,
245 pub children_bytes: Vec<u8>,
247}
248
249const FJALL_KEY_PREFIX: u8 = 0x00;
258
259pub 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
270pub 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#[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 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 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 #[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 #[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 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 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 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 #[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 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 #[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 db.persist(fjall::PersistMode::SyncAll)
575 .map_err(|e| MigrateError::Fjall(format!("persist: {}", e)))?;
576
577 Ok(records.len())
578 }
579
580 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 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 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 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#[cfg(test)]
703mod tests {
704 use super::*;
705 use crate::types::{NodeData, Value};
706 use arena_btreemap::BTreeMap;
707
708 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 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 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 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"]); }
905
906 #[test]
911 fn redb_to_fjall_key_adds_prefix() {
912 let key = redb_to_fjall_key("");
914 assert_eq!(key, vec![0x00]);
915
916 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 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}