#![cfg(feature = "federation")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct InstanceId(pub String);
impl InstanceId {
#[must_use]
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
#[must_use]
pub fn local() -> Self {
Self("local".to_string())
}
}
impl Default for InstanceId {
fn default() -> Self {
Self::local()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum InstanceRole {
Unifying,
Archive,
DomainAuthority,
Edge,
Airgapped,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct AuthoritativeScope {
pub namespace: String,
pub project: String,
}
impl AuthoritativeScope {
#[must_use]
pub fn new(namespace: impl Into<String>, project: impl Into<String>) -> Self {
Self {
namespace: namespace.into(),
project: project.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LinkDirection {
Replica,
Sync,
Subscribe,
Airgap,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ConflictResolution {
SourceAuthority,
LastWriteWins,
CrdtMerge,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Freshness {
Realtime,
Batched,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LinkPolicy {
pub direction: LinkDirection,
pub conflict: ConflictResolution,
pub freshness: Freshness,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OutboundLink {
pub target: InstanceId,
pub policy: LinkPolicy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum WriteOperation {
Create,
Update,
Delete,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FederationState {
pub instance_id: InstanceId,
pub role: InstanceRole,
pub authoritative_scopes: Vec<AuthoritativeScope>,
pub outbound_links: Vec<OutboundLink>,
pub capability_policy_ref: Option<String>,
pub etag: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoutedWrite {
pub target: InstanceId,
pub scope: AuthoritativeScope,
pub operation: WriteOperation,
pub payload: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoutedWriteReceipt {
pub accepted: bool,
pub instance: InstanceId,
pub scope: AuthoritativeScope,
pub commit: Option<String>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConflictSignal {
pub scope: AuthoritativeScope,
pub instances: Vec<InstanceId>,
pub resolution: ConflictResolution,
pub detail: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstanceFederationConfig {
pub instance_id: InstanceId,
pub role: InstanceRole,
pub authoritative_scopes: Vec<AuthoritativeScope>,
pub outbound_links: Vec<OutboundLink>,
pub capability_policy_ref: Option<String>,
}
impl InstanceFederationConfig {
#[must_use]
pub fn to_state(&self) -> FederationState {
FederationState {
instance_id: self.instance_id.clone(),
role: self.role,
authoritative_scopes: self.authoritative_scopes.clone(),
outbound_links: self.outbound_links.clone(),
capability_policy_ref: self.capability_policy_ref.clone(),
etag: None,
}
}
#[must_use]
pub fn accepts_scope(&self, scope: &AuthoritativeScope) -> bool {
self.authoritative_scopes.iter().any(|s| {
(s.namespace == scope.namespace || s.namespace == "*")
&& (s.project == scope.project || s.project == "*")
})
}
}
impl Default for InstanceFederationConfig {
fn default() -> Self {
Self {
instance_id: InstanceId::local(),
role: InstanceRole::Unifying,
authoritative_scopes: vec![AuthoritativeScope::new("local", "*")],
outbound_links: Vec::new(),
capability_policy_ref: None,
}
}
}
impl std::str::FromStr for InstanceRole {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"unifying" => Ok(Self::Unifying),
"archive" => Ok(Self::Archive),
"domain-authority" | "domain_authority" => Ok(Self::DomainAuthority),
"edge" => Ok(Self::Edge),
"airgapped" => Ok(Self::Airgapped),
other => Err(format!(
"unknown instance role '{other}' (expected unifying | archive | domain-authority | edge | airgapped)"
)),
}
}
}
impl std::str::FromStr for AuthoritativeScope {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (ns, proj) = s
.split_once(':')
.ok_or_else(|| format!("authoritative scope '{s}' must be 'namespace:project'"))?;
Ok(Self::new(ns, proj))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn federation_state_serializes_to_the_wire_contract() {
let state = FederationState {
instance_id: InstanceId::new("ijima-1"),
role: InstanceRole::Unifying,
authoritative_scopes: vec![AuthoritativeScope::new("shared", "Dominic")],
outbound_links: vec![OutboundLink {
target: InstanceId::new("ijima-2"),
policy: LinkPolicy {
direction: LinkDirection::Replica,
conflict: ConflictResolution::SourceAuthority,
freshness: Freshness::Realtime,
},
}],
capability_policy_ref: Some("sha256:abc".into()),
etag: Some("w1".into()),
};
let json = serde_json::to_string(&state).expect("serialize");
assert!(
json.contains(r#""instance_id":"ijima-1""#),
"InstanceId must serialize as a plain string: {json}"
);
assert!(
json.contains(r#""authoritative_scopes":[{"namespace":"shared","project":"Dominic"}]"#),
"AuthoritativeScope must be a struct: {json}"
);
assert!(json.contains(r#""role":"Unifying""#), "role: {json}");
assert!(
json.contains(r#""direction":"Replica""#),
"direction: {json}"
);
assert!(
json.contains(r#""conflict":"SourceAuthority""#),
"conflict: {json}"
);
let back: FederationState = serde_json::from_str(&json).expect("deserialize");
assert_eq!(state, back);
}
#[test]
fn config_default_is_local_unifying() {
let cfg = InstanceFederationConfig::default();
assert_eq!(cfg.instance_id, InstanceId::local());
assert_eq!(cfg.role, InstanceRole::Unifying);
assert!(cfg.outbound_links.is_empty());
let state = cfg.to_state();
assert_eq!(state.instance_id, InstanceId::local());
assert_eq!(state.role, InstanceRole::Unifying);
assert!(state.etag.is_none());
}
#[test]
fn accepts_scope_matches_namespace_and_wildcard_project() {
let cfg = InstanceFederationConfig::default(); assert!(cfg.accepts_scope(&AuthoritativeScope::new("local", "Dominic")));
assert!(cfg.accepts_scope(&AuthoritativeScope::new("local", "anything")));
assert!(!cfg.accepts_scope(&AuthoritativeScope::new("shared", "Dominic")));
let open = InstanceFederationConfig {
authoritative_scopes: vec![AuthoritativeScope::new("*", "*")],
..InstanceFederationConfig::default()
};
assert!(open.accepts_scope(&AuthoritativeScope::new("shared", "X")));
}
#[test]
fn instance_role_from_str_is_lowercase_tolerant() {
use std::str::FromStr;
assert_eq!(
InstanceRole::from_str("unifying").unwrap(),
InstanceRole::Unifying
);
assert_eq!(
InstanceRole::from_str("Airgapped").unwrap(),
InstanceRole::Airgapped
);
assert_eq!(
InstanceRole::from_str("domain-authority").unwrap(),
InstanceRole::DomainAuthority
);
assert!(InstanceRole::from_str("bogus").is_err());
}
#[test]
fn authoritative_scope_from_str_parses_namespace_project() {
use std::str::FromStr;
let s = AuthoritativeScope::from_str("shared:Dominic").unwrap();
assert_eq!(s.namespace, "shared");
assert_eq!(s.project, "Dominic");
assert!(AuthoritativeScope::from_str("nocolon").is_err());
}
#[test]
fn routed_write_and_receipt_round_trip() {
let write = RoutedWrite {
target: InstanceId::new("ijima-1"),
scope: AuthoritativeScope::new("shared", "Dominic"),
operation: WriteOperation::Create,
payload: serde_json::json!({"content": "hello"}),
};
let json = serde_json::to_string(&write).expect("serialize");
let back: RoutedWrite = serde_json::from_str(&json).expect("deserialize");
assert_eq!(write.target, back.target);
assert_eq!(write.scope, back.scope);
assert_eq!(write.operation, back.operation);
let receipt = RoutedWriteReceipt {
accepted: true,
instance: InstanceId::new("ijima-1"),
scope: AuthoritativeScope::new("shared", "Dominic"),
commit: Some("mem_1".into()),
warnings: vec![],
};
let rj = serde_json::to_string(&receipt).expect("serialize");
let _: RoutedWriteReceipt = serde_json::from_str(&rj).expect("deserialize");
}
}