use std::sync::OnceLock;
#[doc(hidden)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SealToken {
_private: (),
}
impl SealToken {
pub(crate) const fn __new() -> Self {
Self { _private: () }
}
}
pub trait App {
#[doc(hidden)]
const __DJOGI_APP_SEAL: SealToken;
const LABEL: &'static str;
const DATABASE: &'static str;
const TOMBSTONE: bool = false;
const DESCRIPTOR: AppDescriptor;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AppDescriptor {
pub label: &'static str,
pub database: &'static str,
pub renamed_from: Option<&'static str>,
pub tombstone: bool,
}
impl AppDescriptor {
pub const GLOBAL_LABEL: &'static str = "";
pub const GLOBAL_DATABASE: &'static str = "main";
pub const GLOBAL: AppDescriptor = AppDescriptor {
label: Self::GLOBAL_LABEL,
database: Self::GLOBAL_DATABASE,
renamed_from: None,
tombstone: false,
};
}
inventory::collect!(AppDescriptor);
fn validate_app_identity_uniqueness(sorted: &[AppDescriptor]) {
for pair in sorted.windows(2) {
if pair[0].label.is_empty() || pair[0].label != pair[1].label {
continue;
}
if pair[0].database == pair[1].database {
panic!(
"djogi::apps: duplicate app identity \
(database = {:?}, label = {:?}) declared across \
multiple `djogi::apps!` invocations — \
(database, label) pairs must be unique per crate \
(and across linked djogi-using crates)",
pair[0].database, pair[0].label,
);
}
panic!(
"djogi::apps: app label {:?} is declared in two \
distinct database targets ({:?} and {:?}) — v1 \
requires workspace-wide label uniqueness across \
all databases. ModelDescriptor carries only the \
label, so cross-app FK resolution cannot \
disambiguate same-label / different-database \
pairs today. Rename one of the apps, or wait for \
the (label, database) descriptor-shape upgrade \
deferred in docs/spec/apps-and-database-domains.md.",
pair[0].label, pair[0].database, pair[1].database,
);
}
}
pub struct AppRegistry;
impl AppRegistry {
pub fn all() -> &'static [AppDescriptor] {
static CACHE: OnceLock<Vec<AppDescriptor>> = OnceLock::new();
CACHE.get_or_init(|| {
let mut out: Vec<AppDescriptor> = Vec::new();
out.push(AppDescriptor::GLOBAL);
for desc in inventory::iter::<AppDescriptor> {
out.push(*desc);
}
out.sort_by(|a, b| (a.label, a.database).cmp(&(b.label, b.database)));
validate_app_identity_uniqueness(&out);
out
})
}
pub fn cross_app_edges() -> &'static [CrossAppEdge] {
static CACHE: OnceLock<Vec<CrossAppEdge>> = OnceLock::new();
CACHE.get_or_init(|| {
use crate::descriptor::ModelDescriptor;
use crate::descriptor::RelationKind;
use std::collections::HashMap;
let label_to_database: HashMap<&'static str, &'static str> = AppRegistry::all()
.iter()
.map(|d| (d.label, d.database))
.collect();
let database_for_label = |label: &'static str| -> &'static str {
label_to_database
.get(label)
.copied()
.unwrap_or(AppDescriptor::GLOBAL_DATABASE)
};
let mut type_to_identity: HashMap<&'static str, (&'static str, &'static str)> =
HashMap::new();
for m in inventory::iter::<ModelDescriptor> {
let label = m.app.unwrap_or(AppDescriptor::GLOBAL_LABEL);
let database = database_for_label(label);
if let Some(prior) = type_to_identity.insert(m.type_name, (label, database)) {
panic!(
"djogi::apps: model type name `{}` is registered by two distinct \
apps — first `{}`/`{}`, now `{}`/`{}`. Type-name collisions break \
cross-app FK edge resolution; rename one of the models, or wait for \
(module_path, type_name) keying to land in the descriptor shape.",
m.type_name, prior.0, prior.1, label, database,
);
}
}
let mut edges: Vec<CrossAppEdge> = Vec::new();
for source in inventory::iter::<ModelDescriptor> {
let (source_app, source_database) = type_to_identity
.get(source.type_name)
.copied()
.expect("source model registered above");
for field in source.fields {
if !matches!(
field.relation_kind,
Some(RelationKind::ForeignKey) | Some(RelationKind::OneToOne)
) {
continue;
}
let Some(target_type) = field.target_type_name else {
continue;
};
let Some(&(target_app, target_database)) = type_to_identity.get(target_type)
else {
continue;
};
if source_app == target_app && source_database == target_database {
continue;
}
edges.push(CrossAppEdge {
source_database,
source_app,
source_type: source.type_name,
source_field: field.name,
target_database,
target_app,
target_type,
});
}
}
edges.sort_by(|a, b| {
(
a.source_database,
a.source_app,
a.source_type,
a.source_field,
)
.cmp(&(
b.source_database,
b.source_app,
b.source_type,
b.source_field,
))
});
edges
})
}
pub fn cross_app_cycles() -> &'static [Vec<AppIdentity>] {
static CACHE: OnceLock<Vec<Vec<AppIdentity>>> = OnceLock::new();
CACHE.get_or_init(|| {
use std::collections::{HashMap, HashSet};
let mut adj: HashMap<AppIdentity, Vec<AppIdentity>> = HashMap::new();
for edge in Self::cross_app_edges() {
let from = AppIdentity {
database: edge.source_database,
label: edge.source_app,
};
let to = AppIdentity {
database: edge.target_database,
label: edge.target_app,
};
let entry = adj.entry(from).or_default();
if !entry.contains(&to) {
entry.push(to);
}
}
for neighbours in adj.values_mut() {
neighbours.sort();
}
let mut cycles: Vec<Vec<AppIdentity>> = Vec::new();
let mut onstack: HashSet<AppIdentity> = HashSet::new();
let mut done: HashSet<AppIdentity> = HashSet::new();
let mut stack: Vec<AppIdentity> = Vec::new();
let mut roots: Vec<AppIdentity> = adj.keys().copied().collect();
roots.sort();
fn dfs(
node: AppIdentity,
adj: &HashMap<AppIdentity, Vec<AppIdentity>>,
onstack: &mut HashSet<AppIdentity>,
done: &mut HashSet<AppIdentity>,
stack: &mut Vec<AppIdentity>,
cycles: &mut Vec<Vec<AppIdentity>>,
) {
if done.contains(&node) {
return;
}
onstack.insert(node);
stack.push(node);
if let Some(neighbours) = adj.get(&node) {
for &nbr in neighbours {
if onstack.contains(&nbr) {
if let Some(start) = stack.iter().position(|n| *n == nbr) {
let mut cycle: Vec<AppIdentity> = stack[start..].to_vec();
cycle.push(nbr);
cycles.push(cycle);
}
} else if !done.contains(&nbr) {
dfs(nbr, adj, onstack, done, stack, cycles);
}
}
}
stack.pop();
onstack.remove(&node);
done.insert(node);
}
for root in &roots {
dfs(
*root,
&adj,
&mut onstack,
&mut done,
&mut stack,
&mut cycles,
);
}
cycles
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct AppIdentity {
pub database: &'static str,
pub label: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CrossAppEdge {
pub source_database: &'static str,
pub source_app: &'static str,
pub source_type: &'static str,
pub source_field: &'static str,
pub target_database: &'static str,
pub target_app: &'static str,
pub target_type: &'static str,
}
#[doc(hidden)]
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppDiagnostic {
FolderDrift {
database: String,
label: String,
},
UnknownLedgerApp {
database: String,
label: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_contains_synthetic_global_bucket() {
let all = AppRegistry::all();
assert!(
all.iter()
.any(|d| d.label.is_empty() && d.database == "main" && !d.tombstone),
"global bucket must be present"
);
}
#[test]
fn app_descriptor_global_const_matches_synthetic_bucket() {
assert!(AppDescriptor::GLOBAL.label.is_empty());
assert_eq!(AppDescriptor::GLOBAL.database, "main");
assert_eq!(AppDescriptor::GLOBAL.renamed_from, None);
const _: () = assert!(!AppDescriptor::GLOBAL.tombstone);
}
#[test]
fn registry_all_is_alphabetised_by_label() {
let all = AppRegistry::all();
let labels: Vec<&str> = all.iter().map(|d| d.label).collect();
let mut sorted = labels.clone();
sorted.sort();
assert_eq!(labels, sorted);
}
#[test]
fn cross_app_edges_smoke() {
let edges = AppRegistry::cross_app_edges();
assert!(edges.is_empty(), "djogi core has no cross-app FKs");
}
#[test]
fn cross_app_cycles_smoke() {
let cycles = AppRegistry::cross_app_cycles();
assert!(cycles.is_empty(), "djogi core has no cross-app cycles");
}
#[test]
fn app_diagnostic_variants_constructible() {
let folder_drift = AppDiagnostic::FolderDrift {
database: "main".to_string(),
label: "oldbilling".to_string(),
};
let unknown_ledger = AppDiagnostic::UnknownLedgerApp {
database: "main".to_string(),
label: "mystery_app".to_string(),
};
assert_ne!(folder_drift, unknown_ledger);
}
#[test]
fn cross_app_edge_equality_and_ordering() {
let a = CrossAppEdge {
source_database: "main",
source_app: "billing",
source_type: "Invoice",
source_field: "customer_id",
target_database: "main",
target_app: "users",
target_type: "User",
};
let b = CrossAppEdge {
source_database: "main",
source_app: "billing",
source_type: "Invoice",
source_field: "customer_id",
target_database: "main",
target_app: "users",
target_type: "User",
};
assert_eq!(a, b);
}
#[test]
fn app_identity_sorts_by_database_then_label() {
let main_users = AppIdentity {
database: "main",
label: "users",
};
let main_billing = AppIdentity {
database: "main",
label: "billing",
};
let crud_audit = AppIdentity {
database: "crud_log",
label: "audit",
};
let mut ids = vec![main_users, main_billing, crud_audit];
ids.sort();
assert_eq!(ids, vec![crud_audit, main_billing, main_users]);
}
fn descriptor_for(label: &'static str, database: &'static str) -> AppDescriptor {
AppDescriptor {
label,
database,
renamed_from: None,
tombstone: false,
}
}
#[test]
fn validate_uniqueness_accepts_distinct_labels() {
let mut descs = vec![
AppDescriptor::GLOBAL,
descriptor_for("billing", "main"),
descriptor_for("users", "main"),
];
descs.sort_by(|a, b| (a.label, a.database).cmp(&(b.label, b.database)));
validate_app_identity_uniqueness(&descs);
}
#[test]
#[should_panic(expected = "duplicate app identity")]
fn validate_uniqueness_panics_on_duplicate_pair() {
let mut descs = vec![
AppDescriptor::GLOBAL,
descriptor_for("audit", "main"),
descriptor_for("audit", "main"),
];
descs.sort_by(|a, b| (a.label, a.database).cmp(&(b.label, b.database)));
validate_app_identity_uniqueness(&descs);
}
#[test]
#[should_panic(expected = "workspace-wide label uniqueness")]
fn validate_uniqueness_panics_on_same_label_different_database() {
let mut descs = vec![
AppDescriptor::GLOBAL,
descriptor_for("audit", "crud_log"),
descriptor_for("audit", "main"),
];
descs.sort_by(|a, b| (a.label, a.database).cmp(&(b.label, b.database)));
validate_app_identity_uniqueness(&descs);
}
}