turnframe_store/events.rs
1//! The event journal: the append-only claim ledger (spec §17.1, ADR-012).
2//!
3//! Append-only in the sense that carries the guarantee: no event is inserted
4//! between two others, removed or moved, and the only write touching a stored
5//! one is [`EventJournalWriter::redact_payload`], which empties a payload
6//! without disturbing identity, type, sequence or timestamps. A batch is
7//! appended atomically, a duplicate id rejects the whole batch, and the store
8//! assigns every event a [`StoredEvent::sequence`] that strictly increases
9//! across the whole store. Payloads cross type-erased; every lookup is
10//! account-scoped.
11//!
12//! There are **two reads and choosing wrong is a correctness bug**:
13//! [`EventJournalReader::list_since`] pages by case revision, which is not a
14//! position, and answers "what happened to this case since the revision I
15//! hold"; [`EventJournalReader::read_from`] pages by the store-assigned
16//! sequence, which is, and is the read for any consumer that must see every
17//! event exactly once.
18//!
19//! Why erasure is a redaction rather than a delete, and how to design payloads
20//! that need it less, is in
21//! [`docs/persistence.md`](https://github.com/turnframe-rs/turnframe/blob/main/docs/persistence.md).
22//!
23
24use async_trait::async_trait;
25use chrono::{DateTime, Utc};
26use serde::{Deserialize, Serialize};
27use turnframe_core::case::CaseKey;
28use turnframe_core::event::{Commit, CommittedEvent, EventRedaction, ReceiptEvent, RedactedEvent};
29use turnframe_core::ids::{AccountId, CaseRevision, CommandId, EventId, RedactionAuthority};
30
31use crate::error::StoreError;
32
33/// A committed event as persisted (spec §22.2, `tf_domain_events`).
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct StoredEvent {
36 /// Store-assigned position in the journal, strictly increasing in append
37 /// order across the whole store — not per case and not per account.
38 ///
39 /// It is a position, not a count: nothing promises it has no gaps, only
40 /// that a later append gets a higher one. [`EventCursor`] pages by it; see
41 /// the module documentation for why the revision cannot.
42 pub sequence: u64,
43 /// Identifier in the ledger.
44 pub event_id: EventId,
45 /// Owning tenant.
46 pub account_id: AccountId,
47 /// The case.
48 pub case_key: CaseKey,
49 /// Revision of the case produced by the commit that emitted the event.
50 pub case_revision: CaseRevision,
51 /// Command whose execution produced it.
52 pub command_id: CommandId,
53 /// Stable event type label.
54 pub event_type: String,
55 /// Type-erased payload, or JSON `null` once it has been erased.
56 ///
57 /// [`Self::redaction`] is what says which of the two it is; a domain whose
58 /// event type legitimately serializes to `null` is not misread.
59 pub payload: serde_json::Value,
60 /// When it was committed.
61 pub occurred_at: DateTime<Utc>,
62 /// Present once the payload has been erased, saying when and on whose
63 /// authority — never what was removed.
64 ///
65 /// It defaults to absent, so a record written before this field existed
66 /// reads back as an event whose payload is intact, which it is.
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub redaction: Option<EventRedaction>,
69}
70
71impl StoredEvent {
72 /// The core view of the event.
73 ///
74 /// The payload comes back as it is stored, so an erased event yields a
75 /// `null` payload here. Use [`Self::to_receipt_event`] wherever the caller
76 /// has to tell the two apart, which is everywhere a receipt is rendered.
77 #[must_use]
78 pub fn to_committed(&self) -> CommittedEvent<serde_json::Value> {
79 CommittedEvent {
80 event_id: self.event_id,
81 event_type: self.event_type.clone(),
82 occurred_at: self.occurred_at,
83 payload: self.payload.clone(),
84 }
85 }
86
87 /// Returns `true` when the payload has been erased.
88 #[must_use]
89 pub fn is_redacted(&self) -> bool {
90 self.redaction.is_some()
91 }
92
93 /// The event in the form a receipt renderer takes, which distinguishes a
94 /// payload that is there from one that was erased.
95 #[must_use]
96 pub fn to_receipt_event(&self) -> ReceiptEvent<serde_json::Value> {
97 match &self.redaction {
98 None => ReceiptEvent::Committed(self.to_committed()),
99 Some(redaction) => ReceiptEvent::Redacted(RedactedEvent {
100 event_id: self.event_id,
101 event_type: self.event_type.clone(),
102 occurred_at: self.occurred_at,
103 redaction: redaction.clone(),
104 }),
105 }
106 }
107}
108
109/// The events one command committed, with the revision they produced.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct EventBatch {
112 /// Owning tenant.
113 pub account_id: AccountId,
114 /// The case.
115 pub case_key: CaseKey,
116 /// The command that produced the events.
117 pub command_id: CommandId,
118 /// Revision after the commit.
119 pub revision: CaseRevision,
120 /// The events, in commit order.
121 pub events: Vec<CommittedEvent<serde_json::Value>>,
122}
123
124impl EventBatch {
125 /// Builds a batch from an already erased list of events.
126 #[must_use]
127 pub fn new(
128 account_id: AccountId,
129 case_key: CaseKey,
130 command_id: CommandId,
131 revision: CaseRevision,
132 events: Vec<CommittedEvent<serde_json::Value>>,
133 ) -> Self {
134 Self {
135 account_id,
136 case_key,
137 command_id,
138 revision,
139 events,
140 }
141 }
142
143 /// Builds a batch from a typed [`Commit`], erasing every event payload.
144 ///
145 /// # Errors
146 /// * `Serialization` when an event payload cannot be rendered as JSON.
147 pub fn from_commit<S, E: Serialize>(
148 account_id: AccountId,
149 case_key: CaseKey,
150 command_id: CommandId,
151 commit: &Commit<S, E>,
152 ) -> Result<Self, StoreError> {
153 let mut events = Vec::with_capacity(commit.events.len());
154 for event in &commit.events {
155 let payload =
156 serde_json::to_value(&event.payload).map_err(|_| StoreError::Serialization)?;
157 events.push(CommittedEvent {
158 event_id: event.event_id,
159 event_type: event.event_type.clone(),
160 occurred_at: event.occurred_at,
161 payload,
162 });
163 }
164 Ok(Self::new(
165 account_id,
166 case_key,
167 command_id,
168 commit.new_revision,
169 events,
170 ))
171 }
172
173 /// Identifiers of the events, in order.
174 #[must_use]
175 pub fn event_ids(&self) -> Vec<EventId> {
176 self.events.iter().map(|e| e.event_id).collect()
177 }
178
179 /// Returns `true` when the batch carries no event.
180 #[must_use]
181 pub fn is_empty(&self) -> bool {
182 self.events.is_empty()
183 }
184}
185
186/// The events one command committed, read back from the ledger, in the form a
187/// receipt renderer takes.
188///
189/// It is the read-side counterpart of [`EventBatch`], and the difference is the
190/// whole point: a batch is what an append carries, so every event in it has a
191/// payload, while a read may hand back an event whose payload was erased. Build
192/// these with [`group_for_receipts`].
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct LedgerReceiptGroup {
195 /// The case the events belong to.
196 pub case_key: CaseKey,
197 /// The command that produced them.
198 pub command_id: CommandId,
199 /// Revision the commit produced.
200 pub revision: CaseRevision,
201 /// The events, in append order, erasures carried through.
202 pub events: Vec<ReceiptEvent<serde_json::Value>>,
203}
204
205/// Groups a ledger read by the command that produced it, keeping append order,
206/// and carrying every erasure through.
207///
208/// This is how a caller regenerating a response from the journal — the readback
209/// ADR-012 point 6 requires — turns stored events into something a domain can
210/// render receipts from. Going through [`StoredEvent::to_committed`] instead
211/// would hand the domain a redacted event dressed as an intact one, with JSON
212/// `null` where its payload used to be, and the type-erasure boundary would
213/// then fail to deserialize it: a lost response where an honest receipt was
214/// available.
215///
216/// Order is the order of `events`, and a group appears where its first event
217/// did, so a regenerated response has the block order the original had.
218#[must_use]
219pub fn group_for_receipts(events: &[StoredEvent]) -> Vec<LedgerReceiptGroup> {
220 let mut groups: Vec<LedgerReceiptGroup> = Vec::new();
221 for event in events {
222 let existing = groups.iter().position(|group| {
223 group.case_key == event.case_key && group.command_id == event.command_id
224 });
225 match existing.and_then(|index| groups.get_mut(index)) {
226 Some(group) => group.events.push(event.to_receipt_event()),
227 None => groups.push(LedgerReceiptGroup {
228 case_key: event.case_key.clone(),
229 command_id: event.command_id,
230 revision: event.case_revision,
231 events: vec![event.to_receipt_event()],
232 }),
233 }
234 }
235 groups
236}
237
238/// A position in the journal's total order, for
239/// [`EventJournalReader::read_from`].
240///
241/// It is exclusive: a read `after` a cursor returns events strictly beyond it,
242/// so handing back the cursor of the page just consumed is exactly "continue
243/// where I stopped". [`EventCursor::START`] is before every event.
244///
245/// A cursor is a store-assigned sequence, so it is only meaningful against the
246/// store that issued it. Persist it next to whatever the consumer built from
247/// the events and the consumer resumes exactly, across restarts.
248#[derive(
249 Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize,
250)]
251#[serde(transparent)]
252pub struct EventCursor(pub u64);
253
254impl EventCursor {
255 /// Before the first event of the journal.
256 pub const START: Self = Self(0);
257
258 /// The cursor that resumes strictly after the event at `sequence`.
259 #[must_use]
260 pub const fn after(sequence: u64) -> Self {
261 Self(sequence)
262 }
263
264 /// The raw sequence this cursor sits on.
265 #[must_use]
266 pub const fn value(self) -> u64 {
267 self.0
268 }
269}
270
271impl std::fmt::Display for EventCursor {
272 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273 write!(f, "{}", self.0)
274 }
275}
276
277/// One page of [`EventJournalReader::read_from`].
278///
279/// `next_cursor` is where the following call must resume. It is the sequence of
280/// the last event in `events`, or the cursor that was asked for when the page
281/// is empty, so a consumer can store it unconditionally and never has to
282/// reason about the empty case.
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284pub struct EventPage {
285 /// The events, in sequence order, at most the requested limit.
286 pub events: Vec<StoredEvent>,
287 /// Where to resume. Never goes backwards.
288 pub next_cursor: EventCursor,
289}
290
291impl EventPage {
292 /// A page holding `events`, resuming after the last of them or at `asked`
293 /// when there are none.
294 #[must_use]
295 pub fn new(events: Vec<StoredEvent>, asked: EventCursor) -> Self {
296 let next_cursor = events
297 .last()
298 .map_or(asked, |event| EventCursor::after(event.sequence));
299 Self {
300 events,
301 next_cursor,
302 }
303 }
304
305 /// Returns `true` when the page carried no event, which is how a consumer
306 /// learns it has caught up.
307 #[must_use]
308 pub fn is_empty(&self) -> bool {
309 self.events.is_empty()
310 }
311}
312
313/// The read half of the append-only claim ledger (spec §22.1).
314///
315/// The ledger is the only thing an operational receipt may cite, so reading it
316/// is how a caller checks a claim; appending to it is a separate trait.
317#[async_trait]
318pub trait EventJournalReader: Send + Sync {
319 /// Events of one case with `case_revision > since`, in append order, at
320 /// most `limit`. `since = CaseRevision::ZERO` lists the whole history.
321 ///
322 /// This is the *history of a case*, and `limit` is a cap on the answer, not
323 /// a page boundary: a revision that produced several events can be cut in
324 /// half by it and the caller has no cursor to resume from, because the next
325 /// call can only name a revision again. To consume a stream exactly once,
326 /// use [`read_from`](EventJournalReader::read_from) instead; the module
327 /// documentation lays the two side by side.
328 ///
329 /// # Errors
330 /// * [`StoreError`] when the history could not be read.
331 async fn list_since(
332 &self,
333 account: &AccountId,
334 case_key: &CaseKey,
335 since: CaseRevision,
336 limit: usize,
337 ) -> Result<Vec<StoredEvent>, StoreError>;
338
339 /// The account's next events after `after` in the journal's total order,
340 /// across every case, at most `limit`, as an [`EventPage`].
341 ///
342 /// This is the exact-paging read. Start at [`EventCursor::START`], hand the
343 /// page's `next_cursor` back on the next call, and every event of the
344 /// account is delivered **once**, in commit order, however the journal grows
345 /// in between: appends land at higher sequences than any cursor already
346 /// issued, so they are seen by a later page and never re-order an earlier
347 /// one. An empty page means the consumer has caught up; the cursor stays
348 /// where it was and the call can simply be repeated later.
349 ///
350 /// `limit` bounds the page and nothing else. Events of other tenants
351 /// between two of this account's events are skipped without consuming it,
352 /// so a page is short only when the account has no more events, never
353 /// because a neighbour was noisy. A `limit` of zero is legal and returns an
354 /// empty page at the same cursor — which a consumer looping on
355 /// [`EventPage::is_empty`] would read as "caught up", so do not pass one.
356 ///
357 /// # Errors
358 /// * Whatever the backend raises; a page is never partial.
359 async fn read_from(
360 &self,
361 account: &AccountId,
362 after: EventCursor,
363 limit: usize,
364 ) -> Result<EventPage, StoreError>;
365
366 /// The events with the given identifiers that exist for `account`, in the
367 /// order requested; unknown or foreign identifiers are omitted. Receipt
368 /// verification reads events back through this method (ADR-012 point 6).
369 ///
370 /// # Errors
371 /// * [`StoreError`] when the events could not be read.
372 async fn get_by_ids(
373 &self,
374 account: &AccountId,
375 ids: &[EventId],
376 ) -> Result<Vec<StoredEvent>, StoreError>;
377
378 /// Number of events of a case.
379 ///
380 /// # Errors
381 /// * [`StoreError`] when the count could not be read.
382 async fn count(&self, account: &AccountId, case_key: &CaseKey) -> Result<u64, StoreError>;
383}
384
385/// The write half of the append-only claim ledger (spec §22.1).
386#[async_trait]
387pub trait EventJournalWriter: Send + Sync {
388 /// Appends a batch atomically and returns the event identifiers in order.
389 ///
390 /// # Errors
391 /// * `Other(INVALID_RECORD)` when the batch is empty.
392 /// * `Conflict` when any `event_id` already exists for the account; nothing
393 /// of the batch is written.
394 async fn append(&self, batch: EventBatch) -> Result<Vec<EventId>, StoreError>;
395
396 /// Erases one event's payload in place, and records that it happened.
397 ///
398 /// This is the ledger's only answer to an erasure obligation, and the whole
399 /// contract is in what it must **not** disturb (see the module
400 /// documentation). After it returns:
401 ///
402 /// * the event is still there, with the same
403 /// [`event_id`](StoredEvent::event_id), the same
404 /// [`event_type`](StoredEvent::event_type), the same
405 /// [`sequence`](StoredEvent::sequence), the same
406 /// [`case_revision`](StoredEvent::case_revision),
407 /// [`command_id`](StoredEvent::command_id) and
408 /// [`occurred_at`](StoredEvent::occurred_at);
409 /// * it is still returned by [`list_since`](EventJournalReader::list_since),
410 /// [`read_from`](EventJournalReader::read_from) and
411 /// [`get_by_ids`](EventJournalReader::get_by_ids), in the same position,
412 /// and still counted by [`count`](EventJournalReader::count);
413 /// * its [`payload`](StoredEvent::payload) is JSON `null` and its
414 /// [`redaction`](StoredEvent::redaction) is present, so
415 /// [`StoredEvent::to_receipt_event`] hands the domain a
416 /// [`ReceiptEvent::Redacted`](turnframe_core::event::ReceiptEvent);
417 /// * no other event is touched.
418 ///
419 /// **Implementing it as a delete is a contract violation**, not an
420 /// optimisation: it breaks every claim that rests on the event and silently
421 /// skips a position for every consumer paging by cursor.
422 ///
423 /// The record of the erasure is stored **on the event itself** rather than
424 /// in a log beside it, so one write makes both the erasure and its audit
425 /// trail, and a redacted payload without a record of who removed it is not
426 /// a state this store can be in. It carries the instant and the
427 /// [`RedactionAuthority`] and never what was removed. The store stamps the
428 /// instant from its own clock, because an erasure is timed by the system
429 /// that performed it.
430 ///
431 /// Repeating the call is a no-op that returns the **first** record: an
432 /// erasure request that is retried must not rewrite the history of who
433 /// erased what, and there is nothing left to erase the second time.
434 ///
435 /// # Errors
436 /// * `NotFound` when no event of `account` has that identifier. An event of
437 /// another tenant is `NotFound` too, indistinguishable from absent
438 /// (spec §25.4).
439 async fn redact_payload(
440 &self,
441 account: &AccountId,
442 event_id: &EventId,
443 authority: &RedactionAuthority,
444 ) -> Result<EventRedaction, StoreError>;
445}
446
447/// The append-only claim ledger (spec §22.1): both halves.
448///
449/// There is nothing to implement here: write [`EventJournalReader`] and
450/// [`EventJournalWriter`] and the blanket implementation below supplies this
451/// trait.
452pub trait EventJournal: EventJournalReader + EventJournalWriter {}
453
454impl<T: EventJournalReader + EventJournalWriter + ?Sized> EventJournal for T {}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 #[test]
461 fn batch_from_commit_erases_payloads() {
462 #[derive(Serialize)]
463 struct Ev {
464 n: u32,
465 }
466 let commit: Commit<(), Ev> = Commit {
467 state: None,
468 new_revision: CaseRevision(3),
469 events: vec![CommittedEvent {
470 event_id: EventId::nil(),
471 event_type: "t".into(),
472 occurred_at: DateTime::<Utc>::UNIX_EPOCH,
473 payload: Ev { n: 7 },
474 }],
475 idempotency_replay: false,
476 };
477 let batch = EventBatch::from_commit(
478 AccountId::from("a"),
479 CaseKey::new("w", "c"),
480 CommandId::nil(),
481 &commit,
482 )
483 .unwrap();
484 assert_eq!(batch.revision, CaseRevision(3));
485 assert_eq!(batch.events[0].payload, serde_json::json!({"n": 7}));
486 assert_eq!(batch.event_ids(), vec![EventId::nil()]);
487 assert!(!batch.is_empty());
488 }
489
490 #[test]
491 fn cursor_is_exclusive_and_pages_carry_where_to_resume() {
492 let event = |sequence: u64| StoredEvent {
493 sequence,
494 event_id: EventId::new(),
495 account_id: AccountId::from("a"),
496 case_key: CaseKey::new("w", "c"),
497 case_revision: CaseRevision(1),
498 command_id: CommandId::nil(),
499 event_type: "t".into(),
500 payload: serde_json::Value::Null,
501 occurred_at: DateTime::<Utc>::UNIX_EPOCH,
502 redaction: None,
503 };
504
505 assert_eq!(EventCursor::START, EventCursor(0));
506 assert_eq!(EventCursor::default(), EventCursor::START);
507 assert_eq!(EventCursor::after(7).value(), 7);
508 assert_eq!(EventCursor::after(7).to_string(), "7");
509
510 let page = EventPage::new(vec![event(4), event(5)], EventCursor::after(3));
511 assert!(!page.is_empty());
512 assert_eq!(
513 page.next_cursor,
514 EventCursor::after(5),
515 "resume after the last event of the page"
516 );
517
518 let caught_up = EventPage::new(Vec::new(), page.next_cursor);
519 assert!(caught_up.is_empty());
520 assert_eq!(
521 caught_up.next_cursor, page.next_cursor,
522 "an empty page must not move the cursor"
523 );
524
525 let json = serde_json::to_string(&page).unwrap();
526 assert!(json.contains("\"next_cursor\":5"), "{json}");
527 assert_eq!(serde_json::from_str::<EventPage>(&json).unwrap(), page);
528 }
529
530 /// A stored event with `payload`, redacted when `redaction` says so.
531 fn stored(sequence: u64, case: &str, command: CommandId, redacted: bool) -> StoredEvent {
532 StoredEvent {
533 sequence,
534 event_id: EventId::new(),
535 account_id: AccountId::from("a"),
536 case_key: CaseKey::new("w", case),
537 case_revision: CaseRevision(1),
538 command_id: command,
539 event_type: "t".into(),
540 payload: if redacted {
541 serde_json::Value::Null
542 } else {
543 serde_json::json!({ "full_name": "Marta Bianchi" })
544 },
545 occurred_at: DateTime::<Utc>::UNIX_EPOCH,
546 redaction: redacted.then(|| EventRedaction {
547 redacted_at: DateTime::<Utc>::UNIX_EPOCH,
548 authority: RedactionAuthority::from("erasure-request-1"),
549 }),
550 }
551 }
552
553 #[test]
554 fn a_redacted_event_reaches_a_receipt_renderer_as_redacted() {
555 let intact = stored(1, "c", CommandId::nil(), false);
556 assert!(!intact.is_redacted());
557 assert!(!intact.to_receipt_event().is_redacted());
558 assert_eq!(
559 intact.to_receipt_event().payload(),
560 Some(&serde_json::json!({ "full_name": "Marta Bianchi" }))
561 );
562
563 let erased = stored(2, "c", CommandId::nil(), true);
564 assert!(erased.is_redacted());
565 let event = erased.to_receipt_event();
566 assert!(event.is_redacted(), "the payload is gone and it says so");
567 assert_eq!(event.event_id(), erased.event_id, "identity survives");
568 assert_eq!(event.event_type(), "t", "the type survives");
569 assert_eq!(
570 event.occurred_at(),
571 erased.occurred_at,
572 "the instant survives"
573 );
574 assert_eq!(
575 event.redaction().map(|r| r.authority.as_str()),
576 Some("erasure-request-1")
577 );
578
579 // The record travels with the event through serialization, and an event
580 // written before the field existed reads back as intact.
581 let json = serde_json::to_string(&erased).expect("a stored event serializes");
582 assert_eq!(
583 serde_json::from_str::<StoredEvent>(&json).expect("and deserializes"),
584 erased
585 );
586 let older = serde_json::json!({
587 "sequence": 1,
588 "event_id": EventId::nil(),
589 "account_id": "a",
590 "case_key": { "workflow": "w", "case_id": "c" },
591 "case_revision": 1,
592 "command_id": CommandId::nil(),
593 "event_type": "t",
594 "payload": {},
595 "occurred_at": "1970-01-01T00:00:00Z",
596 });
597 let older: StoredEvent = serde_json::from_value(older).expect("a record without the field");
598 assert!(!older.is_redacted(), "no record means nothing was erased");
599 }
600
601 #[test]
602 fn grouping_a_ledger_read_keeps_order_and_carries_erasures() {
603 let (first, second) = (CommandId::new(), CommandId::new());
604 let events = vec![
605 stored(1, "c1", first, false),
606 stored(2, "c1", first, true),
607 stored(3, "c2", second, false),
608 // Back to the first command: the group it belongs to already
609 // exists, and it must not open a second one.
610 stored(4, "c1", first, false),
611 ];
612 let groups = group_for_receipts(&events);
613
614 assert_eq!(groups.len(), 2, "one group per command");
615 assert_eq!(groups[0].case_key, CaseKey::new("w", "c1"));
616 assert_eq!(groups[0].command_id, first);
617 assert_eq!(groups[0].events.len(), 3);
618 assert_eq!(groups[1].case_key, CaseKey::new("w", "c2"));
619 assert_eq!(groups[1].events.len(), 1);
620
621 assert_eq!(
622 groups[0]
623 .events
624 .iter()
625 .map(ReceiptEvent::is_redacted)
626 .collect::<Vec<_>>(),
627 vec![false, true, false],
628 "the erasure of the middle event survives the grouping"
629 );
630 assert_eq!(
631 groups[0]
632 .events
633 .iter()
634 .map(ReceiptEvent::event_id)
635 .collect::<Vec<_>>(),
636 vec![events[0].event_id, events[1].event_id, events[3].event_id],
637 "append order inside a group"
638 );
639 assert!(group_for_receipts(&[]).is_empty());
640 }
641
642 #[test]
643 fn stored_event_to_committed() {
644 let stored = StoredEvent {
645 sequence: 1,
646 event_id: EventId::nil(),
647 account_id: AccountId::from("a"),
648 case_key: CaseKey::new("w", "c"),
649 case_revision: CaseRevision(1),
650 command_id: CommandId::nil(),
651 event_type: "t".into(),
652 payload: serde_json::Value::Null,
653 occurred_at: DateTime::<Utc>::UNIX_EPOCH,
654 redaction: None,
655 };
656 let committed = stored.to_committed();
657 assert_eq!(committed.event_id, EventId::nil());
658 assert_eq!(committed.event_type, "t");
659 }
660}