djogi 0.1.0-alpha.2

Model-first web framework for Rust — web-framework-agnostic core; Axum integration opt-in via the `axum` feature flag
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
//! Transactional outbox — Phase 4 Task 6 (write side) + Phase 5 Task 11.5 (worker side).
//!
//! Models annotated with `#[model(events)]` emit one outbox row into
//! `{table}_outbox` on every successful `create`, `save`, `delete`,
//! `update_returning_pair`, or `delete_returning` performed through a
//! [`DjogiContext`]. Because the outbox INSERT shares the same context (and
//! therefore the same active transaction), the outbox row commits or rolls back
//! atomically with the primary write — no separate "event publisher" fan-out is
//! required at the write side. A downstream poller / CDC consumer drains
//! `{table}_outbox` asynchronously.
//!
//! # PG18 OLD/NEW RETURNING and the outbox (djogi#180)
//!
//! `Model::update_returning_pair` and `QuerySet::execute_returning_pairs`
//! expose both the pre- and post-update row snapshots to the **caller**, but
//! the outbox **still emits a single `Save` row** with the DB-returned
//! post-image (`pair.new`) as the payload, after `outbox_exclude` filtering.
//! This matches the `save()` outbox contract — no diff-shaped `{ old, new }`
//! payload is emitted in v1.
//!
//! Rationale: the existing outbox schema (`action` + single `payload` column)
//! cannot represent a diff without a breaking schema change. Downstream
//! consumers that currently process `Save` payloads would break if the payload
//! shape changed silently. A diff-outbox schema requires a separate plan
//! (migration, new action type, worker shape change, and publisher review).
//!
//! `Model::delete_returning` similarly emits a single `Delete` row with the
//! DB-returned pre-delete snapshot as the payload — identical to `delete()`
//! but using the authoritative DB snapshot rather than the consumed `self`.
//!
//! # Module layout
//!
//! - `mod.rs` (this file) — write side: `emit_event`, `OutboxAction`, payload shaping.
//!   All Phase 4 exports are unchanged.
//! - `worker` — claim-pending, mark-published/failed, recover-stale. Feature-gated
//!   on the `outbox` cargo feature.
//! - `publisher` — `Publisher` trait + `PublishError` enum. Feature-gated on `outbox`.
//! - `publishers` — concrete publisher implementations. `NotifyPublisher` is always
//!   available under `outbox`; Redis/Kafka/NATS ship behind their own sub-feature flags.
//!
//! # Why this lives here (not inside the macro body)
//!
//! The macro emits a **one-line call** into this module after every
//! successful CRUD:
//!
//! ```ignore
//! if Self::descriptor().has_outbox {
//!     ::djogi::outbox::emit_event(ctx, &row, ::djogi::outbox::OutboxAction::Create).await?;
//! }
//! ```
//!
//! Keeping the payload shaping + dispatch out of the macro has three
//! benefits:
//!
//! 1. **Easier to audit.** Reviewers read plain Rust rather than a
//!    `quote! { ... }` block to understand payload exclusion, bind
//!    layout, and error flow.
//! 2. **One place to evolve.** Later-phase additions (encrypted payload
//!    fields, tenant partitioning, per-action `rationale` columns) land
//!    here without touching the macro.
//! 3. **Unit-testable.** `emit_event` is a plain async function; its
//!    payload-shaping helper is testable without bringing up a proc-macro
//!    harness.
//!
//! # How it composes with `atomic()`
//!
//! The emitter takes `&mut DjogiContext` and dispatches via the same
//! `__inner_mut_for_macros` pattern the macro CRUD uses. A pool-backed
//! context writes outside any transaction; a transaction-backed context
//! writes inside the outermost atomic scope. Rollback of the outer
//! scope removes both the primary row and its outbox companion — the
//! "transactional" in *transactional outbox* is Postgres' own ACID
//! guarantee, not a framework-level two-phase dance.
//!
//! # Payload filtering
//!
//! `emit_event` serializes the row via `serde_json::to_value` and then
//! walks `T::descriptor().fields` to strip keys whose
//! [`FieldDescriptor::outbox_exclude`] flag is set. The exclusion is
//! key-based (not index-based) so that even if serde's field order
//! drifts, the filter stays correct.
//!
//! # DDL side-channel (Phase 7 handoff — deferred)
//!
//! The eventual plan is for the macro to write a DDL stub to
//! `target/djogi_outbox/{table}_outbox.sql` at build time so the Phase
//! 7 migration differ can consume it. **That emission is not shipped
//! today** — this module ships only the runtime payload/insert path.
//! Phase 4 integration tests hand-write the matching DDL in
//! `tests/integration/migrations/phase4/*.sql`; Phase 6 / Phase 7 will
//! land the macro-side side-channel and retire those hand-written
//! fixtures.

// ---------------------------------------------------------------------------
// Worker / publisher sub-modules — feature-gated on `outbox`.
// ---------------------------------------------------------------------------

#[cfg(feature = "outbox")]
pub mod publisher;
#[cfg(feature = "outbox")]
pub mod publishers;
#[cfg(feature = "outbox")]
pub mod worker;

/// Re-exported for `djogi::outbox::PublishError` and `djogi::outbox::Publisher`
/// convenience paths.
#[cfg(feature = "outbox")]
pub use publisher::{PublishError, Publisher};

/// Re-exported for the `djogi::outbox::OutboxRow` convenience path.
#[cfg(feature = "outbox")]
pub use worker::OutboxRow;

// ---------------------------------------------------------------------------
// Write-side implementation (Phase 4 Task 6) — always compiled.
// ---------------------------------------------------------------------------

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 _;

// Postgres parameter indices are `u16`-bounded (`$1..$65535`). Bulk save
// outbox inserts use 3 binds per row (`row_id`, `action`, `payload`), so
// cap each statement well below the ceiling.
const BULK_SAVE_INSERT_MAX_BINDS: usize = 30_000;
const BULK_SAVE_INSERT_ROWS_PER_CHUNK: usize = BULK_SAVE_INSERT_MAX_BINDS / 3;

/// The three CRUD operations a model can emit an outbox row for.
///
/// Stored as a `TEXT` column so downstream consumers (Python, TS, SQL
/// dashboards) do not need Postgres enum-to-enum bindings. Lowercase
/// ASCII to match the informal convention used by CDC tools like
/// Debezium (`c` / `u` / `d` → `create` / `update` / `delete`; we use
/// `save` rather than `update` because the framework's write verb is
/// `save`, not "update").
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutboxAction {
    /// `Model::create` — new row inserted into the primary table.
    Create,
    /// `Model::save` — existing row updated via `UPDATE ... RETURNING *`.
    /// Payload carries the DB-refreshed values (post-trigger, post-
    /// default) because `save` rehydrates the receiver before the
    /// outbox call runs.
    Save,
    /// `Model::delete` — row removed from the primary table. Payload
    /// carries the pre-delete snapshot so downstream consumers can
    /// reconstruct what was removed.
    Delete,
}

impl OutboxAction {
    /// String literal used in the outbox `action` column. Stable — any
    /// rename would be a breaking change for downstream consumers, so
    /// future additions land as new variants rather than renames.
    pub const fn as_sql_str(&self) -> &'static str {
        match self {
            OutboxAction::Create => "create",
            OutboxAction::Save => "save",
            OutboxAction::Delete => "delete",
        }
    }
}

/// Emit a transactional outbox row for `row` through `ctx`.
///
/// Called from macro-generated CRUD bodies exactly when
/// `T::descriptor().has_outbox` is `true` — i.e. the model opted into
/// `#[model(events)]`. The caller holds the responsibility of gating
/// the call: `emit_event` itself always writes, so it must not be
/// invoked for non-events models.
///
/// # Error flow
///
/// - `serde_json::to_value` failure → `DjogiError::Serde`.
/// - Database execute failure → `DjogiError::Pg`.
///
/// Both are propagated verbatim so the calling CRUD method's `?`
/// rolls the transaction back on any failure.
///
/// # Payload shape
///
/// `serde_json::to_value(row)` produces a `Value::Object` keyed by
/// the struct's field names (serde's default `rename_all`). The
/// filter walks `T::descriptor().fields` and removes any key whose
/// `outbox_exclude` flag is set, so the emitted JSONB carries only
/// the columns the model author deemed safe to share. Non-object
/// outputs (e.g. a user who derived `Serialize` for a tuple struct)
/// pass through unfiltered — `outbox_exclude` keys off column names,
/// which only exist on record-shaped payloads.
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())?;
    // Validate the **derived** outbox table identifier (not the source
    // table alone). The 7-byte `_outbox` suffix can push an otherwise-
    // valid 57-63 byte table name past Postgres's 63-byte identifier
    // limit. Matches the defense-in-depth check in
    // `query::refresh::fetch_delta` and `outbox::worker::validate_table_ident`.
    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?;

    // Synchronous in-process publisher hook for `crate::notify`
    // subscribers — fires `pg_notify('djogi_<table>', '{kind,id}')`
    // in the same transaction. Distinct from Phase 5's
    // `NotifyPublisher`, which carries full JSONB payloads on
    // user-named channels for downstream relay. The `check_plain_ident`
    // pass guards against a malicious macro fork smuggling an invalid
    // table name through the SQL embedding.
    #[cfg(feature = "notify")]
    {
        let table = T::table_name();
        let channel = format!("djogi_{table}");
        // Validate the **derived channel** (not just the table). The
        // 6-byte `djogi_` prefix can push an otherwise-valid 58-63 byte
        // table name past Postgres's 63-byte identifier limit, which
        // matches the subscriber-side validation in `crate::notify`.
        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(), &notify_payload.as_str()],
        )
        .await?;
    }

    Ok(())
}

/// Emit one `Save` outbox row per entry in `rows` using a single batched
/// `INSERT ... VALUES (...), (...), ...` statement.
///
/// This is used by bulk `execute_returning_pairs` to preserve per-row outbox
/// semantics without issuing one INSERT round-trip per updated row.
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, &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:?}"
            )))
        })?;
        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(), &notify_payload.as_str()],
            )
            .await?;
        }
    }

    Ok(())
}

/// Build the slim `{kind, id}` JSON payload for the notify hook.
/// `serde_json::json!` (not `format!`) so a future PK type whose
/// `Display` produces double-quotes or backslashes still emits valid
/// JSON.
#[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()
}

/// Build the outbox `INSERT` SQL for a given outbox table. Caller is
/// responsible for validating the identifier via `check_plain_ident`
/// before passing it in (the SQL embeds the name unquoted).
fn insert_sql(outbox_table: &str) -> String {
    format!("INSERT INTO {outbox_table} (row_id, action, payload) VALUES ($1, $2, $3)")
}

/// Serialize `row` into a JSON object, then strip any keys the
/// descriptor marks `outbox_exclude`.
///
/// Separated from `emit_event` so the filter semantics are unit-
/// testable without a database. Accepts `&ModelDescriptor` rather
/// than re-fetching via `T::descriptor()` so the test callers can
/// pass a minimal fixture descriptor.
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 {
    //! Payload-shaping tests — `emit_event` itself needs a live DB and
    //! is covered by the Phase 4 integration suite. The filter logic
    //! is pure and lives here.

    use super::*;
    use crate::descriptor::{
        FieldDescriptor, FieldSqlType, ModelDescriptor, PkType, field_descriptor, model_descriptor,
    };

    fn minimal_descriptor_with_exclusions(exclusions: &'static [&'static str]) -> ModelDescriptor {
        // Build a descriptor whose fields match the serde output of the
        // test payload. Columns listed in `exclusions` are flagged
        // `outbox_exclude = true`; all others are `false`. A `&[]`
        // `exclusions` slice yields a descriptor with no exclusions —
        // the identity case.
        //
        // We use static `FieldDescriptor` slices so the returned
        // `ModelDescriptor` can borrow them with `'static` lifetimes.
        static FIELDS: &[FieldDescriptor] = &[
            FieldDescriptor {
                ..field_descriptor("keep", FieldSqlType::Text, false)
            },
            FieldDescriptor {
                outbox_exclude: true,
                ..field_descriptor("secret", FieldSqlType::Text, false)
            },
        ];

        // The test helper caller hands in `&["secret"]` to match the
        // static above, or `&[]` to bypass exclusions. Rather than
        // building the descriptor dynamically (which would require
        // leaking memory or fighting the 'static bound), we return
        // the pre-baked slice when the caller asked for the "secret"
        // exclusion and a separate non-excluding slice otherwise.
        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() {
        // Caller is now responsible for `_outbox` suffix construction +
        // ident validation; `insert_sql` is a pure formatter.
        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"));
    }

    // build_notify_payload byte-shape tests. The receiver's decoder
    // depends on `kind` being a known enum variant and `id` being a
    // string, so a regression that widened the payload or reordered
    // keys would fail here.
    #[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() {
        // Defensive: PK types whose Display output contains double
        // quotes or backslashes must still produce valid JSON.
        // serde_json::json! handles this; raw format! would not.
        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"#);
    }
}