use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::generation::manifest::ManifestColumn;
use crate::runtime::catalog::{CatalogManager, CatalogState};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MessageDescriptor {
pub message_type: String,
pub project_id: String,
pub catalog_version: String,
pub manifest_checksum: String,
pub schema: String,
pub table: String,
pub primary_key: Vec<String>,
pub fields: Vec<FieldDescriptor>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FieldDescriptor {
pub name: String,
pub column_name: String,
pub proto_type: String,
pub sql_type: String,
pub not_null: bool,
pub is_primary: bool,
pub is_array: bool,
}
impl From<&ManifestColumn> for FieldDescriptor {
fn from(col: &ManifestColumn) -> Self {
Self {
name: col.field_name.clone(),
column_name: col.column_name.clone(),
proto_type: col.proto_type.clone(),
sql_type: col.sql_type.clone(),
not_null: col.not_null,
is_primary: col.is_primary,
is_array: col.is_array,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum NegotiationOutcome {
Match {
active_version: String,
manifest_checksum: String,
},
BackwardCompatible {
active_version: String,
client_version: String,
manifest_checksum: String,
},
Incompatible {
active_version: String,
client_version: String,
reason: String,
},
}
impl NegotiationOutcome {
pub fn may_proceed(&self) -> bool {
!matches!(self, Self::Incompatible { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LookupError {
MessageNotFound {
project_id: String,
message_type: String,
},
Incompatible {
project_id: String,
message_type: String,
client_version: String,
active_version: String,
reason: String,
},
}
#[derive(Debug, Clone)]
pub struct SchemaRegistry {
catalog: Arc<CatalogManager>,
}
impl SchemaRegistry {
pub fn new(catalog: Arc<CatalogManager>) -> Self {
Self { catalog }
}
pub fn negotiate_version(&self, project_id: &str, client_version: &str) -> NegotiationOutcome {
let active: Arc<CatalogState> = self.catalog.active_for(project_id);
let active_version = active.metadata.version.clone();
let manifest_checksum = active.metadata.checksum.clone();
if client_version.trim().is_empty() {
return NegotiationOutcome::Match {
active_version,
manifest_checksum,
};
}
match self.catalog.compatibility_error(client_version, project_id) {
None if active_version == client_version => NegotiationOutcome::Match {
active_version,
manifest_checksum,
},
None => NegotiationOutcome::BackwardCompatible {
active_version,
client_version: client_version.to_string(),
manifest_checksum,
},
Some(reason) => NegotiationOutcome::Incompatible {
active_version,
client_version: client_version.to_string(),
reason,
},
}
}
pub fn lookup_message(
&self,
project_id: &str,
message_type: &str,
client_version: &str,
) -> Result<MessageDescriptor, LookupError> {
let outcome = self.negotiate_version(project_id, client_version);
if let NegotiationOutcome::Incompatible {
active_version,
client_version,
reason,
} = &outcome
{
return Err(LookupError::Incompatible {
project_id: project_id.to_string(),
message_type: message_type.to_string(),
client_version: client_version.clone(),
active_version: active_version.clone(),
reason: reason.clone(),
});
}
let active = self.catalog.active_for(project_id);
let table =
crate::broker::table_for_message(&active.manifest, message_type).ok_or_else(|| {
LookupError::MessageNotFound {
project_id: project_id.to_string(),
message_type: message_type.to_string(),
}
})?;
Ok(MessageDescriptor {
message_type: table.message_name.clone(),
project_id: project_id.to_string(),
catalog_version: active.metadata.version.clone(),
manifest_checksum: active.metadata.checksum.clone(),
schema: table.schema.clone(),
table: table.table.clone(),
primary_key: table.primary_key.clone(),
fields: table.columns.iter().map(FieldDescriptor::from).collect(),
})
}
pub fn list_messages(&self, project_id: &str) -> Vec<String> {
let active = self.catalog.active_for(project_id);
active
.manifest
.tables
.iter()
.map(|t| t.message_name.clone())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generation::manifest::{CatalogManifest, ManifestColumn, ManifestTable};
fn manifest_with_customer(checksum: &str) -> CatalogManifest {
CatalogManifest {
checksum_sha256: checksum.to_string(),
tables: vec![ManifestTable {
checksum_sha256: checksum.to_string(),
message_name: "acme.billing.v1.Customer".to_string(),
schema: "public".to_string(),
table: "customers".to_string(),
primary_key: vec!["id".to_string()],
columns: vec![
ManifestColumn {
field_name: "id".into(),
column_name: "id".into(),
proto_type: "string".into(),
sql_type: "uuid".into(),
is_primary: true,
not_null: true,
..Default::default()
},
ManifestColumn {
field_name: "email".into(),
column_name: "email".into(),
proto_type: "string".into(),
sql_type: "text".into(),
..Default::default()
},
],
..Default::default()
}],
..Default::default()
}
}
async fn registry_with_project(version: &str, compat: &str) -> SchemaRegistry {
let catalog = Arc::new(CatalogManager::new(manifest_with_customer("default")));
catalog
.stage_catalog(
manifest_with_customer("billing-v1"),
"billing".into(),
version.into(),
compat.into(),
)
.await
.unwrap();
catalog
.activate_catalog_for("billing", version)
.await
.unwrap();
SchemaRegistry::new(catalog)
}
#[tokio::test]
async fn empty_client_version_matches_active() {
let registry = registry_with_project("2.0.0", "backward").await;
let outcome = registry.negotiate_version("billing", "");
match outcome {
NegotiationOutcome::Match { active_version, .. } => {
assert_eq!(active_version, "2.0.0");
}
other => panic!("expected Match, got {other:?}"),
}
}
#[tokio::test]
async fn exact_version_match() {
let registry = registry_with_project("2.0.0", "backward").await;
let outcome = registry.negotiate_version("billing", "2.0.0");
assert!(matches!(outcome, NegotiationOutcome::Match { .. }));
}
#[tokio::test]
async fn older_client_passes_under_backward_compat() {
let registry = registry_with_project("2.5.0", "backward").await;
let outcome = registry.negotiate_version("billing", "2.1.0");
assert!(
matches!(outcome, NegotiationOutcome::BackwardCompatible { .. }),
"got {outcome:?}"
);
assert!(outcome.may_proceed());
}
#[tokio::test]
async fn breaking_change_returns_incompatible_with_reason() {
let registry = registry_with_project("2.5.0", "backward").await;
let outcome = registry.negotiate_version("billing", "3.0.0");
match outcome {
NegotiationOutcome::Incompatible {
client_version,
reason,
..
} => {
assert_eq!(client_version, "3.0.0");
assert!(reason.contains("backward-compatible") || reason.contains("compat"));
}
other => panic!("expected Incompatible, got {other:?}"),
}
}
#[tokio::test]
async fn exact_mode_rejects_any_drift() {
let registry = registry_with_project("2.5.0", "exact").await;
let outcome = registry.negotiate_version("billing", "2.5.1");
assert!(matches!(outcome, NegotiationOutcome::Incompatible { .. }));
}
#[tokio::test]
async fn lookup_returns_descriptor_with_fields() {
let registry = registry_with_project("1.0.0", "backward").await;
let descriptor = registry
.lookup_message("billing", "acme.billing.v1.Customer", "1.0.0")
.expect("descriptor");
assert_eq!(descriptor.message_type, "acme.billing.v1.Customer");
assert_eq!(descriptor.project_id, "billing");
assert_eq!(descriptor.schema, "public");
assert_eq!(descriptor.table, "customers");
assert_eq!(descriptor.primary_key, vec!["id"]);
assert_eq!(descriptor.fields.len(), 2);
let pk = descriptor
.fields
.iter()
.find(|f| f.is_primary)
.expect("pk field");
assert_eq!(pk.name, "id");
}
#[tokio::test]
async fn lookup_unknown_message_returns_message_not_found() {
let registry = registry_with_project("1.0.0", "backward").await;
let err = registry
.lookup_message("billing", "acme.billing.v1.UnknownThing", "1.0.0")
.unwrap_err();
assert!(matches!(err, LookupError::MessageNotFound { .. }));
}
#[tokio::test]
async fn lookup_with_incompatible_client_returns_typed_error() {
let registry = registry_with_project("2.0.0", "exact").await;
let err = registry
.lookup_message("billing", "acme.billing.v1.Customer", "2.5.0")
.unwrap_err();
assert!(matches!(err, LookupError::Incompatible { .. }));
}
#[tokio::test]
async fn list_messages_enumerates_active_catalog() {
let registry = registry_with_project("1.0.0", "backward").await;
let messages = registry.list_messages("billing");
assert_eq!(messages, vec!["acme.billing.v1.Customer".to_string()]);
}
#[tokio::test]
async fn unknown_project_falls_back_to_default() {
let registry = registry_with_project("1.0.0", "backward").await;
let descriptor = registry
.lookup_message("nonexistent-project", "acme.billing.v1.Customer", "")
.expect("falls back to default");
assert_eq!(descriptor.project_id, "nonexistent-project");
}
}