use serde::{Deserialize, Serialize};
use crate::commit::{MemoryMutation, OpId, TenantId};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct YrpOp {
pub tenant_id: TenantId,
pub op_id: OpId,
pub mutation: MemoryMutation,
pub idempotency_key: Option<String>,
}
impl YrpOp {
pub fn encode(&self) -> Result<Vec<u8>, String> {
serde_json::to_vec(self).map_err(|e| format!("encode YrpOp: {e}"))
}
pub fn decode(bytes: &[u8]) -> Result<Self, String> {
serde_json::from_slice(bytes).map_err(|e| format!("decode YrpOp: {e}"))
}
}
pub fn fnv1a64(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf29ce484222325;
for b in bytes {
h ^= *b as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
pub fn claim_key_for_idempotency(tenant: TenantId, key: &str) -> u64 {
let mut buf = Vec::with_capacity(key.len() + 12);
buf.extend_from_slice(b"idem:");
buf.extend_from_slice(&tenant.0.to_le_bytes());
buf.extend_from_slice(key.as_bytes());
fnv1a64(&buf)
}
pub fn claim_key_for_op(tenant: TenantId, op_id: &OpId) -> u64 {
let mut buf = Vec::with_capacity(64);
buf.extend_from_slice(b"op:");
buf.extend_from_slice(&tenant.0.to_le_bytes());
buf.extend_from_slice(op_id.to_string().as_bytes());
fnv1a64(&buf)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_op(key: Option<&str>) -> YrpOp {
YrpOp {
tenant_id: TenantId::new(7),
op_id: OpId::new_random(),
mutation: MemoryMutation::UpsertMemory {
rid: "0198-test-rid".into(),
text: "hello".into(),
memory_type: "semantic".into(),
importance: 0.5,
valence: 0.0,
half_life: 168.0,
metadata: serde_json::json!({}),
namespace: "ns".into(),
certainty: 1.0,
domain: "work".into(),
source: "user".into(),
emotional_state: None,
embedding: Some(vec![0.25, -0.5]),
extracted_entities: vec!["hello".into()],
created_at_unix_micros: Some(1_784_000_000_000_000),
embedding_model: Some("default".into()),
},
idempotency_key: key.map(String::from),
}
}
#[test]
fn yrp_op_round_trips_through_payload_bytes() {
for key in [None, Some("client-key-1")] {
let op = sample_op(key);
let bytes = op.encode().expect("encode");
let back = YrpOp::decode(&bytes).expect("decode");
assert_eq!(op, back);
}
}
#[test]
fn claim_key_digest_is_pinned() {
assert_eq!(fnv1a64(b""), 0xcbf29ce484222325);
assert_eq!(fnv1a64(b"a"), 0xaf63dc4c8601ec8c);
let k1 = claim_key_for_idempotency(TenantId::new(1), "same-key");
let k2 = claim_key_for_idempotency(TenantId::new(2), "same-key");
assert_ne!(k1, k2, "tenant scoping must separate identical keys");
assert_eq!(
k1,
claim_key_for_idempotency(TenantId::new(1), "same-key"),
"digest must be deterministic"
);
}
}