use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
pub(crate) type TxId = u64;
pub(crate) fn tx_id_now() -> TxId {
#[cfg(all(target_arch = "wasm32", feature = "browser"))]
{
js_sys::Date::now() as u64
}
#[cfg(not(all(target_arch = "wasm32", feature = "browser")))]
{
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("System time before UNIX epoch")
.as_millis() as u64
}
}
pub type EntityId = Uuid;
pub(crate) type Attribute = String;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum Value {
String(String),
Integer(i64),
Float(f64),
Boolean(bool),
Ref(EntityId),
Keyword(String),
Null,
}
impl Eq for Value {}
impl std::hash::Hash for Value {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
std::mem::discriminant(self).hash(state);
match self {
Value::String(s) => s.hash(state),
Value::Integer(i) => i.hash(state),
Value::Float(f) => {
if f.is_nan() {
0_u8.hash(state);
} else if f.is_sign_negative() {
1_u8.hash(state);
(-f).to_bits().hash(state);
} else {
2_u8.hash(state);
f.to_bits().hash(state);
}
}
Value::Boolean(b) => b.hash(state),
Value::Ref(r) => r.hash(state),
Value::Keyword(k) => k.hash(state),
Value::Null => {}
}
}
}
impl PartialOrd for Value {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Value {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
match (self, other) {
(Value::String(a), Value::String(b)) => a.cmp(b),
(Value::Integer(a), Value::Integer(b)) => a.cmp(b),
(Value::Float(a), Value::Float(b)) => {
if a.is_nan() && b.is_nan() {
std::cmp::Ordering::Equal
} else if a.is_nan() {
std::cmp::Ordering::Greater
} else if b.is_nan() {
std::cmp::Ordering::Less
} else {
a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
}
}
(Value::Boolean(a), Value::Boolean(b)) => a.cmp(b),
(Value::Ref(a), Value::Ref(b)) => a.cmp(b),
(Value::Keyword(a), Value::Keyword(b)) => a.cmp(b),
(Value::Null, Value::Null) => std::cmp::Ordering::Equal,
_ => {
fn discriminant(v: &Value) -> u8 {
match v {
Value::String(_) => 0,
Value::Integer(_) => 1,
Value::Float(_) => 2,
Value::Boolean(_) => 3,
Value::Ref(_) => 4,
Value::Keyword(_) => 5,
Value::Null => 6,
}
}
discriminant(self).cmp(&discriminant(other))
}
}
}
}
impl Value {
pub fn as_string(&self) -> Option<&str> {
match self {
Value::String(s) => Some(s),
_ => None,
}
}
pub fn as_integer(&self) -> Option<i64> {
match self {
Value::Integer(i) => Some(*i),
_ => None,
}
}
pub fn as_float(&self) -> Option<f64> {
match self {
Value::Float(f) => Some(*f),
_ => None,
}
}
pub fn as_boolean(&self) -> Option<bool> {
match self {
Value::Boolean(b) => Some(*b),
_ => None,
}
}
pub fn as_ref(&self) -> Option<EntityId> {
match self {
Value::Ref(id) => Some(*id),
_ => None,
}
}
pub fn as_keyword(&self) -> Option<&str> {
match self {
Value::Keyword(k) => Some(k),
_ => None,
}
}
pub fn is_null(&self) -> bool {
matches!(self, Value::Null)
}
}
pub(crate) const VALID_TIME_FOREVER: i64 = i64::MAX;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub(crate) struct Fact {
pub(crate) entity: EntityId,
pub(crate) attribute: Attribute,
pub(crate) value: Value,
pub(crate) tx_id: TxId,
pub(crate) tx_count: u64,
pub(crate) valid_from: i64,
pub(crate) valid_to: i64,
pub(crate) asserted: bool,
}
impl Fact {
pub(crate) fn new(entity: EntityId, attribute: Attribute, value: Value, tx_id: TxId) -> Self {
Fact {
entity,
attribute,
value,
tx_id,
tx_count: 0,
valid_from: tx_id as i64,
valid_to: VALID_TIME_FOREVER,
asserted: true,
}
}
pub(crate) fn with_valid_time(
entity: EntityId,
attribute: Attribute,
value: Value,
tx_id: TxId,
tx_count: u64,
valid_from: i64,
valid_to: i64,
) -> Self {
Fact {
entity,
attribute,
value,
tx_id,
tx_count,
valid_from,
valid_to,
asserted: true,
}
}
pub(crate) fn retract(
entity: EntityId,
attribute: Attribute,
value: Value,
tx_id: TxId,
) -> Self {
Fact {
entity,
attribute,
value,
tx_id,
tx_count: 0,
valid_from: tx_id as i64,
valid_to: VALID_TIME_FOREVER,
asserted: false,
}
}
pub(crate) fn is_asserted(&self) -> bool {
self.asserted
}
}
#[cfg(test)]
impl Fact {
#[allow(dead_code)]
pub(crate) fn with_asserted(
entity: EntityId,
attribute: Attribute,
value: Value,
tx_id: TxId,
asserted: bool,
) -> Self {
Fact {
entity,
attribute,
value,
tx_id,
tx_count: 0,
valid_from: tx_id as i64,
valid_to: VALID_TIME_FOREVER,
asserted,
}
}
pub(crate) fn is_retracted(&self) -> bool {
!self.asserted
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct TransactOptions {
pub(crate) valid_from: Option<i64>,
pub(crate) valid_to: Option<i64>,
}
impl TransactOptions {
pub(crate) fn new(valid_from: Option<i64>, valid_to: Option<i64>) -> Self {
TransactOptions {
valid_from,
valid_to,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tx_id_from_system_time(time: std::time::SystemTime) -> TxId {
time.duration_since(std::time::UNIX_EPOCH)
.expect("System time before UNIX epoch")
.as_millis() as u64
}
#[test]
fn test_tx_id_timestamp() {
let tx1 = tx_id_now();
std::thread::sleep(std::time::Duration::from_millis(5));
let tx2 = tx_id_now();
assert!(
tx2 > tx1,
"Transaction IDs should be chronologically ordered"
);
use std::time::SystemTime;
let now = SystemTime::now();
let tx_id = tx_id_from_system_time(now);
assert!(
tx_id > 0,
"tx_id_from_system_time should produce positive value"
);
}
#[test]
fn test_tx_id_ordering() {
let mut tx_ids = vec![];
for _ in 0..5 {
tx_ids.push(tx_id_now());
std::thread::sleep(std::time::Duration::from_millis(1));
}
for i in 1..tx_ids.len() {
assert!(
tx_ids[i] > tx_ids[i - 1],
"TxIds should be strictly increasing"
);
}
}
#[test]
fn test_value_creation_and_accessors() {
let string_val = Value::String("Alice".to_string());
assert_eq!(string_val.as_string(), Some("Alice"));
assert_eq!(string_val.as_integer(), None);
assert!(!string_val.is_null());
let int_val = Value::Integer(42);
assert_eq!(int_val.as_integer(), Some(42));
assert_eq!(int_val.as_string(), None);
let float_val = Value::Float(4.5);
assert_eq!(float_val.as_float(), Some(4.5));
let bool_val = Value::Boolean(true);
assert_eq!(bool_val.as_boolean(), Some(true));
let ref_id = Uuid::new_v4();
let ref_val = Value::Ref(ref_id);
assert_eq!(ref_val.as_ref(), Some(ref_id));
assert_eq!(ref_val.as_string(), None);
let keyword_val = Value::Keyword(":person".to_string());
assert_eq!(keyword_val.as_keyword(), Some(":person"));
let null_val = Value::Null;
assert!(null_val.is_null());
assert_eq!(null_val.as_string(), None);
}
#[test]
fn test_fact_creation() {
let entity = Uuid::new_v4();
let fact = Fact::new(
entity,
":person/name".to_string(),
Value::String("Alice".to_string()),
1,
);
assert_eq!(fact.entity, entity);
assert_eq!(fact.attribute, ":person/name");
assert_eq!(fact.value, Value::String("Alice".to_string()));
assert_eq!(fact.tx_id, 1);
assert!(fact.is_asserted());
assert!(!fact.is_retracted());
}
#[test]
fn test_fact_retraction() {
let entity = Uuid::new_v4();
let fact = Fact::retract(
entity,
":person/name".to_string(),
Value::String("Alice".to_string()),
2,
);
assert_eq!(fact.entity, entity);
assert_eq!(fact.attribute, ":person/name");
assert_eq!(fact.tx_id, 2);
assert!(!fact.is_asserted());
assert!(fact.is_retracted());
}
#[test]
fn test_fact_with_ref_value() {
let alice = Uuid::new_v4();
let bob = Uuid::new_v4();
let friendship = Fact::new(alice, ":friend".to_string(), Value::Ref(bob), 1);
assert_eq!(friendship.entity, alice);
assert_eq!(friendship.attribute, ":friend");
assert_eq!(friendship.value.as_ref(), Some(bob));
assert!(friendship.is_asserted());
}
#[test]
fn test_fact_equality() {
let entity = Uuid::new_v4();
let fact1 = Fact::new(
entity,
":person/name".to_string(),
Value::String("Alice".to_string()),
1,
);
let fact2 = Fact::new(
entity,
":person/name".to_string(),
Value::String("Alice".to_string()),
1,
);
assert_eq!(fact1, fact2);
let fact3 = Fact::new(
entity,
":person/name".to_string(),
Value::String("Alice".to_string()),
2,
);
assert_ne!(fact1, fact3);
let fact4 = Fact::with_valid_time(
entity,
":person/name".to_string(),
Value::String("Alice".to_string()),
1,
99, 1,
VALID_TIME_FOREVER,
);
assert_ne!(fact1, fact4);
let fact5 = Fact::with_valid_time(
entity,
":person/name".to_string(),
Value::String("Alice".to_string()),
1,
0,
9999, VALID_TIME_FOREVER,
);
assert_ne!(fact1, fact5);
let fact6 = Fact::with_valid_time(
entity,
":person/name".to_string(),
Value::String("Alice".to_string()),
1,
0,
1,
12345, );
assert_ne!(fact1, fact6);
}
#[test]
fn test_value_types() {
let values = vec![
Value::String("test".to_string()),
Value::Integer(42),
Value::Float(4.5),
Value::Boolean(true),
Value::Ref(Uuid::new_v4()),
Value::Keyword(":status/active".to_string()),
Value::Null,
];
for value in values {
let serialized = serde_json::to_string(&value).unwrap();
let deserialized: Value = serde_json::from_str(&serialized).unwrap();
assert_eq!(value, deserialized);
}
}
#[test]
fn test_fact_has_valid_time_fields() {
let entity = Uuid::new_v4();
let fact = Fact::new(
entity,
":person/name".to_string(),
Value::String("Alice".to_string()),
1000,
);
assert_eq!(fact.valid_from, 1000_i64);
assert_eq!(fact.valid_to, VALID_TIME_FOREVER);
assert_eq!(fact.tx_count, 0);
}
#[test]
fn test_fact_with_explicit_valid_time() {
let entity = Uuid::new_v4();
let fact = Fact::with_valid_time(
entity,
":employment/status".to_string(),
Value::Keyword(":active".to_string()),
1000,
1,
1672531200000_i64, 1685577600000_i64, );
assert_eq!(fact.valid_from, 1672531200000_i64);
assert_eq!(fact.valid_to, 1685577600000_i64);
assert_eq!(fact.tx_count, 1);
}
#[test]
fn test_valid_time_forever_constant() {
assert_eq!(VALID_TIME_FOREVER, i64::MAX);
}
#[test]
fn test_transact_options_defaults() {
let opts = TransactOptions::default();
assert!(opts.valid_from.is_none());
assert!(opts.valid_to.is_none());
}
fn cmp_values(a: Value, b: Value) -> std::cmp::Ordering {
a.cmp(&b)
}
#[test]
fn value_ord_cross_variant_is_antisymmetric() {
let pairs: &[(Value, Value)] = &[
(Value::String("hello".into()), Value::Integer(42)),
(Value::Integer(1), Value::Float(1.0)),
(Value::Boolean(true), Value::Null),
(Value::Keyword(":k".into()), Value::String("x".into())),
];
for (a, b) in pairs {
let forward = cmp_values(a.clone(), b.clone());
let backward = cmp_values(b.clone(), a.clone());
assert_eq!(
forward,
backward.reverse(),
"Value::Ord cross-variant comparison must be antisymmetric"
);
assert_ne!(
forward,
std::cmp::Ordering::Equal,
"Values of different types must not compare as Equal"
);
}
}
#[test]
fn value_ord_cross_variant_is_stable() {
let string_lt_integer = cmp_values(Value::String("a".into()), Value::Integer(0));
for s in ["", "z", "hello world"] {
for i in [i64::MIN, 0, i64::MAX] {
let result = cmp_values(Value::String(s.into()), Value::Integer(i));
assert_eq!(
result, string_lt_integer,
"Cross-variant ordering must depend only on the variant, not the inner value"
);
}
}
}
}