Skip to main content

pharos_runtime_compiler/
database.rs

1//! Database source normalization at the authoring/compiler boundary.
2use pharos_runtime_database::contract::{AppDataContract, validate_contract};
3use pharos_runtime_database::operations::DatabaseError;
4use serde_json::Value;
5use std::io::Read;
6use std::path::Path;
7
8/// Reads a regular contract within the compiler input limit, including file growth races.
9fn source_text(path: &Path) -> Result<String, DatabaseError> {
10    let meta = std::fs::symlink_metadata(path).map_err(|_| DatabaseError::InvalidContract)?;
11    if !meta.is_file() || meta.len() > 8 * 1024 * 1024 {
12        return Err(DatabaseError::InvalidContract);
13    }
14    let mut text = String::new();
15    std::fs::File::open(path)
16        .map_err(|_| DatabaseError::InvalidContract)?
17        .take(8 * 1024 * 1024 + 1)
18        .read_to_string(&mut text)
19        .map_err(|_| DatabaseError::InvalidContract)?;
20    if text.len() > 8 * 1024 * 1024 {
21        return Err(DatabaseError::InvalidContract);
22    }
23    Ok(text)
24}
25
26/// Normalizes a manifest-selected source document without changing runtime loaders.
27pub fn normalize_text(
28    text: &str,
29    yaml: bool,
30    app_id: &str,
31) -> Result<AppDataContract, DatabaseError> {
32    if text.len() > 8 * 1024 * 1024 {
33        return Err(DatabaseError::InvalidContract);
34    }
35    let value: Value = if yaml {
36        crate::database_yaml::parse(text).map_err(|_| DatabaseError::InvalidContract)?
37    } else {
38        serde_json::from_str(text).map_err(|_| DatabaseError::InvalidContract)?
39    };
40    validated_value(value, app_id)
41}
42
43/// Validates already expanded content once before canonical serialization.
44fn validated_value(value: Value, app_id: &str) -> Result<AppDataContract, DatabaseError> {
45    let contract: AppDataContract =
46        serde_json::from_value(value).map_err(|_| DatabaseError::InvalidContract)?;
47    if contract.schema.app_id != app_id {
48        return Err(DatabaseError::InvalidContract);
49    }
50    validate_contract(&contract)?;
51    json_bytes(&contract)?;
52    Ok(contract)
53}
54
55/// Reads an authoring contract through the compiler; served JSON loaders stay separate.
56pub fn load_source(path: &Path, app_id: &str) -> Result<AppDataContract, DatabaseError> {
57    let meta = std::fs::symlink_metadata(path).map_err(|_| DatabaseError::InvalidContract)?;
58    if !meta.is_file() || meta.len() > 8 * 1024 * 1024 {
59        return Err(DatabaseError::InvalidContract);
60    }
61    let extension = path
62        .extension()
63        .and_then(|v| v.to_str())
64        .ok_or(DatabaseError::InvalidContract)?;
65    if !matches!(extension, "json" | "yml" | "yaml") {
66        return Err(DatabaseError::InvalidContract);
67    }
68    let text = source_text(path)?;
69    normalize_text(&text, extension != "json", app_id)
70}
71
72/// Emits compact human-readable YAML with shared entity declarations for initial migrations.
73pub fn author_yaml(contract: &AppDataContract) -> Result<String, DatabaseError> {
74    validate_contract(contract)?;
75    let encoded = serde_json::to_string(contract).map_err(|_| DatabaseError::InvalidContract)?;
76    let namespace = format!(
77        "PHAROS_{}",
78        pharos_runtime_database::operations::bytes_fingerprint(encoded.as_bytes())
79    );
80    if encoded.contains(&namespace) {
81        return Err(DatabaseError::InvalidContract);
82    }
83    let mut value = serde_json::to_value(contract).map_err(|_| DatabaseError::InvalidContract)?;
84    let entities = value["schema"]["entities"]
85        .as_array()
86        .ok_or(DatabaseError::InvalidContract)?
87        .clone();
88    let mut markers = Vec::new();
89    for (index, entity) in entities.iter().enumerate() {
90        let marker = format!("{namespace}_ANCHOR_{index}");
91        value["schema"]["entities"][index] = Value::String(marker.clone());
92        markers.push((marker, entity.clone()));
93    }
94    if let Some(migrations) = value["migrations"].as_array_mut() {
95        for migration in migrations {
96            if let Some(changes) = migration["changes"].as_array_mut() {
97                for change in changes {
98                    if let Some(entity) = change.get("create_entity")
99                        && let Some(index) = entities.iter().position(|declared| declared == entity)
100                    {
101                        change["create_entity"] =
102                            Value::String(format!("{namespace}_ALIAS_{index}"));
103                    }
104                }
105            }
106        }
107    }
108    let emitted = pharos_runtime_core::yaml::serialize_yaml_value(&value)
109        .ok_or(DatabaseError::InvalidContract)?;
110    let mut text = String::new();
111    for line in emitted.lines() {
112        if let Some((index, (_, entity))) = markers.iter().enumerate().find(|(_, (marker, _))| {
113            line.trim().trim_start_matches("- ").trim_matches('"') == marker
114        }) {
115            let indent = line.len() - line.trim_start().len();
116            text.push_str(&format!("{}- &entity_{index}\n", " ".repeat(indent)));
117            let fields = pharos_runtime_core::yaml::serialize_yaml_value(entity)
118                .ok_or(DatabaseError::InvalidContract)?;
119            for field in fields.lines().filter(|line| *line != "---") {
120                text.push_str(&format!("{}{field}\n", " ".repeat(indent + 2)));
121            }
122        } else {
123            let mut replaced = line.to_owned();
124            for index in 0..entities.len() {
125                let marker = format!("{namespace}_ALIAS_{index}");
126                replaced = replaced.replace(&format!("\"{marker}\""), &format!("*entity_{index}"));
127                replaced = replaced.replace(&marker, &format!("*entity_{index}"));
128            }
129            text.push_str(&replaced);
130            text.push('\n');
131        }
132    }
133    let verified = normalize_text(&text, true, &contract.schema.app_id)?;
134    if verified != *contract {
135        return Err(DatabaseError::InvalidContract);
136    }
137    Ok(text)
138}
139
140/// Compiles the selected database YAML without source-map or reference rewriting mutations.
141pub fn compile_file(path: &Path) -> Result<Value, DatabaseError> {
142    let text = source_text(path)?;
143    let value = crate::database_yaml::parse(&text).map_err(|diagnostic| {
144        eprintln!(
145            "{}:{}:{}: {}: {}",
146            path.display(),
147            diagnostic.line,
148            diagnostic.column,
149            diagnostic.code,
150            diagnostic.message
151        );
152        DatabaseError::InvalidContract
153    })?;
154    let app_id = value
155        .pointer("/schema/app_id")
156        .and_then(Value::as_str)
157        .ok_or(DatabaseError::InvalidContract)?;
158    let app_id = app_id.to_owned();
159    let contract = validated_value(value, &app_id)?;
160    serde_json::to_value(contract).map_err(|_| DatabaseError::InvalidContract)
161}
162
163/// Materializes an explicitly selected authoring contract as a JSON build artifact.
164pub fn materialize_json(
165    source: impl AsRef<Path>,
166    destination: impl AsRef<Path>,
167) -> Result<u64, std::io::Error> {
168    let source = source.as_ref();
169    let destination = destination.as_ref();
170    let text = source_text(source).map_err(|_| {
171        std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid source contract")
172    })?;
173    let yaml = source.extension().and_then(|v| v.to_str()) != Some("json");
174    let value = if yaml {
175        crate::database_yaml::parse(&text)
176            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.message))?
177    } else {
178        serde_json::from_str(&text)?
179    };
180    let app_id = value
181        .pointer("/schema/app_id")
182        .and_then(Value::as_str)
183        .ok_or_else(|| {
184            std::io::Error::new(std::io::ErrorKind::InvalidData, "missing app identity")
185        })?;
186    let app_id = app_id.to_owned();
187    let contract = validated_value(value, &app_id).map_err(|_| {
188        std::io::Error::new(
189            std::io::ErrorKind::InvalidData,
190            "invalid logical database contract",
191        )
192    })?;
193    let bytes = json_bytes(&contract).map_err(|_| {
194        std::io::Error::new(
195            std::io::ErrorKind::InvalidData,
196            "compiled contract exceeds output limit",
197        )
198    })?;
199    pharos_runtime_core::fs::write_binary_file(destination, &bytes, 0o640)?;
200    Ok(bytes.len() as u64)
201}
202
203/// Compiles the manifest-selected source contract into an external build artifact.
204pub fn compile_selected(
205    root: &Path,
206    artifact: &Path,
207    app_id: &str,
208) -> Result<std::path::PathBuf, DatabaseError> {
209    let manifest = pharos_runtime_core::yaml::parse_yaml_file(&root.join("pharos.app.yml"))
210        .ok_or(DatabaseError::InvalidContract)?;
211    if manifest.get("app_id").and_then(Value::as_str) != Some(app_id) {
212        return Err(DatabaseError::InvalidContract);
213    }
214    let selected = manifest
215        .pointer("/database/migration_contract")
216        .and_then(Value::as_str)
217        .ok_or(DatabaseError::InvalidContract)?;
218    let relative = Path::new(selected);
219    if !relative
220        .components()
221        .all(|component| matches!(component, std::path::Component::Normal(_)))
222    {
223        return Err(DatabaseError::InvalidContract);
224    }
225    let root = root
226        .canonicalize()
227        .map_err(|_| DatabaseError::InvalidContract)?;
228    let source = root.join(relative);
229    if source
230        .canonicalize()
231        .map_err(|_| DatabaseError::InvalidContract)?
232        != source
233    {
234        return Err(DatabaseError::InvalidContract);
235    }
236    let contract = load_source(&source, app_id)?;
237    std::fs::create_dir_all(artifact).map_err(|_| DatabaseError::InvalidContract)?;
238    let artifact = artifact
239        .canonicalize()
240        .map_err(|_| DatabaseError::InvalidContract)?;
241    if artifact.starts_with(&root) {
242        return Err(DatabaseError::InvalidContract);
243    }
244    let destination = artifact.join(relative.with_extension("json"));
245    pharos_runtime_core::fs::write_file_within(
246        &artifact,
247        &destination,
248        &json_bytes(&contract)?,
249        0o640,
250    )
251    .map_err(|_| DatabaseError::InvalidContract)?;
252    Ok(destination)
253}
254
255/// Bounds canonical JSON output before writing any compiled contract artifact.
256fn json_bytes(contract: &AppDataContract) -> Result<Vec<u8>, DatabaseError> {
257    let bytes = serde_json::to_vec_pretty(contract).map_err(|_| DatabaseError::InvalidContract)?;
258    if bytes.len() > 8 * 1024 * 1024 {
259        return Err(DatabaseError::InvalidContract);
260    }
261    Ok(bytes)
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use pharos_runtime_database::contract::migration_checksum;
268    use pharos_runtime_database::operations::validate_schema;
269    /// Certifies UCAL authoring at the compiler boundary before JSON-only runtime loading.
270    #[test]
271    fn staged_ucal_contract_remains_valid() {
272        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
273            .join("../../../apps/ucal/data/contracts/database.yml");
274        let text = std::fs::read_to_string(path).expect("UCAL contract");
275        let contract: AppDataContract = serde_json::from_value(
276            crate::database_yaml::parse(&text).expect("compile source YAML"),
277        )
278        .expect("parse UCAL contract");
279        assert_eq!(validate_schema(&contract.schema), Ok(()));
280        for migration in &contract.migrations {
281            assert_eq!(
282                migration.checksum,
283                migration_checksum(&migration.app_id, &migration.id, &migration.changes)
284                    .expect("checksum")
285            );
286        }
287        assert_eq!(validate_contract(&contract), Ok(()));
288    }
289}