Skip to main content

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}