use crate::contracts::availability::{SchemaAvailability, SchemaFreshness};
use crate::contracts::view::ContractSchemaState;
use saya_store::KnowledgeItem;
use saya_types::{FINGERPRINT_VERSION, KnowledgeState, SchemaBinding};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum KnowledgeValidity {
Valid,
NeedsReview,
SchemaUnavailable,
Invalid,
}
impl KnowledgeValidity {
pub(crate) fn aggregate(self, other: Self) -> Self {
let rank = |state: Self| match state {
Self::Invalid => 3,
Self::SchemaUnavailable => 2,
Self::NeedsReview => 1,
Self::Valid => 0,
};
if rank(self) >= rank(other) {
self
} else {
other
}
}
}
impl From<KnowledgeValidity> for ContractSchemaState {
fn from(validity: KnowledgeValidity) -> Self {
match validity {
KnowledgeValidity::Valid => Self::Current,
KnowledgeValidity::NeedsReview => Self::NeedsReview,
KnowledgeValidity::SchemaUnavailable => Self::LiveSchemaUnavailable,
KnowledgeValidity::Invalid => Self::Stale,
}
}
}
pub(crate) fn item_validity_for(
item: &KnowledgeItem,
availability: &SchemaAvailability,
freshness: SchemaFreshness,
) -> KnowledgeValidity {
if matches!(item.state, KnowledgeState::Dismissed) {
return KnowledgeValidity::Invalid;
}
let Some(schema) = availability.live_table_schema(freshness) else {
return KnowledgeValidity::SchemaUnavailable;
};
if item.fingerprint_version != FINGERPRINT_VERSION {
return KnowledgeValidity::NeedsReview;
}
match serde_json::from_str::<SchemaBinding>(&item.schema_binding_json) {
Ok(binding) => {
let obj = &item.object;
match binding.validate_in_tree(schema, obj.catalog(), obj.schema(), obj.object()) {
saya_types::BindingValidity::Valid => KnowledgeValidity::Valid,
saya_types::BindingValidity::Invalid => KnowledgeValidity::Invalid,
}
}
Err(_) => KnowledgeValidity::NeedsReview,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::contracts::availability::{SchemaAvailability, SchemaFreshness};
use saya_types::{Column, DatabaseObjectKind, SchemaTree};
const FRESH_NOW: i64 = 1_000_000;
fn schema_with(table: saya_types::Table) -> SchemaTree {
SchemaTree {
databases: vec![saya_types::Database {
name: "catalog".into(),
schemas: vec![saya_types::Schema {
name: "public".into(),
tables: vec![table],
}],
}],
}
}
fn table(cols: &[(&str, &str, bool)]) -> saya_types::Table {
saya_types::Table {
name: "orders".into(),
columns: cols
.iter()
.map(|(name, ty, nullable)| Column {
name: (*name).into(),
data_type: (*ty).into(),
nullable: *nullable,
})
.collect(),
}
}
#[test]
fn aggregate_one_invalid_among_valid_is_invalid() {
let verdicts = [
KnowledgeValidity::Valid,
KnowledgeValidity::Invalid,
KnowledgeValidity::Valid,
];
let aggregate = verdicts
.iter()
.copied()
.fold(KnowledgeValidity::Valid, KnowledgeValidity::aggregate);
assert_eq!(
aggregate,
KnowledgeValidity::Invalid,
"one invalid claim flags its object; it cannot hide behind a valid sibling"
);
}
use crate::contracts::availability::MODEL_SCHEMA_MAX_AGE_MS;
use saya_store::KnowledgeItem;
use saya_types::{
ClaimOrigin, ColumnRequirement, FINGERPRINT_VERSION, KnowledgeSlot, SchemaBinding,
};
fn item_profile() -> saya_types::ProfileIdentity {
saya_types::ProfileIdentity::parse(&format!("p-{}", "a".repeat(64))).unwrap()
}
fn item_object() -> saya_types::DatabaseObjectRef {
saya_types::DatabaseObjectRef::new(
item_profile(),
"catalog",
"public",
"orders",
DatabaseObjectKind::Table,
)
.unwrap()
}
fn time_column_item(state: KnowledgeState, binding_json: &str) -> KnowledgeItem {
KnowledgeItem {
id: "ki-1".into(),
object: item_object(),
slot: KnowledgeSlot::TableDefaultTime,
cardinality_single: true,
value: saya_types::ClaimPayload::default_time_column("created_at", None).unwrap(),
source: ClaimOrigin::UserExplicit,
state,
schema_binding_json: binding_json.into(),
fingerprint_version: FINGERPRINT_VERSION,
created_unix_ms: 0,
updated_unix_ms: 0,
}
}
fn time_binding() -> String {
serde_json::to_string(&SchemaBinding::Column {
column: "created_at".to_string(),
requirement: ColumnRequirement::Time,
})
.unwrap()
}
fn table_binding() -> String {
serde_json::to_string(&SchemaBinding::Table).unwrap()
}
#[test]
fn item_validity_for_a_satisfied_binding_is_valid() {
let item = time_column_item(KnowledgeState::Active, &time_binding());
let schema = schema_with(table(&[("created_at", "timestamp", false)]));
assert_eq!(
item_validity_for(
&item,
&SchemaAvailability::available(schema, FRESH_NOW),
SchemaFreshness::for_model(FRESH_NOW),
),
KnowledgeValidity::Valid,
"a binding the live schema satisfies is valid"
);
}
#[test]
fn item_validity_unrelated_column_change_is_valid_under_d4() {
let item = time_column_item(KnowledgeState::Active, &time_binding());
let schema = schema_with(table(&[
("id", "bigint", false),
("created_at", "timestamp", false),
("note", "text", true),
]));
assert_eq!(
item_validity_for(
&item,
&SchemaAvailability::available(schema, FRESH_NOW),
SchemaFreshness::for_model(FRESH_NOW),
),
KnowledgeValidity::Valid,
"an unrelated column changing must not invalidate under D-4"
);
}
#[test]
fn item_validity_dropped_bound_column_is_invalid() {
let item = time_column_item(KnowledgeState::Active, &time_binding());
let schema = schema_with(table(&[("id", "bigint", false)]));
assert_eq!(
item_validity_for(
&item,
&SchemaAvailability::available(schema, FRESH_NOW),
SchemaFreshness::for_model(FRESH_NOW),
),
KnowledgeValidity::Invalid,
"a gone bound column makes the item invalid"
);
}
#[test]
fn item_validity_retyped_bound_column_is_invalid() {
let item = time_column_item(KnowledgeState::Active, &time_binding());
let schema = schema_with(table(&[("created_at", "text", false)]));
assert_eq!(
item_validity_for(
&item,
&SchemaAvailability::available(schema, FRESH_NOW),
SchemaFreshness::for_model(FRESH_NOW),
),
KnowledgeValidity::Invalid,
"a bound column that lost its temporal type is invalid"
);
}
#[test]
fn item_validity_missing_or_unavailable_schema_is_schema_unavailable() {
let item = time_column_item(KnowledgeState::Active, &time_binding());
assert_eq!(
item_validity_for(
&item,
&SchemaAvailability::Missing,
SchemaFreshness::for_model(FRESH_NOW),
),
KnowledgeValidity::SchemaUnavailable,
"a missing cache is not evidence a bound column is gone"
);
assert_eq!(
item_validity_for(
&item,
&SchemaAvailability::Unavailable,
SchemaFreshness::for_model(FRESH_NOW),
),
KnowledgeValidity::SchemaUnavailable,
"a store error is not evidence a bound column is gone"
);
}
#[test]
fn item_validity_stale_by_age_cache_is_schema_unavailable() {
let item = time_column_item(KnowledgeState::Active, &time_binding());
let schema = schema_with(table(&[("created_at", "timestamp", false)]));
let now = MODEL_SCHEMA_MAX_AGE_MS + 1;
assert_eq!(
item_validity_for(
&item,
&SchemaAvailability::available(schema, 0),
SchemaFreshness::for_model(now),
),
KnowledgeValidity::SchemaUnavailable,
"a stale-by-age cache cannot vouch for currency on the model path"
);
}
#[test]
fn item_validity_non_current_fingerprint_version_is_needs_review() {
let mut item = time_column_item(KnowledgeState::Active, &time_binding());
item.fingerprint_version = FINGERPRINT_VERSION + 1;
let schema = schema_with(table(&[("created_at", "timestamp", false)]));
assert_eq!(
item_validity_for(
&item,
&SchemaAvailability::available(schema, FRESH_NOW),
SchemaFreshness::for_model(FRESH_NOW),
),
KnowledgeValidity::NeedsReview,
"a binding written under an unknown format is held for review"
);
}
#[test]
fn item_validity_dismissed_is_invalid_regardless_of_schema() {
let item = time_column_item(KnowledgeState::Dismissed, &time_binding());
let schema = schema_with(table(&[("created_at", "timestamp", false)]));
assert_eq!(
item_validity_for(
&item,
&SchemaAvailability::available(schema, FRESH_NOW),
SchemaFreshness::for_model(FRESH_NOW),
),
KnowledgeValidity::Invalid,
"a dismissed item is not a live fact"
);
assert_eq!(
item_validity_for(
&item,
&SchemaAvailability::Missing,
SchemaFreshness::for_model(FRESH_NOW),
),
KnowledgeValidity::Invalid,
"dismissal beats schema unavailability"
);
}
#[test]
fn item_validity_table_binding_depends_only_on_the_table_existing() {
let item = KnowledgeItem {
id: "ki-2".into(),
object: item_object(),
slot: KnowledgeSlot::TableGrain,
cardinality_single: true,
value: saya_types::ClaimPayload::table_grain("one row per order", None).unwrap(),
source: ClaimOrigin::UserExplicit,
state: KnowledgeState::Active,
schema_binding_json: table_binding(),
fingerprint_version: FINGERPRINT_VERSION,
created_unix_ms: 0,
updated_unix_ms: 0,
};
let present = schema_with(table(&[("id", "bigint", false)]));
assert_eq!(
item_validity_for(
&item,
&SchemaAvailability::available(present, FRESH_NOW),
SchemaFreshness::for_model(FRESH_NOW),
),
KnowledgeValidity::Valid,
"a table binding is valid when the table exists"
);
let absent_table = schema_with(saya_types::Table {
name: "other".into(),
columns: vec![Column {
name: "id".into(),
data_type: "bigint".into(),
nullable: false,
}],
});
assert_eq!(
item_validity_for(
&item,
&SchemaAvailability::available(absent_table, FRESH_NOW),
SchemaFreshness::for_model(FRESH_NOW),
),
KnowledgeValidity::Invalid,
"a table binding is invalid when the table is gone"
);
}
#[test]
fn item_validity_unparseable_binding_is_needs_review() {
let item = time_column_item(KnowledgeState::Active, "not json at all");
let schema = schema_with(table(&[("created_at", "timestamp", false)]));
assert_eq!(
item_validity_for(
&item,
&SchemaAvailability::available(schema, FRESH_NOW),
SchemaFreshness::for_model(FRESH_NOW),
),
KnowledgeValidity::NeedsReview,
"an uninterpretable binding is held for human review, not dropped"
);
}
}