ruststream_sqlx/outbox/spec/builder.rs
1//! `OutboxSpec`, the typed builder of an outbox table's description, and `OutboxTable`, the trait a
2//! record described by hand implements.
3
4use std::fmt::{self, Debug, Display, Formatter};
5use std::marker::PhantomData;
6use std::str::FromStr;
7
8use ruststream_sqlx_dialect::{Column, Form, TableSpec};
9
10use super::{Declaration, Headers, OwnEvent, ProcessedAt, Push};
11
12/// An outbox record described by hand: the trait `#[derive(Outbox)]` implements, and what a
13/// service implements instead of the derive.
14///
15/// `TABLE` describes the table with [`OutboxSpec`], and `type Table` names the builder's final
16/// type; the compiler holds the chain to it. The registry builds the default statements from
17/// `TABLE` once, when the record type is registered, and runs the events the type names with
18/// [`OutboxSpec::own`] from the record's own impls. The record reads its row through
19/// `sqlx::FromRow`, and holds only the columns it reads.
20///
21/// # Examples
22///
23/// ```
24/// # #[cfg(all(feature = "outbox", feature = "postgres"))]
25/// # mod demo {
26/// # use ruststream::OutgoingMessage;
27/// # use ruststream::memory::prelude::*;
28/// # use serde::{Deserialize, Serialize};
29/// # #[derive(Deserialize)] pub struct PlaceOrder { id: u64 }
30/// # #[derive(Serialize, Deserialize, Outgoing)] #[outgoing(name = "orders")] pub struct OrderPlaced { id: u64 }
31/// # #[subscriber("checkout", reply)] async fn place(cmd: &PlaceOrder) -> OrderPlaced { OrderPlaced { id: cmd.id } }
32/// # #[subscriber("orders")] async fn fulfil(_: &OrderPlaced) -> HandlerOutcome { HandlerOutcome::ack() }
33/// use ruststream_sqlx::dialect::Column;
34/// use ruststream_sqlx::outbox::spec::ProcessedAt;
35/// use ruststream_sqlx::outbox::{self, Outbox, OutboxSpec, OutboxTable, TrackedName};
36/// use sqlx::{PgConnection, PgPool, Postgres};
37///
38/// #[derive(sqlx::FromRow)]
39/// pub struct OrderEvent {
40/// id: i64,
41/// name: String,
42/// payload: Vec<u8>,
43/// }
44///
45/// impl OutboxTable for OrderEvent {
46/// type Id = i64;
47/// type Table = OutboxSpec<(ProcessedAt,)>;
48/// const TABLE: Self::Table = OutboxSpec::new(
49/// "outbox",
50/// Column::new("id"),
51/// Column::new("name"),
52/// Column::new("payload"),
53/// )
54/// .processed_at(Column::new("processed_at"));
55///
56/// fn id(&self) -> &i64 {
57/// &self.id
58/// }
59///
60/// fn name(&self) -> &str {
61/// &self.name
62/// }
63///
64/// fn payload(&self) -> &[u8] {
65/// &self.payload
66/// }
67/// }
68///
69/// impl outbox::Publish<Postgres> for OrderEvent {
70/// async fn publish(conn: &mut PgConnection, msg: &OutgoingMessage<'_>) -> sqlx::Result<i64> {
71/// sqlx::query_scalar("INSERT INTO outbox (name, payload) VALUES ($1, $2) RETURNING id")
72/// .bind(msg.name())
73/// .bind(msg.payload())
74/// .fetch_one(conn)
75/// .await
76/// }
77/// }
78///
79/// pub struct Orders;
80///
81/// impl TrackedName for Orders {
82/// const NAME: &'static str = "orders";
83/// }
84///
85/// pub fn app(pool: PgPool) -> impl App {
86/// let tracking = Outbox::new(pool).track::<OrderEvent, Orders>();
87/// RustStream::new(AppInfo::new("orders", "0.1.0"))
88/// .layer(tracking.layer())
89/// .publish_layer(tracking.publish_layer())
90/// .with_broker(MemoryBroker::new(), |b| {
91/// b.include(place).out_reply(Publish);
92/// b.include(fulfil);
93/// b.after_startup(Publish, tracking.republish());
94/// })
95/// }
96/// # }
97/// # fn main() {}
98/// ```
99#[diagnostic::on_unimplemented(
100 message = "`{Self}` is not an outbox record",
101 label = "this type does not describe an outbox table",
102 note = "derive `Outbox` for `{Self}` and mark its `id`, `name` and `payload` fields, or \
103 implement `OutboxTable` for it"
104)]
105pub trait OutboxTable: Sized + Send + Sync + Unpin + 'static {
106 /// The type of the column that identifies a record: the value the id header carries.
107 type Id: Display + FromStr + Send + Sync + 'static;
108
109 /// The table's description as a type: `OutboxSpec<(..)>`, listing the markers the chain of
110 /// `TABLE` sets, in the order it sets them.
111 type Table: Described;
112
113 /// The table's description.
114 const TABLE: Self::Table;
115
116 /// The record's id.
117 fn id(&self) -> &Self::Id;
118
119 /// The name the record was published under.
120 fn name(&self) -> &str;
121
122 /// The published payload.
123 fn payload(&self) -> &[u8];
124}
125
126/// The description of an outbox table, with its settings as types.
127///
128/// `new` names the table and its three required columns: the id, the name a record was published
129/// under, and the payload. The column-only setters (`within`, `data`, `selecting_all`) keep the
130/// type. Each typed setter (`headers`, `processed_at`, `own`) adds its marker of
131/// [`outbox::spec`](super) to the type, and a setting set twice does not compile. The outbox reads
132/// the database's clock. Every setter is a `const fn`.
133///
134/// # Examples
135///
136/// ```
137/// # #[cfg(feature = "outbox")]
138/// # mod demo {
139/// use ruststream_sqlx::dialect::Column;
140/// use ruststream_sqlx::outbox::OutboxSpec;
141/// use ruststream_sqlx::outbox::spec::{Headers, ProcessedAt, own};
142///
143/// /// An outbox in the `shop` schema whose records keep their headers, are marked when
144/// /// processed, and are recovered by the service's own statement.
145/// pub const SHOP_OUTBOX: OutboxSpec<(Headers, ProcessedAt, own::Recover)> = OutboxSpec::new(
146/// "outbox",
147/// Column::new("id"),
148/// Column::new("topic"),
149/// Column::new("body"),
150/// )
151/// .within("shop")
152/// .headers(Column::new("headers"))
153/// .processed_at(Column::new("processed_at"))
154/// .own::<own::Recover>();
155/// # }
156/// # fn main() {}
157/// ```
158pub struct OutboxSpec<Settings = ()> {
159 spec: TableSpec<'static>,
160 settings: PhantomData<fn() -> Settings>,
161}
162
163// By hand: a derive would require each marker of `Settings` to implement the trait, and the
164// markers are never values.
165impl<Settings> Debug for OutboxSpec<Settings> {
166 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
167 formatter
168 .debug_struct("OutboxSpec")
169 .field("spec", &self.spec)
170 .finish_non_exhaustive()
171 }
172}
173
174impl<Settings> Clone for OutboxSpec<Settings> {
175 fn clone(&self) -> Self {
176 *self
177 }
178}
179
180impl<Settings> Copy for OutboxSpec<Settings> {}
181
182impl<Settings> PartialEq for OutboxSpec<Settings> {
183 fn eq(&self, other: &Self) -> bool {
184 self.spec == other.spec
185 }
186}
187
188impl<Settings> Eq for OutboxSpec<Settings> {}
189
190impl OutboxSpec {
191 /// The outbox table `table`, whose records are identified by `id`, published under the name
192 /// in `name`, with the bytes in `payload`.
193 ///
194 /// # Examples
195 ///
196 /// ```
197 /// # #[cfg(feature = "outbox")]
198 /// # mod demo {
199 /// use ruststream_sqlx::dialect::Column;
200 /// use ruststream_sqlx::outbox::OutboxSpec;
201 ///
202 /// /// The plainest outbox: a processed record is deleted.
203 /// pub const OUTBOX: OutboxSpec = OutboxSpec::new(
204 /// "outbox",
205 /// Column::new("id"),
206 /// Column::new("name"),
207 /// Column::new("payload"),
208 /// );
209 /// # }
210 /// # fn main() {}
211 /// ```
212 #[must_use]
213 pub const fn new(
214 table: &'static str,
215 id: Column<'static>,
216 name: Column<'static>,
217 payload: Column<'static>,
218 ) -> Self {
219 Self {
220 spec: TableSpec::new(table, id, Form::RowLock)
221 .group(name)
222 .payload(payload)
223 .database_clock(),
224 settings: PhantomData,
225 }
226 }
227}
228
229impl<Settings> OutboxSpec<Settings> {
230 /// The table's description, as the dialects read it.
231 ///
232 /// # Examples
233 ///
234 /// ```
235 /// # #[cfg(all(feature = "outbox", feature = "postgres"))]
236 /// # mod demo {
237 /// use ruststream_sqlx::dialect::{Column, OutboxDialect, Postgres, StatementError};
238 /// use ruststream_sqlx::outbox::OutboxSpec;
239 ///
240 /// const OUTBOX: OutboxSpec = OutboxSpec::new(
241 /// "outbox",
242 /// Column::new("id"),
243 /// Column::new("name"),
244 /// Column::new("payload"),
245 /// );
246 ///
247 /// /// The statement the outbox's default recovery runs on Postgres.
248 /// pub fn recovery() -> Result<String, StatementError> {
249 /// Ok(Postgres.outbox_recover(&OUTBOX.spec())?.sql().to_owned())
250 /// }
251 /// # }
252 /// # fn main() {}
253 /// ```
254 #[must_use]
255 pub const fn spec(&self) -> TableSpec<'static> {
256 self.spec
257 }
258
259 /// The table in `schema`.
260 ///
261 /// # Examples
262 ///
263 /// ```
264 /// # #[cfg(feature = "outbox")]
265 /// # mod demo {
266 /// use ruststream_sqlx::dialect::Column;
267 /// use ruststream_sqlx::outbox::OutboxSpec;
268 ///
269 /// pub const OUTBOX: OutboxSpec = OutboxSpec::new(
270 /// "outbox",
271 /// Column::new("id"),
272 /// Column::new("name"),
273 /// Column::new("payload"),
274 /// )
275 /// .within("shop");
276 /// # }
277 /// # fn main() {}
278 /// ```
279 #[must_use]
280 pub const fn within(self, schema: &'static str) -> Self {
281 Self {
282 spec: self.spec.within(schema),
283 ..self
284 }
285 }
286
287 /// The columns the record reads besides its roles; the default fetch and recovery select them.
288 ///
289 /// # Examples
290 ///
291 /// ```
292 /// # #[cfg(feature = "outbox")]
293 /// # mod demo {
294 /// use ruststream_sqlx::dialect::Column;
295 /// use ruststream_sqlx::outbox::OutboxSpec;
296 ///
297 /// /// A record that also reads when it was created.
298 /// pub const OUTBOX: OutboxSpec = OutboxSpec::new(
299 /// "outbox",
300 /// Column::new("id"),
301 /// Column::new("name"),
302 /// Column::new("payload"),
303 /// )
304 /// .data(&[Column::new("created_at")]);
305 /// # }
306 /// # fn main() {}
307 /// ```
308 #[must_use]
309 pub const fn data(self, columns: &'static [Column<'static>]) -> Self {
310 Self {
311 spec: self.spec.data(columns),
312 ..self
313 }
314 }
315
316 /// The default fetch and recovery select every column (`SELECT *`): for a record whose
317 /// columns the description cannot list, such as one that flattens another struct.
318 ///
319 /// # Examples
320 ///
321 /// ```
322 /// # #[cfg(feature = "outbox")]
323 /// # mod demo {
324 /// use ruststream_sqlx::dialect::Column;
325 /// use ruststream_sqlx::outbox::OutboxSpec;
326 ///
327 /// pub const OUTBOX: OutboxSpec = OutboxSpec::new(
328 /// "outbox",
329 /// Column::new("id"),
330 /// Column::new("name"),
331 /// Column::new("payload"),
332 /// )
333 /// .selecting_all();
334 /// # }
335 /// # fn main() {}
336 /// ```
337 #[must_use]
338 pub const fn selecting_all(self) -> Self {
339 Self {
340 spec: self.spec.selecting_all(),
341 ..self
342 }
343 }
344
345 /// The column the published headers are kept in. The record implements
346 /// [`HeaderRow`](crate::HeaderRow), and a republished record carries them again.
347 ///
348 /// # Examples
349 ///
350 /// ```
351 /// # #[cfg(feature = "outbox")]
352 /// # mod demo {
353 /// use ruststream_sqlx::dialect::Column;
354 /// use ruststream_sqlx::outbox::OutboxSpec;
355 /// use ruststream_sqlx::outbox::spec::Headers;
356 ///
357 /// pub const OUTBOX: OutboxSpec<(Headers,)> = OutboxSpec::new(
358 /// "outbox",
359 /// Column::new("id"),
360 /// Column::new("name"),
361 /// Column::new("payload"),
362 /// )
363 /// .headers(Column::new("headers"));
364 /// # }
365 /// # fn main() {}
366 /// ```
367 #[must_use]
368 pub const fn headers(
369 self,
370 column: Column<'static>,
371 ) -> OutboxSpec<<Settings as Push<Headers>>::Out>
372 where
373 Settings: Push<Headers>,
374 <Settings as Push<Headers>>::Out: Declaration,
375 {
376 Self::with(&self.spec.headers(column))
377 }
378
379 /// The column the default acknowledgement writes the database's current time to; the default
380 /// fetch and recovery read only the records where it is `NULL`. Without it a processed
381 /// record is deleted.
382 ///
383 /// # Examples
384 ///
385 /// ```
386 /// # #[cfg(feature = "outbox")]
387 /// # mod demo {
388 /// use ruststream_sqlx::dialect::Column;
389 /// use ruststream_sqlx::outbox::OutboxSpec;
390 /// use ruststream_sqlx::outbox::spec::ProcessedAt;
391 ///
392 /// pub const OUTBOX: OutboxSpec<(ProcessedAt,)> = OutboxSpec::new(
393 /// "outbox",
394 /// Column::new("id"),
395 /// Column::new("name"),
396 /// Column::new("payload"),
397 /// )
398 /// .processed_at(Column::new("processed_at"));
399 /// # }
400 /// # fn main() {}
401 /// ```
402 #[must_use]
403 pub const fn processed_at(
404 self,
405 column: Column<'static>,
406 ) -> OutboxSpec<<Settings as Push<ProcessedAt>>::Out>
407 where
408 Settings: Push<ProcessedAt>,
409 <Settings as Push<ProcessedAt>>::Out: Declaration,
410 {
411 Self::with(&self.spec.processed_at(column))
412 }
413
414 /// The record runs `Event` itself, through its impl of the event's trait of
415 /// [`outbox`](mod@crate::outbox), instead of the crate's default; a record that names the event
416 /// without the impl is not registered.
417 ///
418 /// # Examples
419 ///
420 /// ```
421 /// # #[cfg(all(feature = "outbox", feature = "postgres"))]
422 /// # mod demo {
423 /// # use ruststream::OutgoingMessage;
424 /// use ruststream_sqlx::dialect::Column;
425 /// use ruststream_sqlx::outbox::spec::{ProcessedAt, own};
426 /// use ruststream_sqlx::outbox::{self, OutboxSpec, OutboxTable};
427 /// use sqlx::{PgConnection, Postgres};
428 ///
429 /// #[derive(sqlx::FromRow)]
430 /// pub struct OrderEvent {
431 /// id: i64,
432 /// name: String,
433 /// payload: Vec<u8>,
434 /// }
435 ///
436 /// impl OutboxTable for OrderEvent {
437 /// type Id = i64;
438 /// type Table = OutboxSpec<(ProcessedAt, own::Retry)>;
439 /// const TABLE: Self::Table = OutboxSpec::new(
440 /// "outbox",
441 /// Column::new("id"),
442 /// Column::new("name"),
443 /// Column::new("payload"),
444 /// )
445 /// .processed_at(Column::new("processed_at"))
446 /// .own::<own::Retry>();
447 ///
448 /// fn id(&self) -> &i64 {
449 /// &self.id
450 /// }
451 ///
452 /// fn name(&self) -> &str {
453 /// &self.name
454 /// }
455 ///
456 /// fn payload(&self) -> &[u8] {
457 /// &self.payload
458 /// }
459 /// }
460 /// # impl outbox::Publish<Postgres> for OrderEvent {
461 /// # async fn publish(conn: &mut PgConnection, msg: &OutgoingMessage<'_>) -> sqlx::Result<i64> {
462 /// # sqlx::query_scalar("INSERT INTO outbox (name, payload) VALUES ($1, $2) RETURNING id")
463 /// # .bind(msg.name()).bind(msg.payload()).fetch_one(conn).await
464 /// # }
465 /// # }
466 ///
467 /// // The service counts how often a consumer asked for a record again.
468 /// impl outbox::Retry<Postgres> for OrderEvent {
469 /// async fn retry(conn: &mut PgConnection, id: &i64) -> sqlx::Result<()> {
470 /// sqlx::query("UPDATE outbox SET retries = retries + 1 WHERE id = $1")
471 /// .bind(id)
472 /// .execute(conn)
473 /// .await?;
474 /// Ok(())
475 /// }
476 /// }
477 /// # }
478 /// # fn main() {}
479 /// ```
480 #[must_use]
481 pub const fn own<Event: OwnEvent>(self) -> OutboxSpec<<Settings as Push<Event>>::Out>
482 where
483 Settings: Push<Event>,
484 <Settings as Push<Event>>::Out: Declaration,
485 {
486 Self::with(&self.spec)
487 }
488
489 const fn with<Next>(spec: &TableSpec<'static>) -> OutboxSpec<Next> {
490 OutboxSpec {
491 spec: *spec,
492 settings: PhantomData,
493 }
494 }
495}
496
497/// An outbox table's description: [`OutboxSpec`], with settings that fold into one
498/// [`Declaration`]. Machinery behind [`OutboxTable::Table`].
499#[diagnostic::on_unimplemented(
500 message = "`{Self}` is not the description of an outbox table",
501 label = "not an `OutboxSpec`",
502 note = "describe the table with `OutboxSpec::new(..)` and name the chain's type: `type Table = \
503 OutboxSpec<(..)>`"
504)]
505pub trait Described: Copy + Send + Sync + 'static {
506 /// The settings, folded.
507 type Declared: Declaration;
508
509 /// The table's description, as the dialects read it.
510 fn spec(&self) -> TableSpec<'static>;
511}
512
513impl<Settings: Declaration + 'static> Described for OutboxSpec<Settings> {
514 type Declared = Settings;
515
516 fn spec(&self) -> TableSpec<'static> {
517 self.spec
518 }
519}