Skip to main content

pharos_runtime_database/
contract.rs

1//! Bounded loading and validation for one app-owned logical database contract.
2
3use 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
15/// Builds a typed projection from the app's compiled entity contract.
16/// Every requested field must declare its logical type; guesses from SQL storage
17/// or field names would let one backend interpret app data differently.
18pub 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/// One backend-neutral app schema and its ordered additive migrations.
129#[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
137/// Reidentifies a source copy as a new app without selecting a database vendor.
138/// Historical adoption belongs to the original app's populated database and
139/// must not authorize a different app's fresh database.
140pub 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
162/// Rewrites string values in a fresh source copy and regenerates its migration
163/// checksums. This is an authoring operation, never an applied-ledger update.
164pub 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    // Imported source changes cannot adopt a ledger for the original contract.
177    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
193/// Visits bounded JSON values without rewriting field or entity map keys.
194fn 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/// Historical checksum sets prove populated-database adoption without naming
220/// a backend or selecting a physical migration file in an app contract.
221#[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
228/// Reads checksum sets from current app contracts and older saved projects.
229/// Historical backend labels are discarded; adapters verify physical ledgers.
230fn 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
255/// Reconstructs the schema at the end of the historical migration prefix.
256/// Later logical migrations must not be required in an existing populated database.
257pub 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
267/// Replays declaration changes through one migration boundary without exposing future fields.
268pub 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
357/// Checks historical migration evidence before a populated database can be
358/// adopted into the logical ledger. A legacy checksum records what actually
359/// ran; it is never inferred from the current physical table names.
360pub 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
409/// Allows only a lossless integer-to-decimal change outside keys and relations.
410fn 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
426/// Computes the source checksum that every adapter must record in its ledger.
427pub 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
438/// Validates one migration before an adapter writes its logical ledger.
439pub 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
453/// Validates migration identity, source checksums, and entity creation coverage.
454pub 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
692/// Validates historical JSON adoption without selecting a database vendor.
693pub 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
731/// Reads one verified app-owned contract without following symlinks or oversized input.
732pub 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    /// Historical adoption checksums have no adapter selector in app contracts.
755    #[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}