1use std::collections::{BTreeMap, BTreeSet};
4use std::path::Path;
5
6use serde::{Deserialize, Deserializer, Serialize};
7
8use crate::operations::{
9 AppSchema, DatabaseError, Entity, Field, FieldDefault, FieldType, Identity, IndexKey,
10 Migration, SchemaChange, operation_fingerprint, valid_identifier, validate_copy_field,
11 validate_field_value, validate_predicate, validate_schema,
12};
13use serde_json::Value;
14
15pub fn schema_from_entity_catalog(
19 document: &Value,
20 app_id: &str,
21 requested_entities: &[&str],
22) -> Result<AppSchema, DatabaseError> {
23 if !valid_identifier(app_id) || requested_entities.is_empty() || requested_entities.len() > 256
24 {
25 return Err(DatabaseError::InvalidContract);
26 }
27 let rows = document
28 .get("entities")
29 .and_then(Value::as_array)
30 .ok_or(DatabaseError::InvalidContract)?;
31 let mut entities = Vec::with_capacity(requested_entities.len());
32 let mut requested = BTreeSet::new();
33 for wanted in requested_entities {
34 if !valid_identifier(wanted) || !requested.insert(*wanted) {
35 return Err(DatabaseError::InvalidContract);
36 }
37 let row = rows
38 .iter()
39 .find(|row| row.get("entity_id").and_then(Value::as_str) == Some(*wanted))
40 .ok_or(DatabaseError::InvalidContract)?;
41 let table = row
42 .get("table")
43 .and_then(Value::as_str)
44 .filter(|name| valid_identifier(name))
45 .ok_or(DatabaseError::InvalidContract)?;
46 let identity_field = row
47 .get("identity_field")
48 .and_then(Value::as_str)
49 .filter(|name| valid_identifier(name))
50 .ok_or(DatabaseError::InvalidContract)?;
51 let field_rows = row
52 .get("fields")
53 .and_then(Value::as_array)
54 .ok_or(DatabaseError::InvalidContract)?;
55 let mut fields = Vec::with_capacity(field_rows.len());
56 for entry in field_rows {
57 let name = entry
58 .get("field_id")
59 .and_then(Value::as_str)
60 .filter(|name| valid_identifier(name))
61 .ok_or(DatabaseError::InvalidContract)?;
62 let kind: FieldType = serde_json::from_value(
63 entry
64 .get("field_type")
65 .cloned()
66 .ok_or(DatabaseError::InvalidContract)?,
67 )
68 .map_err(|_| DatabaseError::InvalidContract)?;
69 let nullable = entry
70 .get("nullable")
71 .and_then(Value::as_bool)
72 .unwrap_or(false);
73 let max_length = match entry.get("max_length") {
74 Some(value) => Some(
75 value
76 .as_u64()
77 .and_then(|number| u32::try_from(number).ok())
78 .ok_or(DatabaseError::InvalidContract)?,
79 ),
80 None => None,
81 };
82 let default = entry
83 .get("field_default")
84 .cloned()
85 .map(serde_json::from_value::<FieldDefault>)
86 .transpose()
87 .map_err(|_| DatabaseError::InvalidContract)?;
88 fields.push(Field {
89 name: name.to_owned(),
90 kind,
91 nullable,
92 max_length,
93 default,
94 references: None,
95 on_delete: None,
96 constraints: Vec::new(),
97 });
98 }
99 let identity = match row.get("identity_kind").and_then(Value::as_str) {
100 None | Some("auto_integer") => Identity::AutoInteger(identity_field.to_owned()),
101 Some("natural") => Identity::Composite(vec![identity_field.to_owned()]),
102 _ => return Err(DatabaseError::InvalidContract),
103 };
104 let unique = row
105 .get("unique_keys")
106 .cloned()
107 .map(serde_json::from_value::<Vec<Vec<String>>>)
108 .transpose()
109 .map_err(|_| DatabaseError::InvalidContract)?
110 .unwrap_or_default();
111 entities.push(Entity {
112 name: table.to_owned(),
113 identity,
114 fields,
115 unique,
116 indexes: Vec::new(),
117 conditional_indexes: Vec::new(),
118 });
119 }
120 let schema = AppSchema {
121 app_id: app_id.to_owned(),
122 entities,
123 };
124 validate_schema(&schema)?;
125 Ok(schema)
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub struct AppDataContract {
131 pub schema: AppSchema,
132 pub migrations: Vec<Migration>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub legacy_adoption: Option<LegacyAdoption>,
135}
136
137pub fn reidentify_contract(
141 contract: &AppDataContract,
142 app_id: &str,
143) -> Result<AppDataContract, DatabaseError> {
144 validate_contract(contract)?;
145 if !valid_identifier(app_id) {
146 return Err(DatabaseError::InvalidInput);
147 }
148 if contract.schema.app_id == app_id {
149 return Ok(contract.clone());
150 }
151 let mut renamed = contract.clone();
152 renamed.schema.app_id = app_id.to_owned();
153 renamed.legacy_adoption = None;
154 for migration in &mut renamed.migrations {
155 migration.app_id = app_id.to_owned();
156 migration.checksum = migration_checksum(app_id, &migration.id, &migration.changes)?;
157 }
158 validate_contract(&renamed)?;
159 Ok(renamed)
160}
161
162pub fn rewrite_source_contract_strings(
165 contract: &AppDataContract,
166 mut rewrite: impl FnMut(&str) -> String,
167) -> Result<AppDataContract, DatabaseError> {
168 validate_contract(contract)?;
169 let mut value = serde_json::to_value(contract).map_err(|_| DatabaseError::InvalidContract)?;
170 rewrite_contract_strings(&mut value, &mut rewrite, 0)?;
171 let mut copied: AppDataContract =
172 serde_json::from_value(value).map_err(|_| DatabaseError::InvalidContract)?;
173 if copied == *contract {
174 return Ok(copied);
175 }
176 copied.legacy_adoption = None;
178 for migration in &mut copied.migrations {
179 migration.checksum =
180 migration_checksum(&migration.app_id, &migration.id, &migration.changes)?;
181 }
182 if serde_json::to_vec(&copied)
183 .map_err(|_| DatabaseError::InvalidContract)?
184 .len()
185 > 8 * 1024 * 1024
186 {
187 return Err(DatabaseError::CapacityExceeded);
188 }
189 validate_contract(&copied)?;
190 Ok(copied)
191}
192
193fn rewrite_contract_strings(
195 value: &mut Value,
196 rewrite: &mut impl FnMut(&str) -> String,
197 depth: usize,
198) -> Result<(), DatabaseError> {
199 if depth > 64 {
200 return Err(DatabaseError::CapacityExceeded);
201 }
202 match value {
203 Value::String(text) => *text = rewrite(text),
204 Value::Array(values) => {
205 for value in values {
206 rewrite_contract_strings(value, rewrite, depth + 1)?;
207 }
208 }
209 Value::Object(values) => {
210 for value in values.values_mut() {
211 rewrite_contract_strings(value, rewrite, depth + 1)?;
212 }
213 }
214 _ => {}
215 }
216 Ok(())
217}
218
219#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222pub struct LegacyAdoption {
223 pub logical_migrations: Vec<String>,
224 #[serde(deserialize_with = "deserialize_legacy_ledgers")]
225 pub ledgers: Vec<BTreeMap<String, String>>,
226}
227
228fn deserialize_legacy_ledgers<'de, D>(
231 deserializer: D,
232) -> Result<Vec<BTreeMap<String, String>>, D::Error>
233where
234 D: Deserializer<'de>,
235{
236 #[derive(Deserialize)]
237 #[serde(untagged)]
238 enum LedgerInput {
239 Sets(Vec<BTreeMap<String, String>>),
240 Historical(BTreeMap<String, BTreeMap<String, String>>),
241 }
242 match LedgerInput::deserialize(deserializer)? {
243 LedgerInput::Sets(sets) => Ok(sets),
244 LedgerInput::Historical(sets) => {
245 if sets.keys().any(|name| !valid_identifier(name)) {
246 return Err(serde::de::Error::custom(
247 "invalid historical backend identifier",
248 ));
249 }
250 Ok(sets.into_values().collect())
251 }
252 }
253}
254
255pub fn legacy_adoption_schema(
258 contract: &AppDataContract,
259) -> Result<Option<AppSchema>, DatabaseError> {
260 validate_contract(contract)?;
261 let Some(adoption) = &contract.legacy_adoption else {
262 return Ok(None);
263 };
264 schema_through_migrations(contract, adoption.logical_migrations.len()).map(Some)
265}
266
267pub fn schema_through_migrations(
269 contract: &AppDataContract,
270 count: usize,
271) -> Result<AppSchema, DatabaseError> {
272 if count == 0 || count > contract.migrations.len() {
273 return Err(DatabaseError::InvalidContract);
274 }
275 let mut staged = BTreeMap::<String, Entity>::new();
276 for migration in contract.migrations.iter().take(count) {
277 for change in &migration.changes {
278 match change {
279 SchemaChange::CreateEntity(entity) => {
280 staged.insert(entity.name.clone(), entity.clone());
281 }
282 SchemaChange::RetireFieldConstraint { .. } => {}
283 SchemaChange::ConvertField {
284 entity,
285 previous,
286 field,
287 } => {
288 crate::conversion::apply_declaration(
289 staged
290 .get_mut(entity)
291 .ok_or(DatabaseError::InvalidContract)?,
292 previous,
293 field,
294 )?;
295 }
296 SchemaChange::WidenIntegerToDecimal { entity, field } => {
297 widen_integer_to_decimal(
298 staged
299 .get_mut(entity)
300 .ok_or(DatabaseError::InvalidContract)?,
301 field,
302 )?;
303 }
304 SchemaChange::AddField { entity, field } => {
305 staged
306 .get_mut(entity)
307 .ok_or(DatabaseError::InvalidContract)?
308 .fields
309 .push(field.clone());
310 }
311 SchemaChange::PromoteIdentity { entity, field } => {
312 promote_identity(
313 staged
314 .get_mut(entity)
315 .ok_or(DatabaseError::InvalidContract)?,
316 field,
317 )?;
318 }
319 SchemaChange::AddUnique { entity, fields } => {
320 staged
321 .get_mut(entity)
322 .ok_or(DatabaseError::InvalidContract)?
323 .unique
324 .push(fields.clone());
325 }
326 SchemaChange::AddIndex { entity, fields } => {
327 staged
328 .get_mut(entity)
329 .ok_or(DatabaseError::InvalidContract)?
330 .indexes
331 .push(fields.clone());
332 }
333 SchemaChange::AddConditionalIndex { entity, index } => {
334 staged
335 .get_mut(entity)
336 .ok_or(DatabaseError::InvalidContract)?
337 .conditional_indexes
338 .push(index.clone());
339 }
340 SchemaChange::SeedFrom { .. }
341 | SchemaChange::Seed { .. }
342 | SchemaChange::Backfill { .. }
343 | SchemaChange::DeleteWhere { .. }
344 | SchemaChange::CopyJsonRecords { .. }
345 | SchemaChange::CopyField { .. } => {}
346 }
347 }
348 }
349 let schema = AppSchema {
350 app_id: contract.schema.app_id.clone(),
351 entities: staged.into_values().collect(),
352 };
353 validate_schema(&schema)?;
354 Ok(schema)
355}
356
357pub fn verify_legacy_ledger(
361 expected: &BTreeMap<String, String>,
362 observed: &BTreeMap<String, String>,
363) -> Result<(), DatabaseError> {
364 if expected.is_empty() || expected.len() > 256 {
365 return Err(DatabaseError::InvalidContract);
366 }
367 for (id, checksum) in expected {
368 if !valid_identifier(id)
369 || checksum.len() != 64
370 || !checksum.bytes().all(|byte| byte.is_ascii_hexdigit())
371 {
372 return Err(DatabaseError::InvalidContract);
373 }
374 }
375 if !expected.keys().any(|id| observed.contains_key(id)) {
376 return Err(DatabaseError::MissingRecord);
377 }
378 for (id, checksum) in expected {
379 match observed.get(id) {
380 Some(actual) if actual == checksum => {}
381 Some(_) => return Err(DatabaseError::Conflict),
382 None => return Err(DatabaseError::Conflict),
383 }
384 }
385 if observed.len() != expected.len() {
386 return Err(DatabaseError::Conflict);
387 }
388 Ok(())
389}
390
391fn promote_identity(entity: &mut Entity, name: &str) -> Result<(), DatabaseError> {
392 if !valid_identifier(name)
393 || entity.identity != Identity::Composite(vec![name.to_owned()])
394 || !entity.fields.iter().any(|field| {
395 field.name == name
396 && field.kind == FieldType::Integer
397 && !field.nullable
398 && field.default.is_none()
399 && field.references.is_none()
400 && field.constraints.is_empty()
401 })
402 {
403 return Err(DatabaseError::InvalidContract);
404 }
405 entity.identity = Identity::AutoInteger(name.to_owned());
406 Ok(())
407}
408
409fn widen_integer_to_decimal(entity: &mut Entity, name: &str) -> Result<(), DatabaseError> {
411 if entity.identity.fields().contains(&name) {
412 return Err(DatabaseError::InvalidContract);
413 }
414 let field = entity
415 .fields
416 .iter_mut()
417 .find(|field| field.name == name)
418 .ok_or(DatabaseError::InvalidContract)?;
419 if field.kind != FieldType::Integer || field.references.is_some() {
420 return Err(DatabaseError::InvalidContract);
421 }
422 field.kind = FieldType::Decimal;
423 Ok(())
424}
425
426pub fn migration_checksum(
428 app_id: &str,
429 id: &str,
430 changes: &[SchemaChange],
431) -> Result<String, DatabaseError> {
432 if !valid_identifier(app_id) || !valid_identifier(id) || changes.is_empty() {
433 return Err(DatabaseError::InvalidContract);
434 }
435 operation_fingerprint(&(app_id, id, changes))
436}
437
438pub fn validate_migration(schema: &AppSchema, migration: &Migration) -> Result<(), DatabaseError> {
440 validate_schema(schema)?;
441 if migration.app_id != schema.app_id
442 || !valid_identifier(&migration.id)
443 || migration.changes.is_empty()
444 || migration.changes.len() > 256
445 || migration.checksum
446 != migration_checksum(&migration.app_id, &migration.id, &migration.changes)?
447 {
448 return Err(DatabaseError::InvalidContract);
449 }
450 Ok(())
451}
452
453pub fn validate_contract(contract: &AppDataContract) -> Result<(), DatabaseError> {
455 validate_schema(&contract.schema)?;
456 if contract.migrations.is_empty() || contract.migrations.len() > 256 {
457 return Err(DatabaseError::InvalidContract);
458 }
459 if let Some(adoption) = &contract.legacy_adoption {
460 if adoption.logical_migrations.is_empty()
461 || adoption.logical_migrations.len() > contract.migrations.len()
462 || adoption.ledgers.is_empty()
463 || adoption.ledgers.len() > 32
464 || adoption
465 .logical_migrations
466 .iter()
467 .zip(&contract.migrations)
468 .any(|(id, migration)| id != &migration.id)
469 {
470 return Err(DatabaseError::InvalidContract);
471 }
472 for expected in &adoption.ledgers {
473 verify_legacy_ledger(expected, expected)?;
474 }
475 }
476 let mut migrations = BTreeSet::new();
477 let mut staged = BTreeMap::<String, Entity>::new();
478 for migration in &contract.migrations {
479 if migration.app_id != contract.schema.app_id
480 || !valid_identifier(&migration.id)
481 || !migrations.insert(&migration.id)
482 || migration.changes.is_empty()
483 || migration.changes.len() > 256
484 || migration.checksum
485 != migration_checksum(&migration.app_id, &migration.id, &migration.changes)?
486 {
487 return Err(DatabaseError::InvalidContract);
488 }
489 for change in &migration.changes {
490 match change {
491 SchemaChange::CreateEntity(entity) => {
492 if staged.insert(entity.name.clone(), entity.clone()).is_some()
493 || !contract
494 .schema
495 .entities
496 .iter()
497 .any(|item| item.name == entity.name)
498 {
499 return Err(DatabaseError::InvalidContract);
500 }
501 }
502 SchemaChange::RetireFieldConstraint { entity, field, .. } => {
503 if !staged.get(entity).is_some_and(|declaration| {
504 declaration.fields.iter().any(|item| item.name == *field)
505 }) {
506 return Err(DatabaseError::InvalidContract);
507 }
508 crate::conversion::normalize_field_change(&contract.schema, change)?;
509 }
510 SchemaChange::ConvertField {
511 entity,
512 previous,
513 field,
514 } => {
515 crate::conversion::apply_declaration(
516 staged
517 .get_mut(entity)
518 .ok_or(DatabaseError::InvalidContract)?,
519 previous,
520 field,
521 )?;
522 }
523 SchemaChange::WidenIntegerToDecimal { entity, field } => {
524 widen_integer_to_decimal(
525 staged
526 .get_mut(entity)
527 .ok_or(DatabaseError::InvalidContract)?,
528 field,
529 )?;
530 }
531 SchemaChange::AddField { entity, field } => {
532 let declaration = staged
533 .get_mut(entity)
534 .ok_or(DatabaseError::InvalidContract)?;
535 if declaration
536 .fields
537 .iter()
538 .any(|existing| existing.name == field.name)
539 {
540 return Err(DatabaseError::InvalidContract);
541 }
542 declaration.fields.push(field.clone());
543 }
544 SchemaChange::PromoteIdentity { entity, field } => {
545 promote_identity(
546 staged
547 .get_mut(entity)
548 .ok_or(DatabaseError::InvalidContract)?,
549 field,
550 )?;
551 }
552 SchemaChange::AddUnique { entity, fields }
553 | SchemaChange::AddIndex { entity, fields } => {
554 let declaration = staged
555 .get_mut(entity)
556 .ok_or(DatabaseError::InvalidContract)?;
557 if fields.is_empty()
558 || fields
559 .iter()
560 .any(|name| !declaration.fields.iter().any(|field| field.name == *name))
561 {
562 return Err(DatabaseError::InvalidContract);
563 }
564 if matches!(change, SchemaChange::AddUnique { .. }) {
565 declaration.unique.push(fields.clone());
566 } else {
567 declaration.indexes.push(fields.clone());
568 }
569 }
570 SchemaChange::AddConditionalIndex { entity, index } => {
571 let declaration = staged
572 .get_mut(entity)
573 .ok_or(DatabaseError::InvalidContract)?;
574 if index.keys.is_empty()
575 || index.keys.iter().any(|key| {
576 let name = match key {
577 IndexKey::Field(name) | IndexKey::Lowercase(name) => name,
578 };
579 !declaration.fields.iter().any(|field| field.name == *name)
580 })
581 {
582 return Err(DatabaseError::InvalidContract);
583 }
584 if let Some(filter) = &index.filter {
585 validate_predicate(declaration, filter)?;
586 }
587 declaration.conditional_indexes.push(index.clone());
588 }
589 SchemaChange::SeedFrom { .. } => {
590 crate::seed_from::queries(
591 &AppSchema {
592 app_id: contract.schema.app_id.clone(),
593 entities: staged.values().cloned().collect(),
594 },
595 change,
596 )?;
597 }
598 SchemaChange::Seed { entity, values } => {
599 let declaration = staged.get(entity).ok_or(DatabaseError::InvalidContract)?;
600 if values.is_empty()
601 || values
602 .keys()
603 .any(|name| !declaration.fields.iter().any(|field| field.name == *name))
604 {
605 return Err(DatabaseError::InvalidContract);
606 }
607 for (name, value) in values {
608 let field = declaration
609 .fields
610 .iter()
611 .find(|field| field.name == *name)
612 .ok_or(DatabaseError::InvalidContract)?;
613 validate_field_value(field, value)
614 .map_err(|_| DatabaseError::InvalidContract)?;
615 }
616 }
617 SchemaChange::Backfill {
618 entity,
619 filter,
620 values,
621 } => {
622 let declaration = staged.get(entity).ok_or(DatabaseError::InvalidContract)?;
623 validate_predicate(declaration, filter)?;
624 if values.is_empty()
625 || values
626 .keys()
627 .any(|name| !declaration.fields.iter().any(|field| field.name == *name))
628 {
629 return Err(DatabaseError::InvalidContract);
630 }
631 for (name, value) in values {
632 let field = declaration
633 .fields
634 .iter()
635 .find(|field| field.name == *name)
636 .ok_or(DatabaseError::InvalidContract)?;
637 validate_field_value(field, value)
638 .map_err(|_| DatabaseError::InvalidContract)?;
639 }
640 }
641 SchemaChange::DeleteWhere { entity, filter } => {
642 let declaration = staged.get(entity).ok_or(DatabaseError::InvalidContract)?;
643 validate_predicate(declaration, filter)?;
644 }
645 SchemaChange::CopyJsonRecords {
646 source_entity,
647 source_field,
648 source_filter,
649 section,
650 target_entity,
651 fields,
652 } => {
653 let source = staged
654 .get(source_entity)
655 .ok_or(DatabaseError::InvalidContract)?;
656 let target = staged
657 .get(target_entity)
658 .ok_or(DatabaseError::InvalidContract)?;
659 validate_json_record_copy(
660 source,
661 source_field,
662 source_filter,
663 section,
664 target,
665 fields,
666 )?;
667 }
668 SchemaChange::CopyField {
669 entity,
670 source,
671 target,
672 filter,
673 } => {
674 let declaration = staged.get(entity).ok_or(DatabaseError::InvalidContract)?;
675 validate_copy_field(declaration, source, target, filter)?;
676 }
677 }
678 }
679 }
680 if staged.len() != contract.schema.entities.len()
681 || contract
682 .schema
683 .entities
684 .iter()
685 .any(|entity| staged.get(&entity.name) != Some(entity))
686 {
687 return Err(DatabaseError::InvalidContract);
688 }
689 Ok(())
690}
691
692pub fn validate_json_record_copy(
694 source: &Entity,
695 source_field: &str,
696 filter: &crate::operations::Predicate,
697 section: &str,
698 target: &Entity,
699 fields: &[String],
700) -> Result<(), DatabaseError> {
701 if source.name == target.name
702 || !valid_identifier(section)
703 || fields.is_empty()
704 || fields.len() > 64
705 || source
706 .fields
707 .iter()
708 .find(|field| field.name == source_field)
709 .is_none_or(|field| !matches!(field.kind, FieldType::Text | FieldType::Json))
710 {
711 return Err(DatabaseError::InvalidContract);
712 }
713 validate_predicate(source, filter)?;
714 let mut seen = BTreeSet::new();
715 for name in fields {
716 if !seen.insert(name) || !target.fields.iter().any(|field| field.name == *name) {
717 return Err(DatabaseError::InvalidContract);
718 }
719 }
720 if target
721 .identity
722 .fields()
723 .iter()
724 .any(|name| !fields.iter().any(|field| field == name))
725 {
726 return Err(DatabaseError::InvalidContract);
727 }
728 Ok(())
729}
730
731pub fn load_contract(path: &Path, expected_app_id: &str) -> Result<AppDataContract, DatabaseError> {
733 let metadata = std::fs::symlink_metadata(path).map_err(|_| DatabaseError::InvalidContract)?;
734 if !metadata.file_type().is_file() || metadata.len() == 0 || metadata.len() > 8 * 1024 * 1024 {
735 return Err(DatabaseError::InvalidContract);
736 }
737 let bytes = std::fs::read(path).map_err(|_| DatabaseError::InvalidContract)?;
738 if bytes.len() > 8 * 1024 * 1024 {
739 return Err(DatabaseError::InvalidContract);
740 }
741 let contract: AppDataContract =
742 serde_json::from_slice(&bytes).map_err(|_| DatabaseError::InvalidContract)?;
743 if contract.schema.app_id != expected_app_id {
744 return Err(DatabaseError::InvalidContract);
745 }
746 validate_contract(&contract)?;
747 Ok(contract)
748}
749
750#[cfg(test)]
751mod tests {
752 use super::*;
753
754 #[test]
756 fn historical_ledger_sets_are_backend_neutral() {
757 let entity = Entity {
758 name: "record".to_owned(),
759 identity: Identity::AutoInteger("id".to_owned()),
760 fields: vec![Field {
761 name: "id".to_owned(),
762 kind: FieldType::Integer,
763 nullable: false,
764 max_length: None,
765 default: None,
766 references: None,
767 on_delete: None,
768 constraints: Vec::new(),
769 }],
770 unique: Vec::new(),
771 indexes: Vec::new(),
772 conditional_indexes: Vec::new(),
773 };
774 let changes = vec![SchemaChange::CreateEntity(entity.clone())];
775 let migration = Migration {
776 app_id: "probe".to_owned(),
777 id: "create_record".to_owned(),
778 checksum: migration_checksum("probe", "create_record", &changes).expect("checksum"),
779 changes,
780 };
781 let mut contract = AppDataContract {
782 schema: AppSchema {
783 app_id: "probe".to_owned(),
784 entities: vec![entity],
785 },
786 migrations: vec![migration],
787 legacy_adoption: Some(LegacyAdoption {
788 logical_migrations: vec!["create_record".to_owned()],
789 ledgers: vec![BTreeMap::from([("old_record".to_owned(), "a".repeat(64))])],
790 }),
791 };
792 assert_eq!(validate_contract(&contract), Ok(()));
793 let historical = serde_json::json!({
794 "logical_migrations": ["create_record"],
795 "ledgers": {"retired_adapter": {"old_record": "a".repeat(64)}}
796 });
797 let normalized: LegacyAdoption =
798 serde_json::from_value(historical).expect("read historical saved project");
799 assert_eq!(
800 normalized.ledgers,
801 contract.legacy_adoption.as_ref().expect("adoption").ledgers
802 );
803 let emitted = serde_json::to_value(&normalized).expect("write normalized contract");
804 assert!(emitted["ledgers"].is_array());
805 contract
806 .legacy_adoption
807 .as_mut()
808 .expect("adoption")
809 .ledgers
810 .push(BTreeMap::new());
811 assert_eq!(
812 validate_contract(&contract),
813 Err(DatabaseError::InvalidContract)
814 );
815 }
816
817 #[test]
818 fn populated_database_requires_exact_historical_ledger() {
819 let expected = BTreeMap::from([
820 ("base".to_owned(), "a".repeat(64)),
821 ("add_field".to_owned(), "b".repeat(64)),
822 ]);
823 assert_eq!(verify_legacy_ledger(&expected, &expected), Ok(()));
824 let mut missing = expected.clone();
825 missing.remove("add_field");
826 assert_eq!(
827 verify_legacy_ledger(&expected, &missing),
828 Err(DatabaseError::Conflict)
829 );
830 let unrelated = BTreeMap::from([("other_app".to_owned(), "c".repeat(64))]);
831 assert_eq!(
832 verify_legacy_ledger(&expected, &unrelated),
833 Err(DatabaseError::MissingRecord)
834 );
835 let mut drifted = expected.clone();
836 drifted.insert("base".to_owned(), "c".repeat(64));
837 assert_eq!(
838 verify_legacy_ledger(&expected, &drifted),
839 Err(DatabaseError::Conflict)
840 );
841 let mut newer = expected.clone();
842 newer.insert("unknown".to_owned(), "d".repeat(64));
843 assert_eq!(
844 verify_legacy_ledger(&expected, &newer),
845 Err(DatabaseError::Conflict)
846 );
847 }
848}