1#![allow(clippy::too_many_arguments)]
2
3pub mod operations;
4
5use super::json_commands::manifest_database_contract_path;
6use pharos_runtime_database::operations::DatabaseError;
7use std::path::Path;
8
9pub fn dynamic_relational_backend(
30 app_root: &Path,
31 runtime_conf_path: Option<&Path>,
32) -> Option<String> {
33 if !relational_runtime_authority_enabled(app_root) {
34 return None;
35 }
36 load_cli_backend_config(app_root, runtime_conf_path, None)
37 .and_then(|config| operations::resolve(&config).ok())
38 .map(|backend| backend.backend_name().to_owned())
39}
40
41pub fn load_cli_backend_config(
63 app_root: &Path,
64 runtime_conf_path: Option<&Path>,
65 app_id: Option<&str>,
66) -> Option<pharos_runtime_database::DbBackendConfig> {
67 checked_backend_config(app_root, runtime_conf_path, app_id).ok()
68}
69
70pub fn checked_backend_config(
72 app_root: &Path,
73 runtime_conf_path: Option<&Path>,
74 app_id: Option<&str>,
75) -> Result<pharos_runtime_database::DbBackendConfig, DatabaseError> {
76 if app_root.as_os_str().is_empty() {
77 return Err(DatabaseError::InvalidInput);
78 }
79 pharos_runtime_database::DbBackendConfig::from_lookup(
80 app_id.filter(|value| !value.is_empty()).unwrap_or("app"),
81 |key| super::cache::dynamic_config_allowlist_value(app_root, runtime_conf_path, key),
82 )
83 .map_err(|_| DatabaseError::InvalidContract)
84}
85pub fn selected_logical_contract(
87 app_root: &Path,
88 app_id: &str,
89) -> Result<
90 Option<pharos_runtime_database::contract::AppDataContract>,
91 pharos_runtime_database::operations::DatabaseError,
92> {
93 use pharos_runtime_database::operations::{DatabaseError, valid_identifier};
94 if !valid_identifier(app_id) {
95 return Err(DatabaseError::InvalidInput);
96 }
97 let Some(relative) = manifest_database_contract_path(app_root) else {
98 return Ok(None);
99 };
100 let relative_path = Path::new(&relative);
101 if relative_path.extension().and_then(|value| value.to_str()) != Some("json") {
102 return Err(DatabaseError::InvalidContract);
103 }
104 if relative_path
105 .components()
106 .any(|component| !matches!(component, std::path::Component::Normal(_)))
107 {
108 return Err(DatabaseError::InvalidContract);
109 }
110 let root = app_root
111 .canonicalize()
112 .map_err(|_| DatabaseError::InvalidContract)?;
113 let path = root.join(relative_path);
114 let canonical = path
115 .canonicalize()
116 .map_err(|_| DatabaseError::InvalidContract)?;
117 if !canonical.starts_with(&root) || canonical != path {
118 return Err(DatabaseError::InvalidContract);
119 }
120 pharos_runtime_database::contract::load_contract(&path, app_id).map(Some)
121}
122
123pub struct LogicalAppStore {
125 pub config: pharos_runtime_database::DbBackendConfig,
126 pub schema: pharos_runtime_database::operations::AppSchema,
127 pub adapter: std::sync::Arc<dyn pharos_runtime_database::operations::DatabaseOperations>,
128}
129
130pub fn logical_app_store(
132 app_root: &Path,
133 runtime_conf_path: Option<&Path>,
134 app_id: &str,
135) -> Result<LogicalAppStore, pharos_runtime_database::operations::DatabaseError> {
136 logical_app_store_at(app_root, app_root, runtime_conf_path, app_id)
137}
138
139pub fn runtime_logical_app_store(
141 app: &crate::config::AppRuntime,
142) -> Result<LogicalAppStore, DatabaseError> {
143 let source = app
144 .app_root
145 .as_deref()
146 .or(app.artifact_dir.as_deref())
147 .map(Path::new)
148 .ok_or(DatabaseError::InvalidContract)?;
149 let artifact = app.artifact_dir.as_deref().map(Path::new).unwrap_or(source);
150 let app_id = app
151 .app_id
152 .as_deref()
153 .ok_or(DatabaseError::InvalidContract)?;
154 logical_app_store_at(
155 source,
156 artifact,
157 app.runtime_conf_path.as_deref().map(Path::new),
158 app_id,
159 )
160}
161
162fn logical_app_store_at(
164 app_root: &Path,
165 contract_root: &Path,
166 runtime_conf_path: Option<&Path>,
167 app_id: &str,
168) -> Result<LogicalAppStore, DatabaseError> {
169 let contract =
170 selected_logical_contract(contract_root, app_id)?.ok_or(DatabaseError::InvalidContract)?;
171 logical_store_for_schema(app_root, runtime_conf_path, contract.schema)
172}
173
174fn logical_store_for_schema(
176 app_root: &Path,
177 runtime_conf_path: Option<&Path>,
178 schema: pharos_runtime_database::operations::AppSchema,
179) -> Result<LogicalAppStore, DatabaseError> {
180 let config = checked_backend_config(app_root, runtime_conf_path, Some(&schema.app_id))?;
181 let adapter = operations::resolve(&config)?;
182 Ok(LogicalAppStore {
183 config,
184 schema,
185 adapter,
186 })
187}
188
189pub fn source_app_operations_store(
191 app_root: &Path,
192 runtime_conf_path: Option<&Path>,
193 app_id: &str,
194 build_dir: Option<&Path>,
195) -> Result<LogicalAppStore, DatabaseError> {
196 let contract =
197 source_selected_logical_contract(app_root, runtime_conf_path, app_id, build_dir)?
198 .ok_or(DatabaseError::InvalidContract)?;
199 logical_store_for_schema(app_root, runtime_conf_path, contract.schema)
200}
201
202pub fn source_selected_logical_contract(
205 app_root: &Path,
206 runtime_conf_path: Option<&Path>,
207 app_id: &str,
208 build_dir: Option<&Path>,
209) -> Result<Option<pharos_runtime_database::contract::AppDataContract>, DatabaseError> {
210 if app_root.join("pharos.app.json").is_file() {
211 return selected_logical_contract(app_root, app_id);
212 }
213 let manifest = pharos_runtime_core::yaml::parse_yaml_file(&app_root.join("pharos.app.yml"))
214 .ok_or(DatabaseError::InvalidContract)?;
215 if manifest.pointer("/database/migration_contract").is_none() {
216 return Ok(None);
217 }
218 let configured_build = pharos_runtime_core::config::dynamic_config_effective_value(
219 app_root,
220 runtime_conf_path,
221 "build_dir",
222 )
223 .unwrap_or_else(|| "dist".to_owned());
224 let build_dir = build_dir.unwrap_or(Path::new(&configured_build));
225 let artifact = if app_root.join("app.conf").is_file() {
227 pharos_runtime_core::config::effective_app_artifact_dir(app_root, build_dir, app_id)
228 } else {
229 pharos_runtime_core::config::app_artifact_dir_from_build_dir(build_dir, app_id)
230 }
231 .ok_or(DatabaseError::InvalidContract)?;
232 let compiled = pharos_runtime_compiler::database::compile_selected(
233 app_root,
234 Path::new(&artifact),
235 app_id,
236 )?;
237 pharos_runtime_database::contract::load_contract(&compiled, app_id).map(Some)
238}
239
240pub fn runtime_database_ready(app: &crate::config::AppRuntime) -> Option<bool> {
242 let store = runtime_logical_app_store(app).ok()?;
243 Some(store.adapter.ready(&store.config, &store.schema).is_ok())
244}
245
246pub fn app_operations_store(
248 app_root: &Path,
249 runtime_conf_path: Option<&Path>,
250 app_id: &str,
251) -> Result<LogicalAppStore, pharos_runtime_database::operations::DatabaseError> {
252 logical_app_store(app_root, runtime_conf_path, app_id)
253}
254
255pub fn app_local_database_artifact_path(
258 app_root: &Path,
259 runtime_conf_path: Option<&Path>,
260 app_id: &str,
261) -> Result<Option<std::path::PathBuf>, pharos_runtime_database::operations::DatabaseError> {
262 use pharos_runtime_database::operations::DatabaseError;
263 if !relational_runtime_authority_enabled(app_root) {
264 return Ok(None);
265 }
266 let config = checked_backend_config(app_root, runtime_conf_path, Some(app_id))?;
267 let adapter = operations::resolve(&config)?;
268 if adapter.local_artifact_format().is_none() {
269 return Ok(None);
270 }
271 config
272 .sqlite_path
273 .map(Some)
274 .ok_or(DatabaseError::InvalidInput)
275}
276
277pub fn dynamic_relational_authenticate_user(
279 app_root: &Path,
280 runtime_conf_path: Option<&Path>,
281 app_id: Option<&str>,
282 username: &str,
283 password: &str,
284) -> Result<Option<i64>, DatabaseError> {
285 let store = app_operations_store(
286 app_root,
287 runtime_conf_path,
288 app_id.ok_or(DatabaseError::InvalidInput)?,
289 )?;
290 let Some(credentials) =
291 store
292 .adapter
293 .user_credentials(&store.config, &store.schema, username)?
294 else {
295 return Ok(None);
296 };
297 Ok((credentials.is_active
298 && crate::runtime::dynamic::verify_password_hash(
299 &credentials.password_hash,
300 password,
301 Some(username),
302 ))
303 .then_some(credentials.user_id))
304}
305
306pub fn dynamic_relational_update_user_password(
308 app_root: &Path,
309 runtime_conf_path: Option<&Path>,
310 app_id: Option<&str>,
311 user_id: i64,
312 password_hash: &str,
313) -> Result<(), pharos_runtime_database::operations::DatabaseError> {
314 let config = checked_backend_config(app_root, runtime_conf_path, app_id)?;
315 let adapter = operations::resolve(&config)?;
316 adapter.update_user_password(&config, user_id, password_hash)
317}
318
319pub fn dynamic_relational_issue_session(
321 app_root: &Path,
322 runtime_conf_path: Option<&Path>,
323 app_id: Option<&str>,
324 user_id: i64,
325 session_token_hash: &str,
326 expires_at: &str,
327) -> Result<(), DatabaseError> {
328 let config = checked_backend_config(app_root, runtime_conf_path, app_id)?;
329 operations::resolve(&config)?.issue_session(&config, user_id, session_token_hash, expires_at)
330}
331
332pub fn dynamic_relational_managed_businesses(
334 app_root: &Path,
335 runtime_conf_path: Option<&Path>,
336 app_id: Option<&str>,
337 user_id: i64,
338 limit: usize,
339) -> Result<serde_json::Value, DatabaseError> {
340 let store = app_operations_store(
341 app_root,
342 runtime_conf_path,
343 app_id.ok_or(DatabaseError::InvalidInput)?,
344 )?;
345 let businesses = pharos_runtime_business::managed_businesses(
346 store.adapter.as_ref(),
347 &store.config,
348 &store.schema,
349 user_id,
350 limit,
351 )?;
352 serde_json::to_value(businesses).map_err(|_| DatabaseError::StorageFailure)
353}
354
355pub fn dynamic_relational_actor_tenant_roles(
357 app_root: &Path,
358 runtime_conf_path: Option<&Path>,
359 app_id: Option<&str>,
360 user_id: i64,
361) -> Result<Vec<String>, DatabaseError> {
362 let store = app_operations_store(
363 app_root,
364 runtime_conf_path,
365 app_id.ok_or(DatabaseError::InvalidInput)?,
366 )?;
367 pharos_runtime_business::actor_tenant_roles(
368 store.adapter.as_ref(),
369 &store.config,
370 &store.schema,
371 user_id,
372 )
373}
374
375pub fn dynamic_relational_actor_manages_business(
377 app_root: &Path,
378 runtime_conf_path: Option<&Path>,
379 app_id: Option<&str>,
380 user_id: i64,
381 business_slug: &str,
382) -> Result<bool, DatabaseError> {
383 let store = app_operations_store(
384 app_root,
385 runtime_conf_path,
386 app_id.ok_or(DatabaseError::InvalidInput)?,
387 )?;
388 pharos_runtime_business::actor_manages_business(
389 store.adapter.as_ref(),
390 &store.config,
391 &store.schema,
392 user_id,
393 business_slug,
394 )
395}
396
397pub fn dynamic_relational_actor_owns_appointment(
400 app_root: &Path,
401 runtime_conf_path: Option<&Path>,
402 app_id: Option<&str>,
403 user_id: i64,
404 appointment_id: i64,
405) -> Result<bool, DatabaseError> {
406 let store = app_operations_store(
407 app_root,
408 runtime_conf_path,
409 app_id.ok_or(DatabaseError::InvalidInput)?,
410 )?;
411 pharos_runtime_business::actor_owns_appointment(
412 store.adapter.as_ref(),
413 &store.config,
414 &store.schema,
415 user_id,
416 appointment_id,
417 )
418}
419
420pub fn dynamic_relational_append_rate_limit_event(
422 app_root: &Path,
423 runtime_conf_path: Option<&Path>,
424 app_id: Option<&str>,
425 policy_key: &str,
426 bucket_key: &str,
427 outcome: &str,
428) -> Result<(), DatabaseError> {
429 let config = checked_backend_config(app_root, runtime_conf_path, app_id)?;
430 operations::resolve(&config)?.append_rate_limit_event(&config, policy_key, bucket_key, outcome)
431}
432
433pub fn dynamic_relational_admit_rate_limited_request(
435 app_root: &Path,
436 runtime_conf_path: Option<&Path>,
437 app_id: Option<&str>,
438 policy_key: &str,
439 bucket_key: &str,
440 maximum: usize,
441 window_seconds: u64,
442) -> Result<bool, DatabaseError> {
443 let config = checked_backend_config(app_root, runtime_conf_path, app_id)?;
444 operations::resolve(&config)?.admit_rate_limited_request(
445 &config,
446 policy_key,
447 bucket_key,
448 maximum,
449 window_seconds,
450 )
451}
452
453pub fn dynamic_relational_runtime_export_json(
475 app_root: &Path,
476 runtime_conf_path: Option<&Path>,
477 app_id: Option<&str>,
478) -> Option<String> {
479 let manifest = super::declarative::declarative_source_load_fixture(app_root, "pharos.app.yml")?;
480 let app_id = app_id.or_else(|| manifest.get("app_id").and_then(serde_json::Value::as_str))?;
481 let contract =
482 source_selected_logical_contract(app_root, runtime_conf_path, app_id, None).ok()??;
483 let store =
484 logical_store_for_schema(app_root, runtime_conf_path, contract.schema.clone()).ok()?;
485 pharos_runtime_database::snapshot::DatabaseSnapshot::capture(
486 store.adapter.as_ref(),
487 &store.config,
488 &contract,
489 )
490 .ok()?
491 .encode()
492 .ok()
493}
494
495pub fn dynamic_relational_runtime_audit_count(
517 app_root: &Path,
518 runtime_conf_path: Option<&Path>,
519 app_id: Option<&str>,
520) -> Option<String> {
521 let manifest = super::declarative::declarative_source_load_fixture(app_root, "pharos.app.yml")?;
522 let app_id = app_id.or_else(|| manifest.get("app_id").and_then(serde_json::Value::as_str))?;
523 let store = source_app_operations_store(app_root, runtime_conf_path, app_id, None).ok()?;
524 let count = store
525 .adapter
526 .aggregate(
527 &store.config,
528 &store.schema,
529 &pharos_runtime_database::operations::AggregateQuery {
530 entity: "audit_event".to_owned(),
531 filter: None,
532 aggregate: pharos_runtime_database::operations::Aggregate::Count,
533 },
534 )
535 .ok()?
536 .as_u64()?;
537 Some(count.to_string())
538}
539
540pub fn dynamic_relational_runtime_import_state(
564 app_root: &Path,
565 runtime_conf_path: Option<&Path>,
566 app_id: Option<&str>,
567 state_path: &Path,
568 expected_audit_count: Option<&str>,
569) -> i32 {
570 let result = (|| -> Result<(), pharos_runtime_database::operations::DatabaseError> {
571 use pharos_runtime_database::operations::DatabaseError;
572 use std::io::Read;
573 let manifest =
574 super::declarative::declarative_source_load_fixture(app_root, "pharos.app.yml")
575 .ok_or(DatabaseError::InvalidContract)?;
576 let app_id = app_id
577 .or_else(|| manifest.get("app_id").and_then(serde_json::Value::as_str))
578 .ok_or(DatabaseError::InvalidContract)?;
579 let contract = source_selected_logical_contract(app_root, runtime_conf_path, app_id, None)?
580 .ok_or(DatabaseError::InvalidContract)?;
581 let store = logical_store_for_schema(app_root, runtime_conf_path, contract.schema.clone())?;
582 let file = std::fs::File::open(state_path).map_err(|_| DatabaseError::StorageFailure)?;
583 if !file
584 .metadata()
585 .map_err(|_| DatabaseError::StorageFailure)?
586 .is_file()
587 {
588 return Err(DatabaseError::InvalidInput);
589 }
590 let mut bytes = Vec::new();
591 file.take(pharos_runtime_database::snapshot::OPERATOR_LIMITS.max_bytes + 1)
592 .read_to_end(&mut bytes)
593 .map_err(|_| DatabaseError::StorageFailure)?;
594 let snapshot = pharos_runtime_database::snapshot::DatabaseSnapshot::decode(&bytes)?;
595 let counts = expected_audit_count
596 .map(|expected| {
597 expected
598 .parse::<u64>()
599 .map(|expected| pharos_runtime_database::snapshot::RestoreCount {
600 entity: "audit_event".to_owned(),
601 expected,
602 })
603 .map_err(|_| DatabaseError::InvalidInput)
604 })
605 .transpose()?
606 .into_iter()
607 .collect::<Vec<_>>();
608 snapshot.restore(store.adapter.as_ref(), &store.config, &contract, &counts)
609 })();
610 match result {
611 Ok(()) => 0,
612 Err(
613 pharos_runtime_database::operations::DatabaseError::InvalidInput
614 | pharos_runtime_database::operations::DatabaseError::InvalidContract,
615 ) => 64,
616 Err(pharos_runtime_database::operations::DatabaseError::Conflict) => 65,
617 Err(_) => 69,
618 }
619}
620
621pub fn dynamic_migration_drift_report(
647 app_root: &Path,
648 runtime_conf_path: Option<&Path>,
649 app_id: Option<&str>,
650) -> Option<serde_json::Value> {
651 let manifest = super::declarative::declarative_source_load_fixture(app_root, "pharos.app.yml")?;
652 let resolved_app_id =
653 app_id.or_else(|| manifest.get("app_id").and_then(serde_json::Value::as_str))?;
654 match source_selected_logical_contract(app_root, runtime_conf_path, resolved_app_id, None) {
655 Ok(Some(contract)) => {
656 let config =
657 load_cli_backend_config(app_root, runtime_conf_path, Some(resolved_app_id))?;
658 let adapter = operations::resolve(&config).ok()?;
659 let observed = adapter.migration_ledger(&config, resolved_app_id).ok()?;
660 let mut missing_migrations = Vec::new();
661 let mut applied_checksum_drift = Vec::new();
662 for migration in &contract.migrations {
663 match observed.get(&migration.id) {
664 None => {
665 missing_migrations.push(serde_json::Value::String(migration.id.clone()))
666 }
667 Some(checksum) if checksum != &migration.checksum => applied_checksum_drift
668 .push(serde_json::json!({
669 "migration_id": migration.id,
670 "declared": migration.checksum,
671 "observed": checksum,
672 })),
673 Some(_) => {}
674 }
675 }
676 let verified = adapter.verify_contract(&config, &contract).is_ok();
677 Some(serde_json::json!({
678 "schema": "pharos.migration-drift.v1",
679 "status": if verified { "verified" } else { "drifted" },
680 "backend": config.backend,
681 "missing_migrations": missing_migrations,
682 "applied_checksum_drift": applied_checksum_drift,
683 "declared_checksum_drift": [],
684 "interrupted_migrations": [],
685 "missing_live_tables": [],
686 "rollback_classification": [],
687 }))
688 }
689 Ok(None) | Err(_) => None,
690 }
691}
692
693fn manifest_database_app_id(app_root: &Path, hint: Option<&str>) -> Option<String> {
695 let manifest = super::declarative::declarative_source_load_fixture(app_root, "pharos.app.yml")?;
696 let declared = manifest.get("app_id")?.as_str()?.trim();
697 if declared.is_empty() || hint.is_some_and(|hint| hint != declared) {
698 return None;
699 }
700 Some(declared.to_owned())
701}
702
703pub fn dynamic_runtime_prepare(
705 app_root: &Path,
706 runtime_conf_path: Option<&Path>,
707 app_id: Option<&str>,
708) -> i32 {
709 let Some(app_id) = manifest_database_app_id(app_root, app_id) else {
710 return 66;
711 };
712 let Ok(Some(contract)) =
713 source_selected_logical_contract(app_root, runtime_conf_path, &app_id, None)
714 else {
715 return 66;
716 };
717 let Some(config) = load_cli_backend_config(app_root, runtime_conf_path, Some(&app_id)) else {
718 return 66;
719 };
720 let Ok(adapter) = operations::resolve(&config) else {
721 return 69;
722 };
723 match adapter
724 .ensure_database(&config, &contract.schema)
725 .and_then(|()| adapter.apply_contract(&config, &contract))
726 {
727 Ok(()) => 0,
728 Err(_) => 69,
729 }
730}
731
732pub fn dynamic_runtime_ready(
734 app_root: &Path,
735 runtime_conf_path: Option<&Path>,
736 app_id: Option<&str>,
737) -> Option<bool> {
738 let selected_id = manifest_database_app_id(app_root, app_id)?;
739 let config = load_cli_backend_config(app_root, runtime_conf_path, Some(&selected_id))?;
740 let Ok(Some(contract)) =
741 source_selected_logical_contract(app_root, runtime_conf_path, &selected_id, None)
742 else {
743 return Some(false);
744 };
745 let adapter = operations::resolve(&config).ok()?;
746 Some(adapter.ready(&config, &contract.schema).is_ok())
747}
748
749pub fn dynamic_runtime_migrations_verified(
752 app_root: &Path,
753 runtime_conf_path: Option<&Path>,
754 app_id: Option<&str>,
755) -> Option<bool> {
756 let selected_id = manifest_database_app_id(app_root, app_id)?;
757 let config = load_cli_backend_config(app_root, runtime_conf_path, Some(&selected_id))?;
758 match source_selected_logical_contract(app_root, runtime_conf_path, &selected_id, None) {
759 Ok(Some(contract)) => {
760 let adapter = operations::resolve(&config).ok()?;
761 Some(adapter.verify_contract(&config, &contract).is_ok())
762 }
763 Ok(None) => Some(false),
764 Err(_) => Some(false),
765 }
766}
767
768pub(super) fn relational_runtime_authority_enabled(app_root: &Path) -> bool {
772 super::json_commands::manifest_declares_database(app_root)
773}
774
775#[cfg(test)]
776mod rate_limit_tests {
777 #[test]
779 fn scoped_business_and_appointment_ownership() {
780 let directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
781 .join("tmp")
782 .join(format!(
783 "pharos-resource-owner-{}-{}",
784 std::process::id(),
785 std::time::SystemTime::now()
786 .duration_since(std::time::UNIX_EPOCH)
787 .expect("system clock")
788 .as_nanos()
789 ));
790 let app_root = directory.join("app");
791 std::fs::create_dir_all(app_root.join("data/contracts"))
792 .expect("create app contract directory");
793 std::fs::write(
794 app_root.join("pharos.app.yml"),
795 "app_id: owner_test\ndatabase:\n migration_contract: data/contracts/database.json\n",
796 )
797 .expect("write app manifest");
798 let database_path = directory.join("owner.sqlite3");
799 let runtime_conf = directory.join("runtime.conf");
800 std::fs::write(
801 &runtime_conf,
802 format!(
803 "db_backend=sqlite\ndb_path={}\n",
804 database_path.to_string_lossy()
805 ),
806 )
807 .expect("write isolated backend config");
808 use pharos_runtime_database::operations::{
809 AppSchema, Entity, Field, FieldType, Identity, Migration, SchemaChange,
810 };
811 let declarations = [
812 ("business", vec!["id", "slug", "owner_id"]),
813 ("tenant", vec!["id", "source_business_id", "is_active"]),
814 (
815 "tenant_membership",
816 vec![
817 "id",
818 "tenant_id",
819 "user_id",
820 "membership_status",
821 "revoked_at",
822 "role_id",
823 ],
824 ),
825 ("appointment", vec!["id", "user_id", "business_id"]),
826 ];
827 let entities = declarations
828 .into_iter()
829 .map(|(name, fields)| Entity {
830 name: name.to_owned(),
831 identity: Identity::AutoInteger("id".to_owned()),
832 fields: fields
833 .into_iter()
834 .map(|name| Field {
835 name: name.to_owned(),
836 kind: match name {
837 "slug" | "membership_status" | "role_id" => FieldType::Text,
838 "revoked_at" => FieldType::Timestamp,
839 "is_active" => FieldType::Boolean,
840 _ => FieldType::Integer,
841 },
842 nullable: name == "revoked_at",
843 max_length: None,
844 default: None,
845 references: None,
846 on_delete: None,
847 constraints: Vec::new(),
848 })
849 .collect(),
850 unique: Vec::new(),
851 indexes: Vec::new(),
852 conditional_indexes: Vec::new(),
853 })
854 .collect::<Vec<_>>();
855 let schema = AppSchema {
856 app_id: "owner_test".to_owned(),
857 entities,
858 };
859 let mut changes = schema
860 .entities
861 .iter()
862 .cloned()
863 .map(SchemaChange::CreateEntity)
864 .collect::<Vec<_>>();
865 for (name, values) in [
866 (
867 "business",
868 serde_json::json!({"id":1,"slug":"owned","owner_id":10}),
869 ),
870 (
871 "business",
872 serde_json::json!({"id":2,"slug":"other","owner_id":20}),
873 ),
874 (
875 "tenant",
876 serde_json::json!({"id":1,"source_business_id":1,"is_active":true}),
877 ),
878 (
879 "tenant",
880 serde_json::json!({"id":2,"source_business_id":2,"is_active":true}),
881 ),
882 (
883 "tenant_membership",
884 serde_json::json!({"id":1,"tenant_id":1,"user_id":10,"membership_status":"active","revoked_at":null,"role_id":"business_owner"}),
885 ),
886 (
887 "tenant_membership",
888 serde_json::json!({"id":2,"tenant_id":2,"user_id":20,"membership_status":"active","revoked_at":null,"role_id":"business_owner"}),
889 ),
890 (
891 "appointment",
892 serde_json::json!({"id":1,"user_id":11,"business_id":1}),
893 ),
894 (
895 "appointment",
896 serde_json::json!({"id":2,"user_id":21,"business_id":2}),
897 ),
898 ] {
899 changes.push(SchemaChange::Seed {
900 entity: name.to_owned(),
901 values: values.as_object().expect("logical record").clone(),
902 });
903 }
904 let migration = Migration {
905 app_id: "owner_test".to_owned(),
906 id: "ownership_schema".to_owned(),
907 checksum: pharos_runtime_database::contract::migration_checksum(
908 "owner_test",
909 "ownership_schema",
910 &changes,
911 )
912 .expect("ownership checksum"),
913 changes,
914 };
915 let contract = pharos_runtime_database::contract::AppDataContract {
916 schema,
917 migrations: vec![migration],
918 legacy_adoption: None,
919 };
920 std::fs::write(
921 app_root.join("data/contracts/database.json"),
922 serde_json::to_vec(&contract).expect("logical contract JSON"),
923 )
924 .expect("logical ownership contract");
925 let config =
926 super::load_cli_backend_config(&app_root, Some(&runtime_conf), Some("owner_test"))
927 .expect("configured ownership database");
928 let adapter = super::operations::resolve(&config).expect("configured ownership adapter");
929 adapter
930 .apply_contract(&config, &contract)
931 .expect("logical ownership schema and records");
932 assert_eq!(
933 super::dynamic_relational_actor_manages_business(
934 &app_root,
935 Some(&runtime_conf),
936 Some("owner_test"),
937 10,
938 "owned"
939 ),
940 Ok(true)
941 );
942 assert_eq!(
943 super::dynamic_relational_actor_manages_business(
944 &app_root,
945 Some(&runtime_conf),
946 Some("owner_test"),
947 10,
948 "other"
949 ),
950 Ok(false)
951 );
952 assert_eq!(
953 super::dynamic_relational_actor_owns_appointment(
954 &app_root,
955 Some(&runtime_conf),
956 Some("owner_test"),
957 10,
958 1
959 ),
960 Ok(true)
961 );
962 assert_eq!(
963 super::dynamic_relational_actor_owns_appointment(
964 &app_root,
965 Some(&runtime_conf),
966 Some("owner_test"),
967 11,
968 1
969 ),
970 Ok(true)
971 );
972 assert_eq!(
973 super::dynamic_relational_actor_owns_appointment(
974 &app_root,
975 Some(&runtime_conf),
976 Some("owner_test"),
977 10,
978 2
979 ),
980 Ok(false)
981 );
982 std::fs::remove_dir_all(directory).expect("remove resource database directory");
983 }
984}