reddb_server/
document_migration.rs1use std::collections::BTreeSet;
30use std::path::{Path, PathBuf};
31
32use crate::application::{CreateDocumentInput, EntityUseCases};
33use crate::catalog::CollectionModel;
34use crate::presentation::entity_json::storage_json_bytes_to_json;
35use crate::storage::schema::Value;
36use crate::storage::EntityData;
37use crate::{RedDBError, RedDBOptions, RedDBResult, RedDBRuntime};
38
39const DATA_FILE: &str = "db.rdb";
43
44const MIGRATING_SUFFIX: &str = "migrating";
46
47const BACKUP_SUFFIX: &str = "pre-migration";
49
50#[derive(Debug, Clone)]
52pub struct CollectionMigration {
53 pub name: String,
55 pub source_documents: usize,
57 pub migrated_documents: usize,
59 pub auto_indexed_fields: Vec<String>,
61}
62
63#[derive(Debug, Clone)]
65pub struct MigrationReport {
66 pub collections: Vec<CollectionMigration>,
68 pub backup_dir: PathBuf,
70 pub total_documents: usize,
72}
73
74struct SourceCollection {
76 name: String,
77 bodies: Vec<crate::json::Value>,
78 promoted_fields: Vec<String>,
81}
82
83pub fn migrate_store_to_binary_body(store_dir: &Path) -> RedDBResult<MigrationReport> {
92 if !store_dir.is_dir() {
93 return Err(RedDBError::InvalidOperation(format!(
94 "migration source is not a directory: {}",
95 store_dir.display()
96 )));
97 }
98
99 let collections = read_source_collections(store_dir)?;
102
103 let migrating_dir = sibling_dir(store_dir, MIGRATING_SUFFIX)?;
105 let outcome = build_migrated_store(&migrating_dir, &collections);
106 let report_collections = match outcome {
107 Ok(report) => report,
108 Err(err) => {
109 let _ = std::fs::remove_dir_all(&migrating_dir);
111 return Err(err);
112 }
113 };
114
115 let backup_dir = swap_in_migrated_store(store_dir, &migrating_dir)?;
117
118 let total_documents = report_collections
119 .iter()
120 .map(|c| c.migrated_documents)
121 .sum();
122 Ok(MigrationReport {
123 collections: report_collections,
124 backup_dir,
125 total_documents,
126 })
127}
128
129fn read_source_collections(store_dir: &Path) -> RedDBResult<Vec<SourceCollection>> {
132 let source = RedDBRuntime::with_options(RedDBOptions::persistent(store_dir.join(DATA_FILE)))?;
133
134 let mut document_collections: Vec<String> = source
135 .catalog()
136 .collections
137 .into_iter()
138 .filter(|descriptor| descriptor.model == CollectionModel::Document)
139 .map(|descriptor| descriptor.name)
140 .collect();
141 document_collections.sort();
142
143 let mut out = Vec::with_capacity(document_collections.len());
144 for name in document_collections {
145 let mut bodies = Vec::new();
146 let mut promoted: BTreeSet<String> = BTreeSet::new();
147 let mut cursor = None;
148 loop {
149 let page = source.scan_collection(&name, cursor, 1_000)?;
150 for entity in &page.items {
151 let EntityData::Row(row) = &entity.data else {
152 continue;
153 };
154 for (field, _value) in row.iter_fields() {
157 if field != "body"
158 && !crate::reserved_fields::is_reserved_public_item_field(field)
159 {
160 promoted.insert(field.to_string());
161 }
162 }
163 bodies.push(decode_body(row)?);
164 }
165 match page.next {
166 Some(next) => cursor = Some(next),
167 None => break,
168 }
169 }
170 out.push(SourceCollection {
171 name,
172 bodies,
173 promoted_fields: promoted.into_iter().collect(),
174 });
175 }
176
177 Ok(out)
178}
179
180fn decode_body(row: &crate::storage::RowData) -> RedDBResult<crate::json::Value> {
183 match row.get_field("body") {
184 Some(Value::Json(bytes)) => Ok(storage_json_bytes_to_json(bytes)),
185 Some(other) => Ok(crate::presentation::entity_json::storage_value_to_json(
186 other,
187 )),
188 None => Err(RedDBError::InvalidOperation(
189 "document row is missing its `body` field".to_string(),
190 )),
191 }
192}
193
194fn build_migrated_store(
197 migrating_dir: &Path,
198 collections: &[SourceCollection],
199) -> RedDBResult<Vec<CollectionMigration>> {
200 std::fs::create_dir_all(migrating_dir).map_err(RedDBError::Io)?;
201
202 let target =
203 RedDBRuntime::with_options(RedDBOptions::persistent(migrating_dir.join(DATA_FILE)))?;
204 target.execute_query("SET CONFIG storage.binary_document_body = true")?;
208
209 let entities = EntityUseCases::new(&target);
210 let mut report = Vec::with_capacity(collections.len());
211
212 for collection in collections {
213 target.execute_query(&format!("CREATE DOCUMENT {}", collection.name))?;
214
215 for body in &collection.bodies {
216 entities.create_document(CreateDocumentInput {
217 collection: collection.name.clone(),
218 body: body.clone(),
219 metadata: Vec::new(),
220 node_links: Vec::new(),
221 vector_links: Vec::new(),
222 })?;
223 }
224
225 for field in &collection.promoted_fields {
228 target.execute_query(&format!(
229 "CREATE INDEX {index} ON {collection} ({field}) USING BTREE",
230 index = auto_index_name(&collection.name, field),
231 collection = collection.name,
232 field = field,
233 ))?;
234 }
235
236 let migrated = target.scan_collection(&collection.name, None, 1)?.total;
239 let source = collection.bodies.len();
240 ensure_counts_match(&collection.name, source, migrated)?;
241
242 report.push(CollectionMigration {
243 name: collection.name.clone(),
244 source_documents: source,
245 migrated_documents: migrated,
246 auto_indexed_fields: collection.promoted_fields.clone(),
247 });
248 }
249
250 target.flush()?;
253 target.checkpoint()?;
254 drop(target);
255
256 Ok(report)
257}
258
259fn swap_in_migrated_store(store_dir: &Path, migrating_dir: &Path) -> RedDBResult<PathBuf> {
262 let backup_dir = sibling_dir(store_dir, BACKUP_SUFFIX)?;
263
264 std::fs::rename(store_dir, &backup_dir).map_err(RedDBError::Io)?;
266 if let Err(err) = std::fs::rename(migrating_dir, store_dir) {
269 let _ = std::fs::rename(&backup_dir, store_dir);
270 return Err(RedDBError::Io(err));
271 }
272
273 Ok(backup_dir)
274}
275
276fn sibling_dir(store_dir: &Path, suffix: &str) -> RedDBResult<PathBuf> {
279 let file_name = store_dir
280 .file_name()
281 .and_then(|name| name.to_str())
282 .ok_or_else(|| {
283 RedDBError::InvalidOperation(format!(
284 "store directory has no usable name: {}",
285 store_dir.display()
286 ))
287 })?;
288 let parent = store_dir.parent().unwrap_or_else(|| Path::new("."));
289 let candidate = parent.join(format!("{file_name}.{suffix}"));
290 if candidate.exists() {
291 return Err(RedDBError::InvalidOperation(format!(
292 "migration sibling directory already exists: {}",
293 candidate.display()
294 )));
295 }
296 Ok(candidate)
297}
298
299fn ensure_counts_match(collection: &str, source: usize, migrated: usize) -> RedDBResult<()> {
303 if migrated != source {
304 return Err(RedDBError::InvalidOperation(format!(
305 "document count mismatch for collection '{collection}': source {source} != \
306 migrated {migrated}; aborting before swap"
307 )));
308 }
309 Ok(())
310}
311
312fn auto_index_name(collection: &str, field: &str) -> String {
314 fn sanitize(input: &str) -> String {
315 input
316 .chars()
317 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
318 .collect()
319 }
320 format!("mig_{}_{}", sanitize(collection), sanitize(field))
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn matching_counts_pass_mismatch_aborts() {
329 assert!(ensure_counts_match("docs", 4, 4).is_ok());
330 let err = ensure_counts_match("docs", 4, 3).expect_err("mismatch aborts");
332 assert!(matches!(err, RedDBError::InvalidOperation(_)));
333 }
334
335 #[test]
336 fn auto_index_name_is_identifier_safe() {
337 assert_eq!(auto_index_name("docs", "score"), "mig_docs_score");
338 assert_eq!(
339 auto_index_name("my-docs", "user.name"),
340 "mig_my_docs_user_name"
341 );
342 }
343
344 #[test]
345 fn sibling_dir_rejects_existing() {
346 let base = std::env::temp_dir().join(format!(
347 "reddb-mig-sibling-{}-{}",
348 std::process::id(),
349 "store"
350 ));
351 let _ = std::fs::remove_dir_all(&base);
352 std::fs::create_dir_all(&base).unwrap();
353
354 let sib = sibling_dir(&base, "migrating").unwrap();
356 assert!(sib
357 .file_name()
358 .and_then(|name| name.to_str())
359 .is_some_and(|name| name.ends_with(".migrating")));
360
361 std::fs::create_dir_all(&sib).unwrap();
363 assert!(sibling_dir(&base, "migrating").is_err());
364
365 let _ = std::fs::remove_dir_all(&base);
366 let _ = std::fs::remove_dir_all(&sib);
367 }
368}