use super::{
CompactSnapshotCodec, HEADER_V1, ProjectionSnapshot, Result, Writer, retractions, write_cursor,
write_fact, write_revision,
};
use crate::codec::Codec;
use crate::{
domain::{Evidence, MemoryEvent, MemoryFact, MemoryNode},
event::NewEvent,
id::{AgentId, EntityId, EventId, FactId, SessionId, StreamId},
projection::{MemoryProjection, ProjectionClock, replay_tracked},
store::{EventStore, ExpectedVersion, InMemoryStore},
time::Timestamp,
};
fn ts(value: i64) -> Timestamp {
Timestamp::from_unix_micros(value)
}
fn encode_v1(value: &ProjectionSnapshot<MemoryProjection>) -> Result<Vec<u8>> {
let mut writer = Writer::new(HEADER_V1);
write_cursor(&mut writer, &value.cursor)?;
writer.usize(value.projection.nodes.len())?;
for history in &value.projection.nodes {
writer.usize(history.later.len() + 1)?;
write_revision(&mut writer, &history.first)?;
for revision in &history.later {
write_revision(&mut writer, revision)?;
}
}
writer.usize(value.projection.facts.len())?;
for fact in &value.projection.facts {
write_fact(&mut writer, fact)?;
}
writer.usize(value.projection.supersessions.len())?;
for (prior, change) in &value.projection.supersessions {
writer.string(prior.as_str())?;
writer.string(change.replacement.as_str())?;
writer.signed(change.valid_from.as_unix_micros());
writer.signed(change.recorded_at.as_unix_micros());
}
retractions::write_v1(&mut writer, &value.projection.retractions)?;
writer.optional_u64(value.projection.last_global_position);
Ok(writer.finish())
}
fn event(id: &str, recorded_at: i64, payload: MemoryEvent) -> NewEvent<MemoryEvent> {
NewEvent::new(
EventId::new(id).unwrap(),
payload.event_type(),
ts(recorded_at),
ts(recorded_at),
AgentId::new("agent:test").unwrap(),
SessionId::new("session:test").unwrap(),
payload,
)
.unwrap()
}
#[test]
fn v1_compact_snapshot_migrates_a_single_retraction() {
let fact = MemoryFact::new(
FactId::new("fact:old").unwrap(),
EntityId::new("task:1").unwrap(),
"affects",
EntityId::new("file:1").unwrap(),
ts(10),
ts(20),
AgentId::new("agent:test").unwrap(),
SessionId::new("session:test").unwrap(),
Evidence::new("test", "suite").unwrap(),
)
.unwrap();
let pending = [
event(
"event:task",
1,
MemoryEvent::NodeUpserted {
node: MemoryNode::new(EntityId::new("task:1").unwrap(), "task", "Task").unwrap(),
},
),
event(
"event:file",
1,
MemoryEvent::NodeUpserted {
node: MemoryNode::new(EntityId::new("file:1").unwrap(), "file", "File").unwrap(),
},
),
event(
"event:fact",
20,
MemoryEvent::FactRecorded { fact: fact.clone() },
),
event(
"event:retract",
50,
MemoryEvent::FactRetracted {
fact_id: fact.id,
valid_until: ts(30),
evidence: vec![Evidence::new("test", "suite").unwrap()],
},
),
];
let mut store = InMemoryStore::default();
store
.append(
&StreamId::new("stream:retract").unwrap(),
ExpectedVersion::NoStream,
&pending,
)
.unwrap();
let events = store.load_all(None, usize::MAX);
let (projection, cursor) = replay_tracked::<_, MemoryProjection>(&events).unwrap();
let snapshot = ProjectionSnapshot { cursor, projection };
let bytes = encode_v1(&snapshot).unwrap();
assert!(bytes.starts_with(HEADER_V1));
let decoded = CompactSnapshotCodec.decode(&bytes).unwrap();
assert!(
decoded
.projection
.view(ProjectionClock::new(ts(40), ts(60)))
.facts
.is_empty()
);
assert_eq!(
decoded
.projection
.view(ProjectionClock::new(ts(40), ts(40)))
.facts
.len(),
1
);
}