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}