use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
pub(crate) const POSTGRES_DURABILITY_POLL_MS: u64 = 25;
pub(crate) const MYSQL_DURABILITY_POLL_MS: u64 = 50;
pub(crate) const SQLITE_DURABILITY_POLL_MS: u64 = 5;
#[cfg(feature = "mongodb-native")]
pub(crate) const MONGODB_DURABILITY_POLL_MS: u64 = 25;
#[cfg(feature = "cassandra")]
pub(crate) const CASSANDRA_DURABILITY_POLL_MS: u64 = 25;
#[cfg(feature = "neo4j")]
pub(crate) const NEO4J_DURABILITY_POLL_MS: u64 = 25;
#[cfg(feature = "clickhouse")]
pub(crate) const CLICKHOUSE_DURABILITY_POLL_MS: u64 = 25;
pub(crate) fn durability_poll_interval(timeout: Duration, default_ms: u64) -> Duration {
let adaptive_ms = (timeout.as_millis() / 100).clamp(1, u128::from(default_ms)) as u64;
Duration::from_millis(adaptive_ms)
}
#[cfg(feature = "cassandra")]
pub mod cassandra;
#[cfg(feature = "cassandra")]
mod cassandra_admin_audit;
#[cfg(feature = "cassandra")]
mod cassandra_migration_audit;
#[cfg(feature = "cassandra")]
mod cassandra_projection;
#[cfg(feature = "cassandra")]
mod cassandra_saga;
#[cfg(feature = "mongodb-native")]
pub mod mongodb;
#[cfg(feature = "mssql")]
pub mod mssql;
#[cfg(feature = "neo4j")]
pub mod neo4j;
#[cfg(feature = "neo4j")]
mod neo4j_admin_audit;
#[cfg(feature = "neo4j")]
mod neo4j_migration_audit;
#[cfg(feature = "neo4j")]
mod neo4j_projection;
#[cfg(feature = "neo4j")]
mod neo4j_saga;
#[cfg(feature = "clickhouse")]
pub mod clickhouse;
#[cfg(feature = "clickhouse")]
mod clickhouse_admin_audit;
#[cfg(feature = "clickhouse")]
mod clickhouse_migration_audit;
#[cfg(feature = "clickhouse")]
mod clickhouse_projection;
#[cfg(feature = "clickhouse")]
mod clickhouse_saga;
#[cfg(feature = "mysql")]
pub mod mysql;
#[cfg(feature = "postgres")]
pub mod postgres;
#[cfg(feature = "qdrant")]
pub mod qdrant;
#[cfg(feature = "redis")]
pub mod redis;
#[cfg(feature = "sqlite")]
pub mod sqlite;
pub mod system_store;
#[cfg(any(
feature = "qdrant",
feature = "pinecone",
feature = "weaviate",
feature = "elasticsearch"
))]
pub mod vector_plane;
#[cfg(any(feature = "pinecone", feature = "weaviate", feature = "elasticsearch"))]
pub mod vector_system;
#[cfg(any(feature = "postgres", feature = "mysql", feature = "sqlite"))]
mod dialect;
#[cfg(any(
feature = "postgres",
feature = "mysql",
feature = "sqlite",
feature = "mssql"
))]
mod sql_schema;
#[cfg(feature = "sqlite")]
mod sqlite_projection;
#[cfg(feature = "postgres")]
mod postgres_projection;
#[cfg(feature = "mysql")]
mod mysql_projection;
#[cfg(feature = "mssql")]
mod mssql_projection;
#[cfg(feature = "mongodb-native")]
mod mongodb_admin_audit;
#[cfg(feature = "mongodb-native")]
mod mongodb_migration_audit;
#[cfg(feature = "mongodb-native")]
mod mongodb_projection;
#[cfg(feature = "mongodb-native")]
mod mongodb_saga;
#[cfg(feature = "sqlite")]
mod sqlite_saga;
#[cfg(feature = "postgres")]
mod postgres_saga;
#[cfg(feature = "mysql")]
mod mysql_saga;
#[cfg(feature = "mssql")]
mod mssql_saga;
#[cfg(feature = "sqlite")]
mod sqlite_admin_audit;
#[cfg(feature = "postgres")]
mod postgres_admin_audit;
#[cfg(feature = "mysql")]
mod mysql_admin_audit;
#[cfg(feature = "mssql")]
mod mssql_admin_audit;
#[cfg(feature = "sqlite")]
mod sqlite_migration_audit;
#[cfg(feature = "postgres")]
mod postgres_migration_audit;
#[cfg(feature = "mysql")]
mod mysql_migration_audit;
#[cfg(feature = "mssql")]
mod mssql_migration_audit;
#[cfg(all(test, feature = "sqlite"))]
mod conformance;
#[cfg(all(test, feature = "sqlite"))]
mod conformance_live_tests;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DurabilityToken {
pub backend_label: String,
pub value: String,
}
impl DurabilityToken {
pub fn new(backend_label: impl Into<String>, value: impl Into<String>) -> Self {
Self {
backend_label: backend_label.into(),
value: value.into(),
}
}
pub fn is_for(&self, backend_label: &str) -> bool {
self.backend_label.eq_ignore_ascii_case(backend_label)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OutboxEvent {
pub event_seq: i64,
pub event_id: String,
pub topic: String,
pub partition_key: String,
pub payload: serde_json::Value,
pub created_at_unix_ms: i64,
}
#[async_trait]
pub trait CanonicalStore: Send + Sync {
fn backend_label(&self) -> &'static str;
fn instance_name(&self) -> &str;
async fn current_durability_token(&self) -> Result<DurabilityToken, String>;
async fn wait_for_token(
&self,
token: &DurabilityToken,
timeout: Duration,
) -> Result<bool, String>;
async fn enqueue_outbox_event(
&self,
event_id: &str,
topic: &str,
partition_key: &str,
payload: &serde_json::Value,
) -> Result<i64, String>;
async fn outbox_max_seq(&self) -> Result<i64, String>;
async fn ensure_system_tables(&self) -> Result<(), String>;
async fn ensure_advisory_lease_table(&self) -> Result<(), String>;
async fn try_acquire_advisory_lease(
&self,
lease_name: &str,
owner_id: &str,
ttl: std::time::Duration,
) -> Result<bool, String>;
async fn release_advisory_lease(&self, lease_name: &str, owner_id: &str) -> Result<(), String>;
}
use system_store::{AdminAuditStore, MigrationAuditStore, ProjectionTaskStore, SagaStore};
pub trait SystemStores:
CanonicalStore
+ ProjectionTaskStore
+ SagaStore
+ AdminAuditStore
+ MigrationAuditStore
+ Send
+ Sync
{
}
impl<T> SystemStores for T where
T: CanonicalStore
+ ProjectionTaskStore
+ SagaStore
+ AdminAuditStore
+ MigrationAuditStore
+ Send
+ Sync
+ ?Sized
{
}
#[derive(Default, Clone)]
pub struct CanonicalStoreRegistry {
by_key: HashMap<(String, String), Arc<dyn CanonicalStore>>,
system_stores: HashMap<(String, String), Arc<dyn SystemStores>>,
default_key: Option<(String, String)>,
}
impl std::fmt::Debug for CanonicalStoreRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CanonicalStoreRegistry")
.field("registered_keys", &self.registered_keys())
.field("has_default", &self.default_key.is_some())
.finish()
}
}
impl CanonicalStoreRegistry {
pub fn new() -> Self {
Self {
by_key: HashMap::new(),
system_stores: HashMap::new(),
default_key: None,
}
}
pub fn register_full(&mut self, store: Arc<dyn SystemStores>) {
let key = (
CanonicalStore::backend_label(store.as_ref()).to_ascii_lowercase(),
CanonicalStore::instance_name(store.as_ref()).to_string(),
);
if self.default_key.is_none() {
self.default_key = Some(key.clone());
}
self.by_key.insert(key.clone(), store.clone());
self.system_stores.insert(key, store);
}
pub fn get_full(
&self,
backend_label: &str,
instance_name: &str,
) -> Option<Arc<dyn SystemStores>> {
self.system_stores
.get(&(
backend_label.to_ascii_lowercase(),
instance_name.to_string(),
))
.cloned()
}
pub fn default_full_store(&self) -> Option<Arc<dyn SystemStores>> {
self.default_key
.as_ref()
.and_then(|key| self.system_stores.get(key).cloned())
}
pub fn register(&mut self, store: Arc<dyn CanonicalStore>) {
let key = (
store.backend_label().to_ascii_lowercase(),
store.instance_name().to_string(),
);
if self.default_key.is_none() {
self.default_key = Some(key.clone());
}
self.by_key.insert(key, store);
}
pub fn set_default(&mut self, backend_label: &str, instance_name: &str) -> Result<(), String> {
let key = (
backend_label.to_ascii_lowercase(),
instance_name.to_string(),
);
if !self.by_key.contains_key(&key) {
return Err(format!(
"canonical store '{backend_label}:{instance_name}' is not registered"
));
}
self.default_key = Some(key);
Ok(())
}
pub fn get(&self, backend_label: &str, instance_name: &str) -> Option<Arc<dyn CanonicalStore>> {
self.by_key
.get(&(
backend_label.to_ascii_lowercase(),
instance_name.to_string(),
))
.cloned()
}
pub fn default_store(&self) -> Option<Arc<dyn CanonicalStore>> {
self.default_key
.as_ref()
.and_then(|key| self.by_key.get(key).cloned())
}
pub fn all(&self) -> Vec<Arc<dyn CanonicalStore>> {
self.by_key.values().cloned().collect()
}
pub fn registered_keys(&self) -> Vec<(String, String)> {
let mut keys: Vec<(String, String)> = self.by_key.keys().cloned().collect();
keys.sort();
keys
}
}
pub const OUTBOX_TABLE_SCHEMA: &str = r#"
udb_outbox_events:
event_seq BIGINT auto-increment, primary key
event_id CHAR(36) unique, not null
topic VARCHAR(255) not null
partition_key VARCHAR(255) not null default ''
payload JSON not null
created_at TIMESTAMP not null default now
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn durability_token_remembers_its_backend() {
let pg = DurabilityToken::new("postgres", "0/1A2B3C4D");
assert!(pg.is_for("postgres"));
assert!(pg.is_for("POSTGRES"), "case-insensitive");
assert!(!pg.is_for("mysql"));
let mysql = DurabilityToken::new("mysql", "binlog.000123:4567");
assert!(mysql.is_for("mysql"));
assert!(!mysql.is_for("postgres"));
}
#[test]
fn durability_token_round_trips_through_serde() {
let t = DurabilityToken::new("sqlite", "42");
let json = serde_json::to_string(&t).unwrap();
assert!(json.contains(r#""backend_label":"sqlite""#));
assert!(json.contains(r#""value":"42""#));
let back: DurabilityToken = serde_json::from_str(&json).unwrap();
assert_eq!(back, t);
}
}