Skip to main content

cratestack_sqlx/descriptor/
event_enqueue.rs

1//! The enqueue helpers each write path calls in-transaction: one
2//! `cratestack_event_outbox` row, plus the subscription backplane's
3//! notification. Split from [`super::event_outbox`] (the row shape and the
4//! table) to keep both under the ~200-line-per-file convention.
5
6use cratestack_core::{
7    CratestackError, CratestackEventEnvelope, ModelEventKind, model_event_channel,
8};
9
10use super::OutboxColumn;
11use crate::backplane::notify_payload;
12use crate::error::cratestack_error_from_sqlx;
13use crate::sqlx;
14
15/// Inserts one `cratestack_event_outbox` row. `pub` for the same reason
16/// as [`ensure_event_outbox_table`] — see its doc comment.
17///
18/// `model` takes `&str` rather than the `&'static str` every in-crate
19/// caller happens to pass (generated code's model names are always
20/// `&'static str` literals): `cratestack-studio` parses `.cstack`
21/// schemas at runtime, so its model names are owned `String`s with no
22/// `'static` lifetime available. The function only ever borrows `model`
23/// long enough to bind it as a query parameter, so relaxing the bound
24/// costs nothing for the existing callers and is required for the new
25/// one.
26pub async fn enqueue_event_outbox<'e, E, T>(
27    executor: E,
28    model: &str,
29    operation: ModelEventKind,
30    data: &T,
31) -> Result<(), CratestackError>
32where
33    E: sqlx::Executor<'e, Database = sqlx::Postgres>,
34    T: serde::Serialize,
35{
36    // A caller outside the generated write paths (Studio) cannot know whether
37    // the model is `@@subscribe`, so it always notifies.
38    enqueue(executor, model, true, operation, data, None).await
39}
40
41/// The generated write paths' enqueue: notifies subscribers only for an
42/// `@@subscribe` model (`target.notify_subscribers`).
43pub(crate) async fn enqueue_model_event<'e, E, T>(
44    executor: E,
45    target: cratestack_sql::EventTarget,
46    operation: ModelEventKind,
47    data: &T,
48) -> Result<(), CratestackError>
49where
50    E: sqlx::Executor<'e, Database = sqlx::Postgres>,
51    T: serde::Serialize,
52{
53    enqueue(
54        executor,
55        target.model,
56        target.notify_subscribers,
57        operation,
58        data,
59        None,
60    )
61    .await
62}
63
64/// [`enqueue_model_event`] for an update, carrying the row as it was before
65/// the write when `column` says the outbox can store it
66/// ([`crate::SqlxRuntime::keeps_before`]): a subscriber's filter-exit event
67/// needs it, `docs/design/subscriptions-websocket.md` §5.3.
68pub(crate) async fn enqueue_model_update<'e, E, T>(
69    executor: E,
70    target: cratestack_sql::EventTarget,
71    data: &T,
72    before: Option<&T>,
73    column: Option<&OutboxColumn>,
74) -> Result<(), CratestackError>
75where
76    E: sqlx::Executor<'e, Database = sqlx::Postgres>,
77    T: serde::Serialize,
78{
79    let before = before.filter(|_| column.is_some());
80    let result = enqueue(
81        executor,
82        target.model,
83        target.notify_subscribers,
84        ModelEventKind::Updated,
85        data,
86        before,
87    )
88    .await;
89    // An undefined column: what said the table had it was stale.
90    if let (Some(column), Err(error)) = (column, &result)
91        && before.is_some()
92        && error.db_sqlstate() == Some("42703")
93    {
94        column.forget();
95    }
96    result
97}
98
99/// The row, and the backplane notification, in one statement: Postgres
100/// delivers the notification only if the transaction commits.
101const INSERT: &str = "WITH inserted AS (\
102        INSERT INTO cratestack_event_outbox (event_id, model, operation, occurred_at, payload) \
103        VALUES ($1, $2, $3, $4, $5) RETURNING event_id\
104     ) SELECT pg_notify($6, $7) FROM inserted WHERE $7 <> ''";
105const INSERT_WITH_BEFORE: &str = "WITH inserted AS (\
106        INSERT INTO cratestack_event_outbox \
107            (event_id, model, operation, occurred_at, payload, payload_before) \
108        VALUES ($1, $2, $3, $4, $5, $8) RETURNING event_id\
109     ) SELECT pg_notify($6, $7) FROM inserted WHERE $7 <> ''";
110
111async fn enqueue<'e, E, T>(
112    executor: E,
113    model: &str,
114    notify: bool,
115    operation: ModelEventKind,
116    data: &T,
117    before: Option<&T>,
118) -> Result<(), CratestackError>
119where
120    E: sqlx::Executor<'e, Database = sqlx::Postgres>,
121    T: serde::Serialize,
122{
123    let encode = |value: &T| {
124        serde_json::to_value(value).map_err(|error| {
125            CratestackError::Codec(format!("failed to encode event payload: {error}"))
126        })
127    };
128    let payload = encode(data)?;
129    let envelope = CratestackEventEnvelope {
130        event_id: uuid::Uuid::new_v4(),
131        model: model.to_owned(),
132        operation,
133        occurred_at: chrono::Utc::now(),
134        data: payload,
135        before: before.map(encode).transpose()?,
136    };
137    // The subscription backplane's notification, sent by this same statement
138    // so Postgres delivers it only if the transaction commits
139    // (docs/design/subscriptions-websocket.md §4.3). Nobody listening costs
140    // nothing; a `PgNotifyBackplane` listener on any replica receives it.
141    let envelope_json = serde_json::to_string(&envelope).map_err(|error| {
142        CratestackError::Codec(format!("failed to encode event envelope: {error}"))
143    })?;
144    let notification = if notify {
145        notify_payload::encode(
146            &model_event_channel(model),
147            &envelope_json,
148            Some(envelope.event_id),
149        )
150        .unwrap_or_default()
151    } else {
152        String::new()
153    };
154    // Without a pre-image the statement leaves `payload_before` out, so an
155    // outbox table not yet upgraded still takes every other event.
156    let query = sqlx::query(if envelope.before.is_some() {
157        INSERT_WITH_BEFORE
158    } else {
159        INSERT
160    })
161    .bind(envelope.event_id)
162    .bind(model)
163    .bind(operation.as_str())
164    .bind(envelope.occurred_at)
165    .bind(envelope.data)
166    .bind(notify_payload::NOTIFY_CHANNEL)
167    .bind(notification);
168    let query = match envelope.before {
169        Some(before) => query.bind(before),
170        None => query,
171    };
172    query
173        .execute(executor)
174        .await
175        .map_err(cratestack_error_from_sqlx)?;
176
177    Ok(())
178}