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 database)
57    Redb,
58    /// Persy backend (single-file embedded database with MVCC)
59    Persy,
60}
61
62impl Backend {
63    /// Returns the canonical lowercase string used in CLI args and logs.
64    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/// Migration options, typically parsed from CLI arguments.
79#[derive(Debug, Clone)]
80pub struct MigrateOpts {
81    /// Source backend format
82    pub from: Backend,
83    /// Target backend format
84    pub to: Backend,
85    /// Path to source database (file for redb, file for Persy)
86    pub source_path: PathBuf,
87    /// Path to target database (will be created)
88    pub target_path: PathBuf,
89    /// Records per write batch (default: 1000)
90    pub batch_size: usize,
91    /// Overwrite target if it already exists
92    pub force: bool,
93    /// Preview the migration without writing
94    pub dry_run: bool,
95}
96
97/// Result of a completed migration, returned to the caller for reporting.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct MigrationReport {
100    /// Number of records successfully written to target
101    pub records_migrated: usize,
102    /// Number of records found in source
103    pub source_count: usize,
104    /// Number of records in target after migration (== `records_migrated` unless partial)
105    pub target_count_after: usize,
106    /// Total wall-clock duration
107    pub elapsed: Duration,
108    /// Whether this was a dry run
109    pub dry_run: bool,
110}
111
112/// All migration error variants.
113///
114/// Uses [`thiserror`] for idiomatic error definitions. Each variant carries
115/// enough context to be useful in CLI output (path, backend, underlying error).
116#[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    /// Parse a backend name from a CLI string. Returns [`MigrateError::InvalidBackend`]
170    /// if the string is not recognized.
171    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
180// ─────────────────────────────────────────────────────────────────────────────
181// Pure translation functions (no I/O — trivially testable)
182// ─────────────────────────────────────────────────────────────────────────────
183
184/// Translates a redb record (node_id + raw value bytes) into a Persy record payload.
185///
186/// The inner `Children` data is deserialized from the redb format and re-serialized
187/// inside a [`NodeRecord`] wrapper for Persy. Both formats use postcard on `Children`,
188/// so the inner bytes are preserved exactly.
189///
190/// # Errors
191///
192/// Returns [`MigrateError::Postcard`] if the input bytes are not a valid postcard-encoded
193/// `Children` map.
194pub 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
203/// Translates a Persy record payload into a redb (node_id, value_bytes) pair.
204///
205/// Counterpart to [`redb_to_persy_payload`]: unwraps the [`NodeRecord`], re-serializes
206/// the `Children` map in redb's bare-bytes format.
207///
208/// # Errors
209///
210/// Returns [`MigrateError::Postcard`] if the input bytes are not a valid postcard-encoded
211/// `NodeRecord`.
212pub 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// ─────────────────────────────────────────────────────────────────────────────
219// I/O orchestration — requires the `persy` feature
220// ─────────────────────────────────────────────────────────────────────────────
221
222#[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    /// Run a storage migration. Dispatches on the (from, to) backend pair.
234    ///
235    /// # Errors
236    ///
237    /// Returns [`MigrateError::Unsupported`] if `from == to`, or any backend
238    /// I/O error if the source can't be read or the target can't be written.
239    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            // In dry-run, "records_migrated" reports what WOULD have been
263            // written (== source_count). This matches the test contract:
264            // a dry-run preview should show the actual record count.
265            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        // ─── Read source records into memory ──────────────────────────────
275        //
276        // Source is redb, opened read-only. We materialize all records as
277        // postcard-serialized NodeRecord payloads (the Persy on-disk format)
278        // before opening the target. This keeps the migration flow linear
279        // and avoids holding a redb read transaction open across a long
280        // Persy write transaction.
281        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        // An empty source DB (or one without the beam_nodes_v1 table) is a
290        // valid input — treat it as zero records rather than an error.
291        // This matches the e2e_migration_empty_dataset contract.
292        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                    // Empty source — return success with zero records
298                    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        // Dry-run: count only, no writes.
329        if opts.dry_run {
330            return Ok((source_count, 0));
331        }
332
333        // ─── Write all records in a single Persy transaction ────────────
334        //
335        // Mirrors the canonical Persy example pattern and our own
336        // v0.5.0 PersyStorage write path (see `src/adapters/persy_storage.rs`):
337        //
338        //   1. Open ONE Persy handle for the entire migration
339        //   2. Create the segment on first run via the open_or_create_with closure
340        //   3. Resolve the segment id ONCE from that handle
341        //   4. Begin ONE transaction, insert ALL payloads, commit ONCE
342        //   5. Drop the handle (releases flock, flushes final state)
343        //
344        // Why a single transaction:
345        //   * Persy's `solve_segment_id` returns the correct ID for the
346        //     same in-memory address state within a single handle. Holding
347        //     one handle means the segment id is stable for the whole run.
348        //   * The "fresh handle per batch" pattern (prior implementation)
349        //     relied on `solve_segment_id` resolving the same id across
350        //     handles, which can race with the address map during recovery.
351        //     Single-handle is simpler and substrate-aligned.
352        //   * For our 100-record test dataset, all payloads fit in memory.
353        //     For production migrations of millions of records, we can
354        //     chunk within the single transaction (see TODO below).
355
356        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        // Explicit drop ensures all work completes before the caller
423        // (e.g., an e2e test) opens the file for verification.
424        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        // When dry_run, migrated stays 0; source_count still reflects records read.
522        Ok((source_count, migrated))
523    }
524}
525#[cfg(feature = "persy")]
526pub use io::migrate;
527
528// ─────────────────────────────────────────────────────────────────────────────
529// Tests
530// ─────────────────────────────────────────────────────────────────────────────
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use crate::types::{NodeData, Value};
536    use std::collections::BTreeMap;
537
538    /// Helper: build a representative `Children` map using the real BEAM value types.
539    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        // Build children using every Value variant to confirm roundtrip fidelity.
619        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        // Spot-check the Link variant — it's the one most likely to silently corrupt.
662        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        // BTreeMap sorts by key, so insertion order doesn't matter —
704        // but verify the roundtrip doesn't somehow scramble the key set.
705        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"]); // BTreeMap ordering
734    }
735}