use super::{Database, DatabaseValue, RepresentationError as Error, TransactionMode};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use switchy_database::{
Row,
query::{FilterableQuery, SortDirection},
};
pub(super) use installation::install_in;
pub mod diagnostics;
mod installation;
mod observers;
pub mod public_events;
#[cfg(test)]
mod tests;
const VERSION: i64 = 1;
const CONTRACT: &str = "workflow_observation_contract_v1";
const COVERAGE: &str = "workflow_observation_runs_v1";
const EVENTS: &str = "workflow_observations_v1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceCoverage {
pub table: String,
pub columns: Vec<String>,
pub operations: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CoverageOrigin {
Admission,
NotEnabled,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum ScalarFact {
Null,
Integer(i64),
Text(String),
Unavailable(FactUnavailableReason),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FactUnavailableReason {
Oversized,
UnsupportedType,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransitionObservation {
pub sequence: u64,
pub run_id: String,
pub source_table: String,
pub operation: String,
pub facts: BTreeMap<String, ScalarFact>,
pub timestamp_ms: Option<u64>,
pub timestamp_source: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ObservationPage {
pub version: u32,
pub run_id: String,
pub origin: CoverageOrigin,
pub sources: Vec<SourceCoverage>,
pub limitations: Vec<String>,
pub through_sequence: u64,
pub next_after_sequence: Option<u64>,
pub observations: Vec<TransitionObservation>,
}
pub async fn install(db: &dyn Database) -> Result<(), Error> {
let tx = db
.begin_transaction_with_mode(TransactionMode::Immediate)
.await?;
super::verified(&*tx).await?;
installation::install_in(&*tx).await?;
public_events::install(&*tx).await?;
tx.commit().await?;
Ok(())
}
pub async fn available(db: &dyn Database) -> Result<bool, Error> {
if db.get_table_info(CONTRACT).await?.is_none() {
if db.get_table_info(COVERAGE).await?.is_some()
|| db.get_table_info(EVENTS).await?.is_some()
{
return Err(Error::MaintenanceRequired);
}
return Ok(false);
}
installation::verify(db).await?;
Ok(true)
}
pub async fn enable_admission(
tx: &dyn switchy_database::DatabaseTransaction,
run_id: &str,
) -> Result<(), Error> {
validate_run(run_id)?;
super::verified(tx).await?;
installation::verify(tx).await?;
for table in [super::RUNS, "workflow_runs", COVERAGE] {
if !tx
.select(table)
.where_eq("run_id", run_id)
.limit(1)
.execute(tx)
.await?
.is_empty()
{
return Err(Error::InvalidAdmission);
}
}
public_events::install(tx).await?;
public_events::repeat::install_in(tx).await?;
public_events::exclusions::install_in(tx).await?;
public_events::enable(tx, run_id).await?;
public_events::repeat::enable_in(tx, run_id).await?;
public_events::exclusions::enable_in(tx, run_id).await?;
tx.insert(COVERAGE)
.value("run_id", run_id)
.value("version", VERSION)
.execute(tx)
.await?;
Ok(())
}
const fn validate_run(run: &str) -> Result<(), Error> {
if run.is_empty() || run.len() > 256 {
return Err(Error::InvalidAdmission);
}
Ok(())
}
fn integer(row: &Row, key: &str) -> Result<u64, Error> {
match row.get(key) {
Some(DatabaseValue::Int64(value)) => {
u64::try_from(value).map_err(|_| Error::MaintenanceRequired)
}
_ => Err(Error::MaintenanceRequired),
}
}
fn text(row: &Row, key: &str) -> Result<String, Error> {
match row.get(key) {
Some(DatabaseValue::String(value)) if value.len() <= 512 => Ok(value),
_ => Err(Error::MaintenanceRequired),
}
}
pub async fn page(
db: &dyn Database,
run_id: &str,
after_sequence: u64,
through_sequence: Option<u64>,
limit: u16,
) -> Result<ObservationPage, Error> {
validate_run(run_id)?;
if !(1..=100).contains(&limit) {
return Err(Error::InvalidAdmission);
}
let tx = db.begin_transaction().await?;
super::verified(&*tx).await?;
installation::verify(&*tx).await?;
let coverage = tx
.select(COVERAGE)
.where_eq("run_id", run_id)
.limit(1)
.execute(&*tx)
.await?;
let origin = if let Some(row) = coverage.first() {
if integer(row, "version")?
!= u64::try_from(VERSION).map_err(|_| Error::MaintenanceRequired)?
{
return Err(Error::MaintenanceRequired);
}
CoverageOrigin::Admission
} else {
CoverageOrigin::NotEnabled
};
let latest = tx
.select(EVENTS)
.where_eq("run_id", run_id)
.sort("sequence", SortDirection::Desc)
.limit(1)
.execute(&*tx)
.await?;
let high = latest
.first()
.map(|row| integer(row, "sequence"))
.transpose()?
.unwrap_or(0);
let through = through_sequence.unwrap_or(high);
if through > high || after_sequence > through {
return Err(Error::InvalidAdmission);
}
let rows = tx
.select(EVENTS)
.where_eq("run_id", run_id)
.where_gt(
"sequence",
i64::try_from(after_sequence).map_err(|_| Error::InvalidAdmission)?,
)
.where_lte(
"sequence",
i64::try_from(through).map_err(|_| Error::InvalidAdmission)?,
)
.sort("sequence", SortDirection::Asc)
.limit(usize::from(limit) + 1)
.execute(&*tx)
.await?;
if origin == CoverageOrigin::NotEnabled && !rows.is_empty() {
return Err(Error::MaintenanceRequired);
}
let has_more = rows.len() > usize::from(limit);
let observations = rows
.iter()
.take(usize::from(limit))
.map(observers::decode)
.collect::<Result<Vec<_>, _>>()?;
let next_after_sequence = has_more
.then(|| observations.last().map(|row| row.sequence))
.flatten();
let result = ObservationPage {
version: 1, run_id: run_id.into(), origin, sources: observers::coverage(),
limitations: vec![
"Only declared direct-run-id sources and selected scalar columns are observed".into(),
"No owner JSON, exact judgement evidence, session links or graph staging/cancellation-publication sources".into(),
"Text facts are transactionally bounded to 512 bytes; integer and timestamp facts retain only integer storage, with explicit unavailable reasons".into(),
"No transaction identity, execution meaning, historical backfill or durable resume guarantee".into(),
],
through_sequence: through, next_after_sequence, observations,
};
tx.commit().await?;
Ok(result)
}