#[cfg(feature = "outbox")]
pub mod publisher;
#[cfg(feature = "outbox")]
pub mod publishers;
#[cfg(feature = "outbox")]
pub mod worker;
#[cfg(feature = "outbox")]
pub use publisher::{PublishError, Publisher};
#[cfg(feature = "outbox")]
pub use worker::OutboxRow;
use crate::DjogiError;
use crate::context::DjogiContext;
use crate::descriptor::ModelDescriptor;
use crate::model::Model;
use postgres_types::ToSql;
use serde::Serialize;
use std::fmt::Write as _;
const BULK_SAVE_INSERT_MAX_BINDS: usize = 30_000;
const BULK_SAVE_INSERT_ROWS_PER_CHUNK: usize = BULK_SAVE_INSERT_MAX_BINDS / 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutboxAction {
Create,
Save,
Delete,
}
impl OutboxAction {
pub const fn as_sql_str(&self) -> &'static str {
match self {
OutboxAction::Create => "create",
OutboxAction::Save => "save",
OutboxAction::Delete => "delete",
}
}
}
pub async fn emit_event<T: Model + Serialize>(
ctx: &mut DjogiContext,
row: &T,
action: OutboxAction,
) -> Result<(), DjogiError>
where
T::Pk: std::fmt::Display,
{
let payload = build_payload(row, T::descriptor())?;
let outbox_table = format!("{}_outbox", T::table_name());
crate::ident::check_plain_ident(&outbox_table, false).map_err(|e| {
DjogiError::Db(crate::error::DbError::other(format!(
"outbox emit_event: invalid outbox table name {outbox_table:?}: {e:?}"
)))
})?;
let sql = insert_sql(&outbox_table);
let action_str = action.as_sql_str();
let params: &[&(dyn ToSql + Sync)] = &[row.pk_value(), &action_str, &payload];
ctx.execute(&sql, params).await?;
#[cfg(feature = "notify")]
{
let table = T::table_name();
let channel = format!("djogi_{table}");
crate::ident::check_plain_ident(&channel, false).map_err(|e| {
DjogiError::Db(crate::error::DbError::other(format!(
"notify hook: invalid channel name {channel:?}: {e:?}"
)))
})?;
let id_str = format!("{}", row.pk_value());
let notify_payload = build_notify_payload(action, &id_str);
ctx.execute(
"SELECT pg_notify($1, $2)",
&[&channel.as_str(), ¬ify_payload.as_str()],
)
.await?;
}
Ok(())
}
pub async fn emit_save_events_batch<T: Model + Serialize>(
ctx: &mut DjogiContext,
rows: &[&T],
) -> Result<(), DjogiError>
where
T::Pk: std::fmt::Display,
{
if rows.is_empty() {
return Ok(());
}
let outbox_table = format!("{}_outbox", T::table_name());
crate::ident::check_plain_ident(&outbox_table, false).map_err(|e| {
DjogiError::Db(crate::error::DbError::other(format!(
"outbox emit_save_events_batch: invalid outbox table name {outbox_table:?}: {e:?}"
)))
})?;
let mut payloads = Vec::with_capacity(rows.len());
for row in rows {
payloads.push(build_payload(row, T::descriptor())?);
}
let action = OutboxAction::Save.as_sql_str();
for (chunk_idx, chunk) in rows
.chunks(BULK_SAVE_INSERT_ROWS_PER_CHUNK.max(1))
.enumerate()
{
let mut sql = format!("INSERT INTO {outbox_table} (row_id, action, payload) VALUES ");
let mut params: Vec<&(dyn ToSql + Sync)> = Vec::with_capacity(chunk.len() * 3);
let payload_base = chunk_idx * BULK_SAVE_INSERT_ROWS_PER_CHUNK.max(1);
for (i, row) in chunk.iter().enumerate() {
if i > 0 {
sql.push_str(", ");
}
let base = i * 3;
let _ = write!(sql, "(${}, ${}, ${})", base + 1, base + 2, base + 3);
params.push(row.pk_value());
params.push(&action);
params.push(&payloads[payload_base + i]);
}
ctx.execute(&sql, ¶ms).await?;
}
#[cfg(feature = "notify")]
{
let table = T::table_name();
let channel = format!("djogi_{table}");
crate::ident::check_plain_ident(&channel, false).map_err(|e| {
DjogiError::Db(crate::error::DbError::other(format!(
"notify hook: invalid channel name {channel:?}: {e:?}"
)))
})?;
for row in rows {
let id_str = format!("{}", row.pk_value());
let notify_payload = build_notify_payload(OutboxAction::Save, &id_str);
ctx.execute(
"SELECT pg_notify($1, $2)",
&[&channel.as_str(), ¬ify_payload.as_str()],
)
.await?;
}
}
Ok(())
}
#[cfg(feature = "notify")]
pub(crate) fn build_notify_payload(action: OutboxAction, id_str: &str) -> String {
serde_json::json!({
"kind": action.as_sql_str(),
"id": id_str,
})
.to_string()
}
fn insert_sql(outbox_table: &str) -> String {
format!("INSERT INTO {outbox_table} (row_id, action, payload) VALUES ($1, $2, $3)")
}
fn build_payload<T: Serialize>(
row: &T,
descriptor: &ModelDescriptor,
) -> Result<serde_json::Value, DjogiError> {
let mut value = serde_json::to_value(row)?;
if let serde_json::Value::Object(ref mut map) = value {
for field in descriptor.fields {
if field.outbox_exclude {
map.remove(field.name);
}
}
}
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::descriptor::{
FieldDescriptor, FieldSqlType, ModelDescriptor, PkType, field_descriptor, model_descriptor,
};
fn minimal_descriptor_with_exclusions(exclusions: &'static [&'static str]) -> ModelDescriptor {
static FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("keep", FieldSqlType::Text, false)
},
FieldDescriptor {
outbox_exclude: true,
..field_descriptor("secret", FieldSqlType::Text, false)
},
];
static FIELDS_NO_EXCLUDE: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("keep", FieldSqlType::Text, false)
},
FieldDescriptor {
..field_descriptor("secret", FieldSqlType::Text, false)
},
];
let fields = if exclusions.contains(&"secret") {
FIELDS
} else {
FIELDS_NO_EXCLUDE
};
ModelDescriptor {
has_outbox: true,
..model_descriptor("TestRow", "test_rows", PkType::HeerId, fields)
}
}
#[derive(Serialize)]
struct TestRow {
keep: &'static str,
secret: &'static str,
}
#[test]
fn build_payload_strips_excluded_fields() {
let row = TestRow {
keep: "public",
secret: "confidential",
};
let descriptor = minimal_descriptor_with_exclusions(&["secret"]);
let payload = build_payload(&row, &descriptor).unwrap();
let obj = payload.as_object().expect("payload must be an object");
assert!(obj.contains_key("keep"));
assert!(
!obj.contains_key("secret"),
"outbox_exclude field must be stripped, got: {obj:?}"
);
assert_eq!(obj["keep"], serde_json::Value::String("public".into()));
}
#[test]
fn build_payload_preserves_all_fields_when_no_exclusions() {
let row = TestRow {
keep: "a",
secret: "b",
};
let descriptor = minimal_descriptor_with_exclusions(&[]);
let payload = build_payload(&row, &descriptor).unwrap();
let obj = payload.as_object().expect("payload must be an object");
assert!(obj.contains_key("keep"));
assert!(obj.contains_key("secret"));
}
#[test]
fn outbox_action_as_sql_str_matches_spec() {
assert_eq!(OutboxAction::Create.as_sql_str(), "create");
assert_eq!(OutboxAction::Save.as_sql_str(), "save");
assert_eq!(OutboxAction::Delete.as_sql_str(), "delete");
}
#[test]
fn insert_sql_uses_caller_supplied_table() {
let sql = insert_sql("accounts_outbox");
assert!(sql.contains("accounts_outbox"));
assert!(sql.contains("row_id"));
assert!(sql.contains("action"));
assert!(sql.contains("payload"));
assert!(sql.contains("$1"));
assert!(sql.contains("$2"));
assert!(sql.contains("$3"));
}
#[cfg(feature = "notify")]
#[test]
fn notify_payload_shape_create_byte_exact() {
let payload = build_notify_payload(OutboxAction::Create, "12345");
assert_eq!(payload, r#"{"kind":"create","id":"12345"}"#);
}
#[cfg(feature = "notify")]
#[test]
fn notify_payload_shape_save_byte_exact() {
let payload = build_notify_payload(OutboxAction::Save, "67890");
assert_eq!(payload, r#"{"kind":"save","id":"67890"}"#);
}
#[cfg(feature = "notify")]
#[test]
fn notify_payload_shape_delete_byte_exact() {
let payload = build_notify_payload(OutboxAction::Delete, "abcdef-ghi");
assert_eq!(payload, r#"{"kind":"delete","id":"abcdef-ghi"}"#);
}
#[cfg(feature = "notify")]
#[test]
fn notify_payload_round_trips_via_serde() {
let payload = build_notify_payload(OutboxAction::Save, "99");
let parsed: serde_json::Value =
serde_json::from_str(&payload).expect("payload must be valid JSON");
assert_eq!(parsed["kind"], "save");
assert_eq!(parsed["id"], "99");
}
#[cfg(feature = "notify")]
#[test]
fn notify_payload_escapes_special_chars_in_id() {
let payload = build_notify_payload(OutboxAction::Save, r#"id"with\quotes"#);
let parsed: serde_json::Value =
serde_json::from_str(&payload).expect("escaped payload must round-trip");
assert_eq!(parsed["id"], r#"id"with\quotes"#);
}
}