use serde::{Deserialize, Serialize};
use super::kind::EventType;
use super::payload::EventPayload;
use super::type_code::type_code;
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ArtifactRemovedPayload {
pub content_hash: String,
}
impl ArtifactRemovedPayload {
pub fn idempotency_key(content_hash: &str) -> String {
format!("{}:{content_hash}", type_code(EventType::ArtifactRemoved))
}
}
impl EventPayload for ArtifactRemovedPayload {
fn event_type(&self) -> EventType {
EventType::ArtifactRemoved
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::event::EventPayload;
#[test]
fn payload_round_trips_camel_case_and_reports_event_type() {
let p = ArtifactRemovedPayload {
content_hash: "sha256:abc".to_owned(),
};
assert_eq!(p.event_type(), EventType::ArtifactRemoved);
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v["contentHash"], "sha256:abc");
assert_eq!(
v.as_object().unwrap().keys().collect::<Vec<_>>(),
vec!["contentHash"]
);
let back: ArtifactRemovedPayload = serde_json::from_value(v).unwrap();
assert_eq!(back, p);
}
#[test]
fn idempotency_key_is_content_targeted() {
assert_eq!(
ArtifactRemovedPayload::idempotency_key("sha256:abc"),
format!("{}:sha256:abc", type_code(EventType::ArtifactRemoved))
);
}
}
#[cfg(test)]
mod convergence_tests {
use super::*;
use crate::model::{ActorId, JournalId};
use crate::session::event::{EventTarget, EventType, ShoreEvent, Writer, WriterProducer};
use crate::session::projection::freshness::event_set_hash_for_events;
use crate::session::{EventStore, EventWriteOutcome};
fn writer_for(name: &str) -> Writer {
Writer {
actor_id: ActorId::new(format!("actor:{name}")),
producer: WriterProducer {
name: "shore".to_owned(),
version: "test".to_owned(),
},
}
}
fn removal_event(
content_hash: &str,
session: &str,
writer: &str,
occurred_at: &str,
) -> ShoreEvent {
ShoreEvent::new(
EventType::ArtifactRemoved,
ArtifactRemovedPayload::idempotency_key(content_hash),
EventTarget::for_journal(JournalId::new(format!("journal:{session}"))),
writer_for(writer),
ArtifactRemovedPayload {
content_hash: content_hash.to_owned(),
},
occurred_at,
)
.unwrap()
}
#[test]
fn same_content_hash_converges_across_sessions() {
let a = removal_event("sha256:blob", "alpha", "alice", "2026-06-19T00:00:00Z");
let b = removal_event("sha256:blob", "beta", "bob", "2026-06-19T09:09:09Z");
assert_eq!(
a.event_id, b.event_id,
"event id is key-derived from the content hash"
);
assert_eq!(
a.payload_hash, b.payload_hash,
"writer/session/occurredAt not in payload"
);
assert_ne!(
a.writer, b.writer,
"envelope writer differs — and that is fine"
);
assert_ne!(
a.occurred_at, b.occurred_at,
"occurredAt differs — and is not arbitrated"
);
assert_eq!(
event_set_hash_for_events([&a]).unwrap(),
event_set_hash_for_events([&b]).unwrap(),
"event-set contribution is identical"
);
}
#[test]
fn re_record_returns_existing_and_distinct_blob_is_a_new_member() {
let root = tempfile::tempdir().unwrap();
let store = EventStore::open(root.path().join(".shore/data"));
let a = removal_event("sha256:blob", "alpha", "alice", "2026-06-19T00:00:00Z");
let b = removal_event("sha256:blob", "beta", "bob", "2026-06-19T09:09:09Z");
let other = removal_event("sha256:other", "alpha", "alice", "2026-06-19T00:00:00Z");
assert_eq!(
store.record_event_once(&a).unwrap(),
EventWriteOutcome::Created
);
assert_eq!(
store.record_event_once(&b).unwrap(),
EventWriteOutcome::Existing,
"same blob removed by another session converges to Existing (keep-first-stored)"
);
assert_eq!(
store.record_event_once(&other).unwrap(),
EventWriteOutcome::Created,
"a distinct content hash is a new member, not a conflict"
);
let stored = store
.list_events()
.unwrap()
.into_iter()
.find(|event| event.idempotency_key == a.idempotency_key)
.expect("the removal fact is persisted");
assert_eq!(stored.occurred_at, a.occurred_at);
assert_ne!(stored.occurred_at, b.occurred_at);
}
}