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::storage::EntityData;
35use crate::{RedDBError, RedDBOptions, RedDBResult, RedDBRuntime};
36use reddb_types::Value;
37
38const DATA_FILE: &str = "db.rdb";
42
43const MIGRATING_SUFFIX: &str = "migrating";
45
46const BACKUP_SUFFIX: &str = "pre-migration";
48
49#[derive(Debug, Clone)]
51pub struct CollectionMigration {
52 pub name: String,
54 pub source_documents: usize,
56 pub migrated_documents: usize,
58 pub auto_indexed_fields: Vec<String>,
60}
61
62#[derive(Debug, Clone)]
64pub struct MigrationReport {
65 pub collections: Vec<CollectionMigration>,
67 pub backup_dir: PathBuf,
69 pub total_documents: usize,
71}
72
73struct SourceCollection {
75 name: String,
76 bodies: Vec<crate::json::Value>,
77 legacy_fields: Vec<String>,
80}
81
82pub fn migrate_store_to_binary_body(store_dir: &Path) -> RedDBResult<MigrationReport> {
91 if !store_dir.is_dir() {
92 return Err(RedDBError::InvalidOperation(format!(
93 "migration source is not a directory: {}",
94 store_dir.display()
95 )));
96 }
97
98 let collections = read_source_collections(store_dir)?;
101
102 let migrating_dir = sibling_dir(store_dir, MIGRATING_SUFFIX)?;
104 let outcome = build_migrated_store(&migrating_dir, &collections);
105 let report_collections = match outcome {
106 Ok(report) => report,
107 Err(err) => {
108 let _ = std::fs::remove_dir_all(&migrating_dir);
110 return Err(err);
111 }
112 };
113
114 let backup_dir = swap_in_migrated_store(store_dir, &migrating_dir)?;
116
117 let total_documents = report_collections
118 .iter()
119 .map(|c| c.migrated_documents)
120 .sum();
121 Ok(MigrationReport {
122 collections: report_collections,
123 backup_dir,
124 total_documents,
125 })
126}
127
128fn read_source_collections(store_dir: &Path) -> RedDBResult<Vec<SourceCollection>> {
131 let source = RedDBRuntime::with_options(RedDBOptions::persistent(store_dir.join(DATA_FILE)))?;
132
133 let mut document_collections: Vec<String> = source
134 .catalog()
135 .collections
136 .into_iter()
137 .filter(|descriptor| descriptor.model == CollectionModel::Document)
138 .map(|descriptor| descriptor.name)
139 .collect();
140 document_collections.sort();
141
142 let mut out = Vec::with_capacity(document_collections.len());
143 for name in document_collections {
144 let mut bodies = Vec::new();
145 let mut legacy_fields: BTreeSet<String> = BTreeSet::new();
146 let mut cursor = None;
147 loop {
148 let page = source.scan_collection(&name, cursor, 1_000)?;
149 for entity in &page.items {
150 let EntityData::Row(row) = &entity.data else {
151 continue;
152 };
153 for (field, _value) in row.iter_fields() {
156 if field != "body"
157 && !crate::reserved_fields::is_reserved_public_item_field(field)
158 {
159 legacy_fields.insert(field.to_string());
160 }
161 }
162 bodies.push(decode_body(row)?);
163 }
164 match page.next {
165 Some(next) => cursor = Some(next),
166 None => break,
167 }
168 }
169 out.push(SourceCollection {
170 name,
171 bodies,
172 legacy_fields: legacy_fields.into_iter().collect(),
173 });
174 }
175
176 Ok(out)
177}
178
179fn decode_body(row: &crate::storage::RowData) -> RedDBResult<crate::json::Value> {
182 match row.get_field("body") {
183 Some(Value::Json(bytes)) => Ok(reddb_types::value_json::json_bytes_to_json(bytes)),
184 Some(other) => Ok(other.to_json()),
185 None => Err(RedDBError::InvalidOperation(
186 "document row is missing its `body` field".to_string(),
187 )),
188 }
189}
190
191fn build_migrated_store(
194 migrating_dir: &Path,
195 collections: &[SourceCollection],
196) -> RedDBResult<Vec<CollectionMigration>> {
197 std::fs::create_dir_all(migrating_dir).map_err(RedDBError::Io)?;
198
199 let target =
200 RedDBRuntime::with_options(RedDBOptions::persistent(migrating_dir.join(DATA_FILE)))?;
201 target.execute_query("SET CONFIG storage.binary_document_body = true")?;
205
206 let entities = EntityUseCases::new(&target);
207 let mut report = Vec::with_capacity(collections.len());
208
209 for collection in collections {
210 target.execute_query(&format!("CREATE DOCUMENT {}", collection.name))?;
211
212 for body in &collection.bodies {
213 entities.create_document(CreateDocumentInput {
214 collection: collection.name.clone(),
215 body: body.clone(),
216 metadata: Vec::new(),
217 node_links: Vec::new(),
218 vector_links: Vec::new(),
219 })?;
220 }
221
222 for field in &collection.legacy_fields {
225 target.execute_query(&format!(
226 "CREATE INDEX {index} ON {collection} ({field}) USING BTREE",
227 index = auto_index_name(&collection.name, field),
228 collection = collection.name,
229 field = field,
230 ))?;
231 }
232
233 let migrated = target.scan_collection(&collection.name, None, 1)?.total;
236 let source = collection.bodies.len();
237 ensure_counts_match(&collection.name, source, migrated)?;
238
239 report.push(CollectionMigration {
240 name: collection.name.clone(),
241 source_documents: source,
242 migrated_documents: migrated,
243 auto_indexed_fields: collection.legacy_fields.clone(),
244 });
245 }
246
247 target.flush()?;
250 target.checkpoint()?;
251 drop(target);
252
253 Ok(report)
254}
255
256fn swap_in_migrated_store(store_dir: &Path, migrating_dir: &Path) -> RedDBResult<PathBuf> {
259 let backup_dir = sibling_dir(store_dir, BACKUP_SUFFIX)?;
260
261 std::fs::rename(store_dir, &backup_dir).map_err(RedDBError::Io)?;
263 if let Err(err) = std::fs::rename(migrating_dir, store_dir) {
266 let _ = std::fs::rename(&backup_dir, store_dir);
267 return Err(RedDBError::Io(err));
268 }
269
270 Ok(backup_dir)
271}
272
273fn sibling_dir(store_dir: &Path, suffix: &str) -> RedDBResult<PathBuf> {
276 let file_name = store_dir
277 .file_name()
278 .and_then(|name| name.to_str())
279 .ok_or_else(|| {
280 RedDBError::InvalidOperation(format!(
281 "store directory has no usable name: {}",
282 store_dir.display()
283 ))
284 })?;
285 let parent = store_dir.parent().unwrap_or_else(|| Path::new("."));
286 let candidate = parent.join(format!("{file_name}.{suffix}"));
287 if candidate.exists() {
288 return Err(RedDBError::InvalidOperation(format!(
289 "migration sibling directory already exists: {}",
290 candidate.display()
291 )));
292 }
293 Ok(candidate)
294}
295
296fn ensure_counts_match(collection: &str, source: usize, migrated: usize) -> RedDBResult<()> {
300 if migrated != source {
301 return Err(RedDBError::InvalidOperation(format!(
302 "document count mismatch for collection '{collection}': source {source} != \
303 migrated {migrated}; aborting before swap"
304 )));
305 }
306 Ok(())
307}
308
309fn auto_index_name(collection: &str, field: &str) -> String {
311 fn sanitize(input: &str) -> String {
312 input
313 .chars()
314 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
315 .collect()
316 }
317 format!("mig_{}_{}", sanitize(collection), sanitize(field))
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn matching_counts_pass_mismatch_aborts() {
326 assert!(ensure_counts_match("docs", 4, 4).is_ok());
327 let err = ensure_counts_match("docs", 4, 3).expect_err("mismatch aborts");
329 assert!(matches!(err, RedDBError::InvalidOperation(_)));
330 }
331
332 #[test]
333 fn auto_index_name_is_identifier_safe() {
334 assert_eq!(auto_index_name("docs", "score"), "mig_docs_score");
335 assert_eq!(
336 auto_index_name("my-docs", "user.name"),
337 "mig_my_docs_user_name"
338 );
339 }
340
341 #[test]
342 fn sibling_dir_rejects_existing() {
343 let base = std::env::temp_dir().join(format!(
344 "reddb-mig-sibling-{}-{}",
345 std::process::id(),
346 "store"
347 ));
348 let _ = std::fs::remove_dir_all(&base);
349 std::fs::create_dir_all(&base).unwrap();
350
351 let sib = sibling_dir(&base, "migrating").unwrap();
353 assert!(sib
354 .file_name()
355 .and_then(|name| name.to_str())
356 .is_some_and(|name| name.ends_with(".migrating")));
357
358 std::fs::create_dir_all(&sib).unwrap();
360 assert!(sibling_dir(&base, "migrating").is_err());
361
362 let _ = std::fs::remove_dir_all(&base);
363 let _ = std::fs::remove_dir_all(&sib);
364 }
365}