Skip to main content

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}