migrate.rs

  1//! Forward-only document-schema upgrader for Loro state.
  2//!
  3//! Each project's Loro document carries `meta.schema_version`.  On every
  4//! [`Store::open`](crate::db::Store::open) call, [`ensure_current`] compares
  5//! that version to [`CURRENT_SCHEMA_VERSION`] and applies any registered
  6//! upgraders in sequence.  Upgraders are keyed by the source version they
  7//! transform *from*; each one is responsible for mutating the document so it
  8//! conforms to the next version's expectations.
  9//!
 10//! When a new schema change is introduced:
 11//! 1. Bump [`CURRENT_SCHEMA_VERSION`].
 12//! 2. Write an `upgrade_vN_to_vM` function.
 13//! 3. Add a match arm in [`upgrader_for`].
 14
 15use anyhow::{anyhow, bail, Result};
 16use loro::{LoroDoc, LoroValue, ValueOrContainer};
 17
 18/// The schema version that the running code expects every document to be at.
 19pub const CURRENT_SCHEMA_VERSION: u32 = 1;
 20
 21/// Check a document's schema version and apply any necessary upgrades.
 22///
 23/// Returns `Ok(true)` when at least one upgrade was applied (the caller
 24/// should persist the resulting delta), or `Ok(false)` when the document
 25/// was already current.
 26///
 27/// # Errors
 28///
 29/// - Document has no `meta.schema_version`.
 30/// - Document version is newer than `CURRENT_SCHEMA_VERSION`.
 31/// - No upgrader is registered for a version in the upgrade path.
 32/// - An upgrader itself fails.
 33pub fn ensure_current(doc: &LoroDoc) -> Result<bool> {
 34    let mut version = read_schema_version(doc)?;
 35
 36    if version == CURRENT_SCHEMA_VERSION {
 37        return Ok(false);
 38    }
 39
 40    if version > CURRENT_SCHEMA_VERSION {
 41        bail!(
 42            "document schema version {version} is newer than supported \
 43             ({CURRENT_SCHEMA_VERSION}); please upgrade td"
 44        );
 45    }
 46
 47    while version < CURRENT_SCHEMA_VERSION {
 48        let next = version + 1;
 49        let upgrader = upgrader_for(version).ok_or_else(|| {
 50            anyhow!("no upgrader registered for schema version {version} → {next}")
 51        })?;
 52        upgrader(doc)?;
 53        version = next;
 54    }
 55
 56    let meta = doc.get_map("meta");
 57    meta.insert("schema_version", CURRENT_SCHEMA_VERSION as i64)?;
 58
 59    Ok(true)
 60}
 61
 62/// Read `meta.schema_version` from an already-loaded Loro document.
 63///
 64/// Reads directly from the Loro map handler, avoiding a full-document
 65/// JSON serialisation.
 66pub fn read_schema_version(doc: &LoroDoc) -> Result<u32> {
 67    let meta = doc.get_map("meta");
 68    match meta.get("schema_version") {
 69        Some(ValueOrContainer::Value(LoroValue::I64(n))) => {
 70            u32::try_from(n).map_err(|_| anyhow!("meta.schema_version out of u32 range: {n}"))
 71        }
 72        Some(_) => bail!("meta.schema_version has unexpected type"),
 73        None => bail!("invalid or missing meta.schema_version"),
 74    }
 75}
 76
 77/// Look up the upgrade function that transforms a document *from* the given
 78/// version to `version + 1`.  Returns `None` when no upgrader is registered.
 79// The wildcard-only match is intentional: each new schema version adds an
 80// arm here (e.g. `1 => Some(upgrade_v1_to_v2)`).  Clippy's
 81// match_single_binding lint fires because there are no concrete arms yet.
 82#[allow(clippy::match_single_binding)]
 83fn upgrader_for(version: u32) -> Option<fn(&LoroDoc) -> Result<()>> {
 84    match version {
 85        _ => None,
 86    }
 87}
 88
 89#[cfg(test)]
 90mod tests {
 91    use super::*;
 92
 93    /// Build a minimal Loro document stamped at the given schema version.
 94    fn doc_at_version(v: u32) -> LoroDoc {
 95        let doc = LoroDoc::new();
 96        let meta = doc.get_map("meta");
 97        meta.insert("schema_version", v as i64).unwrap();
 98        doc.commit();
 99        doc
100    }
101
102    #[test]
103    fn current_version_is_noop() {
104        let doc = doc_at_version(CURRENT_SCHEMA_VERSION);
105        assert!(!ensure_current(&doc).unwrap());
106    }
107
108    #[test]
109    fn future_version_rejected() {
110        let doc = doc_at_version(CURRENT_SCHEMA_VERSION + 1);
111        let err = ensure_current(&doc).unwrap_err();
112        assert!(
113            err.to_string().contains("newer than supported"),
114            "unexpected error: {err}"
115        );
116    }
117
118    #[test]
119    fn old_version_without_upgrader_gives_clear_error() {
120        let doc = doc_at_version(0);
121        let err = ensure_current(&doc).unwrap_err();
122        assert!(
123            err.to_string().contains("no upgrader registered"),
124            "unexpected error: {err}"
125        );
126    }
127
128    #[test]
129    fn idempotent_on_current_version() {
130        let doc = doc_at_version(CURRENT_SCHEMA_VERSION);
131        assert!(!ensure_current(&doc).unwrap());
132        assert!(!ensure_current(&doc).unwrap());
133    }
134
135    #[test]
136    fn missing_meta_is_error() {
137        let doc = LoroDoc::new();
138        doc.get_map("meta"); // exists but has no schema_version key
139        doc.commit();
140        let err = ensure_current(&doc).unwrap_err();
141        assert!(
142            err.to_string().contains("meta.schema_version"),
143            "unexpected error: {err}"
144        );
145    }
146
147    #[test]
148    fn read_schema_version_returns_stamped_value() {
149        let doc = doc_at_version(42);
150        assert_eq!(read_schema_version(&doc).unwrap(), 42);
151    }
152}