Skip to main content

distributed/repository/
traits.rs

1use std::future::Future;
2
3use crate::entity::{Entity, EventRecord};
4use crate::outbox::OutboxMessage;
5use crate::read_model::{ReadModelLoadGraph, ReadModelLoadRequest, ReadModelQueryCapabilities};
6use crate::snapshot::SnapshotRecord;
7use crate::table::{TableAdapterCapabilities, TableCommitOutcome, TableStoreError, TableWritePlan};
8
9use super::inbox::InboxReceipt;
10use super::{RepositoryError, StreamIdentity};
11
12/// One aggregate event stream staged for an async transactional commit.
13pub struct StreamWrite<'a> {
14    pub identity: StreamIdentity,
15    pub entity: &'a mut Entity,
16}
17
18impl<'a> StreamWrite<'a> {
19    pub fn new(identity: StreamIdentity, entity: &'a mut Entity) -> Self {
20        Self { identity, entity }
21    }
22}
23
24/// Snapshot writes staged in an async transactional commit.
25#[derive(Clone, Debug)]
26pub enum SnapshotWrite {
27    Save {
28        identity: StreamIdentity,
29        record: SnapshotRecord,
30    },
31}
32
33/// A structured async write batch that must commit under one backend transaction.
34pub struct CommitBatch<'a> {
35    pub streams: Vec<StreamWrite<'a>>,
36    pub outbox_messages: Vec<OutboxMessage>,
37    pub read_model_plans: Vec<TableWritePlan>,
38    pub snapshots: Vec<SnapshotWrite>,
39    /// Consumer inbox receipts to record in the same transaction (the optional
40    /// effectively-once effect fence). Empty for the default idempotent path.
41    pub inbox_receipts: Vec<InboxReceipt>,
42}
43
44impl<'a> CommitBatch<'a> {
45    pub fn new(streams: Vec<StreamWrite<'a>>) -> Self {
46        Self {
47            streams,
48            outbox_messages: Vec::new(),
49            read_model_plans: Vec::new(),
50            snapshots: Vec::new(),
51            inbox_receipts: Vec::new(),
52        }
53    }
54
55    pub fn empty() -> Self {
56        Self::new(Vec::new())
57    }
58}
59
60/// Append data prepared from a borrowed stream write before async I/O. Events
61/// are borrowed from the staged entity — backends bind them by reference, so
62/// preparing a batch never clones event payloads.
63#[derive(Clone, Debug)]
64pub struct PreparedEventAppend<'a> {
65    pub identity: StreamIdentity,
66    pub expected_version: u64,
67    pub events: &'a [EventRecord],
68}
69
70impl<'a> PreparedEventAppend<'a> {
71    pub fn from_stream_write(write: &'a StreamWrite<'_>) -> Self {
72        Self {
73            identity: write.identity.clone(),
74            expected_version: write.entity.committed_version(),
75            events: write.entity.new_events(),
76        }
77    }
78}
79
80/// Stream-aware aggregate loading.
81pub trait GetStream: Send + Sync {
82    fn get_stream<'a>(
83        &'a self,
84        identity: &'a StreamIdentity,
85    ) -> impl Future<Output = Result<Option<Entity>, RepositoryError>> + Send + 'a;
86
87    /// Load the streams for the provided identities, skipping missing ones.
88    ///
89    /// The default loads each stream with [`get_stream`], which is always
90    /// correct, just one round trip per identity. Backends with a queryable
91    /// store (Postgres, SQLite) override this with a single grouped query.
92    /// Backends may return entities in storage order rather than input order.
93    ///
94    /// [`get_stream`]: GetStream::get_stream
95    fn get_streams<'a>(
96        &'a self,
97        identities: &'a [StreamIdentity],
98    ) -> impl Future<Output = Result<Vec<Entity>, RepositoryError>> + Send + 'a {
99        async move {
100            let mut entities = Vec::with_capacity(identities.len());
101            for identity in identities {
102                if let Some(entity) = self.get_stream(identity).await? {
103                    entities.push(entity);
104                }
105            }
106            Ok(entities)
107        }
108    }
109
110    /// Load only the events with `sequence > after_version` as a tail-only
111    /// [`Entity`] (see [`Entity::load_tail_from_history`]).
112    ///
113    /// This is the I/O half of snapshot loading: a snapshot covers events up to
114    /// its version, so only the tail must be fetched and decoded. Backends with
115    /// a queryable store (Postgres, SQLite) override this with a
116    /// `WHERE sequence > $after_version` read; the default delegates to
117    /// [`get_stream`], which loads the full history and is always correct, just
118    /// not optimized.
119    ///
120    /// The returned entity's `version`/`committed_version` reflect the true
121    /// persisted stream position (`after_version + tail.len()`), not the tail
122    /// length, so optimistic concurrency and `new_events()` stay correct.
123    ///
124    /// [`get_stream`]: GetStream::get_stream
125    fn get_stream_tail<'a>(
126        &'a self,
127        identity: &'a StreamIdentity,
128        after_version: u64,
129    ) -> impl Future<Output = Result<Option<Entity>, RepositoryError>> + Send + 'a {
130        // Default: no tail optimization. Loading the full history yields the
131        // same hydrated aggregate; only the I/O is heavier.
132        let _ = after_version;
133        self.get_stream(identity)
134    }
135}
136
137/// Transactional commit capability for durable persistence backends.
138pub trait TransactionalCommit: Send + Sync {
139    fn commit_batch<'a>(
140        &'a self,
141        batch: CommitBatch<'a>,
142    ) -> impl Future<Output = Result<(), RepositoryError>> + Send + 'a;
143}
144
145/// Consumer inbox read capability: check whether a `(consumer, message_id)`
146/// receipt has already been recorded.
147///
148/// The pre-check lets a consumer skip re-running a handler for an already-processed
149/// message (and ack the redelivery) before opening a transaction. The
150/// authoritative dedupe is still the receipt's `(consumer, message_id)` primary
151/// key written in [`commit_batch`](TransactionalCommit::commit_batch),
152/// which fences the race where two deliveries both pass the pre-check.
153pub trait InboxStore: Send + Sync {
154    fn inbox_contains<'a>(
155        &'a self,
156        consumer: &'a str,
157        message_id: &'a str,
158    ) -> impl Future<Output = Result<bool, RepositoryError>> + Send + 'a;
159
160    /// Purge inbox receipts older than `age`, returning the number removed.
161    ///
162    /// The consumer inbox grows by one row per processed message and has no
163    /// built-in TTL: **retention is the operator's responsibility.** Once a
164    /// transport's own redelivery window has passed, an old receipt can never
165    /// gate a replay again, so it is safe to delete. Call this periodically
166    /// (e.g. a cron/maintenance task) with an `age` comfortably larger than the
167    /// broker's maximum redelivery/visibility window.
168    ///
169    /// Age is evaluated against the **database clock**, not the caller's, so
170    /// there is no client/server skew. SQL backends issue a single bounded
171    /// `DELETE`; the in-memory store keeps no timestamps and treats any positive
172    /// `age` as a no-op (see [`InMemoryRepository`](crate::InMemoryRepository),
173    /// whose inbox is dev-only).
174    fn purge_inbox_older_than(
175        &self,
176        age: std::time::Duration,
177    ) -> impl Future<Output = Result<u64, RepositoryError>> + Send;
178}
179
180/// Repository trait for types that implement stream reads and commits.
181pub trait Repository: GetStream + TransactionalCommit {}
182
183impl<T> Repository for T where T: GetStream + TransactionalCommit {}
184
185/// Adapter contract for committing read-model write plans.
186pub trait ReadModelWritePlanStore: Send + Sync {
187    fn read_model_capabilities(&self) -> TableAdapterCapabilities;
188
189    fn commit_write_plan(
190        &self,
191        plan: TableWritePlan,
192    ) -> impl Future<Output = Result<TableCommitOutcome, TableStoreError>> + Send + '_;
193}
194
195/// Primary-key relational read-model query contract.
196pub trait RelationalReadModelQueryStore: Send + Sync {
197    fn read_model_query_capabilities(&self) -> ReadModelQueryCapabilities;
198
199    fn load_graph(
200        &self,
201        request: ReadModelLoadRequest,
202    ) -> impl Future<Output = Result<ReadModelLoadGraph, TableStoreError>> + Send + '_;
203}
204
205/// Snapshot persistence keyed by full stream identity.
206pub trait SnapshotStore: Send + Sync {
207    fn get_snapshot<'a>(
208        &'a self,
209        identity: &'a StreamIdentity,
210    ) -> impl Future<Output = Result<Option<SnapshotRecord>, RepositoryError>> + Send + 'a;
211
212    /// Load the snapshots for the provided identities, skipping identities
213    /// without one. Each returned record carries its own aggregate type/id, so
214    /// callers can pair records back to identities.
215    ///
216    /// The default loads each snapshot with [`get_snapshot`], which is always
217    /// correct, just one round trip per identity. Backends with a queryable
218    /// store (Postgres, SQLite) override this with a single grouped query.
219    ///
220    /// [`get_snapshot`]: SnapshotStore::get_snapshot
221    fn get_snapshots<'a>(
222        &'a self,
223        identities: &'a [StreamIdentity],
224    ) -> impl Future<Output = Result<Vec<SnapshotRecord>, RepositoryError>> + Send + 'a {
225        async move {
226            let mut records = Vec::with_capacity(identities.len());
227            for identity in identities {
228                if let Some(record) = self.get_snapshot(identity).await? {
229                    records.push(record);
230                }
231            }
232            Ok(records)
233        }
234    }
235
236    fn save_snapshot<'a>(
237        &'a self,
238        identity: &'a StreamIdentity,
239        record: SnapshotRecord,
240    ) -> impl Future<Output = Result<(), RepositoryError>> + Send + 'a;
241
242    fn delete_snapshot<'a>(
243        &'a self,
244        identity: &'a StreamIdentity,
245    ) -> impl Future<Output = Result<bool, RepositoryError>> + Send + 'a;
246}