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}