Skip to main content

autumn_web/
hooks.rs

1//! Mutation hook types for repository lifecycle callbacks.
2//!
3//! This module provides the types and trait that power before/after mutation
4//! hooks on generated repositories (create, update, delete).
5//!
6//! # Opting in
7//!
8//! By default, repositories generated by `#[repository(Model)]` perform
9//! plain CRUD with no hook overhead. To enable hooks, pass a hooks type:
10//!
11//! ```rust,ignore
12//! #[derive(Clone, Default)]
13//! struct ArticleHooks;
14//!
15//! impl MutationHooks for ArticleHooks {
16//!     type Model = Article;
17//!     type NewModel = NewArticle;
18//!     type UpdateModel = UpdateArticle;
19//!
20//!     // override only the callbacks you need …
21//! }
22//!
23//! #[repository(Article, hooks = ArticleHooks)]
24//! pub trait ArticleRepository {}
25//! ```
26//!
27//! The durable `after_*_commit` hooks are opt-in because they require Autumn's
28//! framework-owned commit-hook queue table. Enable them explicitly with
29//! `#[repository(Article, hooks = ArticleHooks, commit_hooks = true)]`.
30//!
31//! The hooks type **must** implement [`Default`] and [`Clone`] (the
32//! generated extractor constructs it via `Default::default()` and the
33//! repository struct derives `Clone`).
34//!
35//! # Lifecycle
36//!
37//! Every mutating CRUD operation follows the same lifecycle:
38//!
39//! 1. **`before_*`** -- called before the mutation with a mutable
40//!    reference to the [`MutationContext`] and the input/model. The hook
41//!    may validate, enrich, or reject (by returning `Err`).
42//! 2. **persist** -- the actual `INSERT`, `UPDATE`, or `DELETE` runs.
43//!
44//! # Error semantics
45//!
46//! - `before_*` errors prevent the mutation entirely.
47//!
48//! # Helper types
49//!
50//! - [`Patch<T>`] -- tri-state value for partial-update (PATCH) payloads.
51//! - [`FieldDiff<T>`] -- per-field before/after diff for inspecting and
52//!   overriding changes inside hooks.
53//! - [`MutationOp`] -- discriminant for create / update / delete.
54//! - [`MutationContext`] -- carries actor identity, request ID, and
55//!   timestamp into every hook invocation.
56
57use serde::de::Deserializer;
58use serde::{Deserialize, Serialize};
59
60// ── Mutation operation & context ─────────────────────────────────────
61
62/// The kind of mutation being performed on a repository record.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[non_exhaustive]
65pub enum MutationOp {
66    /// A new record is being created.
67    Create,
68    /// An existing record is being updated.
69    Update,
70    /// An existing record is being deleted.
71    Delete,
72}
73
74impl MutationOp {
75    /// Returns the operation name as a static string slice.
76    #[must_use]
77    pub const fn as_str(self) -> &'static str {
78        match self {
79            Self::Create => "create",
80            Self::Update => "update",
81            Self::Delete => "delete",
82        }
83    }
84}
85
86impl std::fmt::Display for MutationOp {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.write_str(self.as_str())
89    }
90}
91
92/// Context available to mutation hooks.
93///
94/// Carries actor identity, request metadata, and timestamps so that
95/// hook implementations can perform auditing, validation, or enrichment.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct MutationContext {
98    /// The mutation operation type.
99    pub op: MutationOp,
100    /// Actor identity (user ID or service name). `None` for anonymous.
101    pub actor: Option<String>,
102    /// Correlation / request ID for tracing.
103    pub request_id: Option<String>,
104    /// Timestamp of the mutation.
105    pub now: chrono::DateTime<chrono::Utc>,
106    /// Cache keys to invalidate after the mutation.
107    pub invalidate_keys: Vec<String>,
108    /// Framework-scoped idempotency key for this mutation, when the mutation
109    /// came from an idempotent HTTP request or a hook set one explicitly.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub idempotency_key: Option<String>,
112}
113
114impl MutationContext {
115    /// Create a new context for the given operation.
116    ///
117    /// Auto-populates `now` with `Utc::now()` and `request_id` with a
118    /// freshly generated UUID v4.
119    #[must_use]
120    pub fn new(op: MutationOp) -> Self {
121        Self {
122            op,
123            actor: None,
124            request_id: Some(uuid::Uuid::new_v4().to_string()),
125            now: chrono::Utc::now(),
126            invalidate_keys: Vec::new(),
127            idempotency_key: None,
128        }
129    }
130
131    /// Add a cache key to the invalidation list.
132    pub fn invalidate(&mut self, key: impl Into<String>) {
133        self.invalidate_keys.push(key.into());
134    }
135
136    /// Set a scoped idempotency key for durable side-effect deduplication.
137    pub fn set_idempotency_key(&mut self, key: impl Into<String>) {
138        self.idempotency_key = Some(key.into());
139    }
140}
141
142// ── Mutation hooks trait ─────────────────────────────────────────────
143
144use crate::AutumnResult;
145use std::future::Future;
146
147/// Repository-scoped mutation lifecycle hooks.
148///
149/// All methods have default no-op implementations, so you only need to
150/// override the callbacks you care about. Each method receives a
151/// [`MutationContext`] (mutable for "before" hooks) and the relevant
152/// model/changeset.
153///
154/// # Associated types
155///
156/// - `Model` -- the read model returned by queries (e.g., `User`).
157/// - `NewModel` -- the insertable changeset (e.g., `NewUser`).
158/// - `UpdateModel` -- the partial-update changeset (e.g., `UpdateUser`).
159pub trait MutationHooks: Send + Sync + 'static {
160    /// The read model (e.g., the `Queryable` struct).
161    type Model: Send + Sync;
162    /// The insertable changeset for new records.
163    type NewModel: Send + Sync;
164    /// The partial-update changeset.
165    type UpdateModel: Send + Sync;
166
167    /// Called before a new record is inserted.
168    fn before_create(
169        &self,
170        _ctx: &mut MutationContext,
171        _new: &mut Self::NewModel,
172    ) -> impl Future<Output = AutumnResult<()>> + Send {
173        async { Ok(()) }
174    }
175
176    /// Called before an existing record is updated.
177    ///
178    /// The `draft` holds the merged before/after state. Use per-field
179    /// accessors (generated by `#[model]`) to inspect and rewrite:
180    ///
181    /// ```rust,ignore
182    /// if draft.status().changed_to(&Status::Approved) {
183    ///     draft.approved_at().set(Some(ctx.now));
184    /// }
185    /// ```
186    fn before_update(
187        &self,
188        _ctx: &mut MutationContext,
189        _draft: &mut UpdateDraft<Self::Model>,
190    ) -> impl Future<Output = AutumnResult<()>> + Send
191    where
192        Self::Model: Clone,
193    {
194        async { Ok(()) }
195    }
196
197    /// Called before an existing record is deleted.
198    fn before_delete(
199        &self,
200        _ctx: &mut MutationContext,
201        _record: &Self::Model,
202    ) -> impl Future<Output = AutumnResult<()>> + Send {
203        async { Ok(()) }
204    }
205
206    /// Called after a new record is inserted.
207    fn after_create(
208        &self,
209        _ctx: &mut MutationContext,
210        _record: &Self::Model,
211    ) -> impl Future<Output = AutumnResult<()>> + Send {
212        async { Ok(()) }
213    }
214
215    /// Called after an existing record is updated.
216    fn after_update(
217        &self,
218        _ctx: &mut MutationContext,
219        _record: &Self::Model,
220    ) -> impl Future<Output = AutumnResult<()>> + Send {
221        async { Ok(()) }
222    }
223
224    /// Called after a new record is inserted **and the transaction commits**.
225    ///
226    /// Unlike `after_create`, this hook fires only when the surrounding
227    /// database transaction has been durably committed. Use this variant for
228    /// side-effects (job enqueues, emails, cache invalidation) that must not
229    /// execute if the transaction rolls back.
230    ///
231    /// When the repository is declared with `commit_hooks = true`, generated
232    /// repository code writes this hook's intent to Autumn's framework-owned
233    /// durable commit-hook queue in the same transaction as the mutation.
234    /// Replicas claim queued hooks with Postgres row locks, so a process-local
235    /// task disappearing is recovered by retrying or dead-lettering the row
236    /// instead of silently losing the side effect.
237    fn after_create_commit(
238        &self,
239        _ctx: &mut MutationContext,
240        _record: &Self::Model,
241    ) -> impl Future<Output = AutumnResult<()>> + Send {
242        async { Ok(()) }
243    }
244
245    /// Called after an existing record is updated **and the transaction commits**.
246    ///
247    /// The same transactional guarantee as `after_create_commit` applies:
248    /// this hook is not called when the surrounding transaction rolls back.
249    fn after_update_commit(
250        &self,
251        _ctx: &mut MutationContext,
252        _record: &Self::Model,
253    ) -> impl Future<Output = AutumnResult<()>> + Send {
254        async { Ok(()) }
255    }
256
257    /// Called after a record is deleted **and the transaction commits**.
258    ///
259    /// The same transactional guarantee as `after_create_commit` applies:
260    /// this hook is not called when the surrounding transaction rolls back.
261    fn after_delete_commit(
262        &self,
263        _ctx: &mut MutationContext,
264        _record: &Self::Model,
265    ) -> impl Future<Output = AutumnResult<()>> + Send {
266        async { Ok(()) }
267    }
268}
269
270/// Stable hook-construction contract used by generated repositories.
271///
272/// This indirection keeps compile-time diagnostics anchored on Autumn-owned
273/// trait names instead of compiler-rendered `Default` wording, which is more
274/// brittle across platforms and environments.
275pub trait RepositoryHooksDefault: Sized {
276    /// Construct a hook instance for generated repository state.
277    fn autumn_default() -> Self;
278}
279
280impl<T> RepositoryHooksDefault for T
281where
282    T: Default,
283{
284    fn autumn_default() -> Self {
285        Self::default()
286    }
287}
288
289/// Stable hook-cloning contract used by generated repositories.
290///
291/// Generated repository implementations clone their hook state through this
292/// trait so compile-fail diagnostics stay deterministic across toolchains.
293pub trait RepositoryHooksClone: Sized {
294    /// Clone hook state for generated repository values.
295    #[must_use]
296    fn autumn_clone(&self) -> Self;
297}
298
299impl<T> RepositoryHooksClone for T
300where
301    T: Clone,
302{
303    fn autumn_clone(&self) -> Self {
304        self.clone()
305    }
306}
307
308// ── Default no-op hooks ──────────────────────────────────────────────
309
310/// Zero-cost no-op implementation of [`MutationHooks`].
311///
312/// Used by generated repository code when the user has not configured any
313/// hooks. All methods use the trait defaults (immediate `Ok(())`).
314pub struct NoHooks<M, N, U> {
315    _phantom: std::marker::PhantomData<(M, N, U)>,
316}
317
318impl<M, N, U> Default for NoHooks<M, N, U> {
319    fn default() -> Self {
320        Self {
321            _phantom: std::marker::PhantomData,
322        }
323    }
324}
325
326impl<M, N, U> MutationHooks for NoHooks<M, N, U>
327where
328    M: Send + Sync + Clone + 'static,
329    N: Send + Sync + 'static,
330    U: Send + Sync + 'static,
331{
332    type Model = M;
333    type NewModel = N;
334    type UpdateModel = U;
335}
336
337/// Tri-state sparse update value.
338///
339/// `Patch<T>` distinguishes between "field not mentioned" ([`Unchanged`](Patch::Unchanged)),
340/// "field explicitly set" ([`Set`](Patch::Set)), and "field explicitly cleared"
341/// ([`Clear`](Patch::Clear), mapping to SQL `NULL`).
342///
343/// This is the building block for partial-update (PATCH) payloads where
344/// omitting a field means "leave it alone" rather than "set it to its
345/// default".
346#[derive(Debug, Clone, Default, PartialEq, Eq)]
347pub enum Patch<T> {
348    /// The field was not included in the update payload.
349    #[default]
350    Unchanged,
351    /// The field was explicitly set to a new value.
352    Set(T),
353    /// The field was explicitly cleared (maps to SQL `NULL`).
354    Clear,
355}
356
357impl<T> Patch<T> {
358    /// Returns `true` if the field was not included in the update.
359    #[must_use]
360    pub const fn is_unchanged(&self) -> bool {
361        matches!(self, Self::Unchanged)
362    }
363
364    /// Returns `true` if the field was explicitly set to a new value.
365    #[must_use]
366    pub const fn is_set(&self) -> bool {
367        matches!(self, Self::Set(_))
368    }
369
370    /// Returns `true` if the field was explicitly cleared.
371    #[must_use]
372    pub const fn is_clear(&self) -> bool {
373        matches!(self, Self::Clear)
374    }
375
376    /// Returns a reference to the inner value if [`Set`](Patch::Set), or `None`.
377    #[must_use]
378    pub const fn as_set(&self) -> Option<&T> {
379        match self {
380            Self::Set(v) => Some(v),
381            _ => None,
382        }
383    }
384
385    /// Converts into a nested `Option`:
386    ///
387    /// - `Set(v)` -> `Some(Some(v))`
388    /// - `Clear` -> `Some(None)`
389    /// - `Unchanged` -> `None`
390    #[must_use]
391    pub fn into_option(self) -> Option<Option<T>> {
392        match self {
393            Self::Set(v) => Some(Some(v)),
394            Self::Clear => Some(None),
395            Self::Unchanged => None,
396        }
397    }
398}
399
400impl<T: Serialize> Serialize for Patch<T> {
401    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
402        match self {
403            Self::Unchanged | Self::Clear => serializer.serialize_none(),
404            Self::Set(v) => v.serialize(serializer),
405        }
406    }
407}
408
409impl<'de, T: Deserialize<'de>> Deserialize<'de> for Patch<T> {
410    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
411        // When this method is called, the field WAS present in the JSON.
412        // Absent fields use the Default impl (→ Unchanged) via #[serde(default)].
413        let opt: Option<T> = Option::deserialize(deserializer)?;
414        Ok(opt.map_or_else(|| Self::Clear, Self::Set))
415    }
416}
417
418/// Per-field before/after diff accessor for mutation hooks.
419///
420/// `FieldDiff<T>` holds the previous and proposed values for a single field,
421/// allowing hook authors to inspect what changed and optionally override the
422/// new value via [`set`](FieldDiff::set).
423#[derive(Debug, Clone, PartialEq, Eq)]
424pub struct FieldDiff<T> {
425    before: T,
426    after: T,
427}
428
429impl<T: PartialEq> FieldDiff<T> {
430    /// Create a new diff from before and after values.
431    #[must_use]
432    pub const fn new(before: T, after: T) -> Self {
433        Self { before, after }
434    }
435
436    /// Reference to the value before the mutation.
437    #[must_use]
438    pub const fn before(&self) -> &T {
439        &self.before
440    }
441
442    /// Reference to the (possibly overridden) value after the mutation.
443    #[must_use]
444    pub const fn after(&self) -> &T {
445        &self.after
446    }
447
448    /// Returns `true` if the field value changed.
449    #[must_use]
450    pub fn changed(&self) -> bool {
451        self.before != self.after
452    }
453
454    /// Returns `true` if the field value did not change.
455    #[must_use]
456    pub fn unchanged(&self) -> bool {
457        self.before == self.after
458    }
459
460    /// Returns `true` if the field changed **and** the new value equals `value`.
461    #[must_use]
462    pub fn changed_to(&self, value: &T) -> bool {
463        self.changed() && self.after == *value
464    }
465
466    /// Returns `true` if the field changed **and** the old value equals `value`.
467    #[must_use]
468    pub fn changed_from(&self, value: &T) -> bool {
469        self.changed() && self.before == *value
470    }
471
472    /// Override the after value. Does not affect `before`.
473    pub fn set(&mut self, value: T) {
474        self.after = value;
475    }
476}
477
478impl<T: PartialEq> FieldDiff<Option<T>> {
479    /// Returns `true` if the field went from `None` to `Some`.
480    #[must_use]
481    pub const fn was_set(&self) -> bool {
482        self.before.is_none() && self.after.is_some()
483    }
484
485    /// Returns `true` if the field went from `Some` to `None`.
486    #[must_use]
487    pub const fn was_cleared(&self) -> bool {
488        self.before.is_some() && self.after.is_none()
489    }
490}
491
492// ── UpdateDraft & DraftField ─────────────────────────────────────────
493
494/// Merged before/after snapshot of a model for `before_update` hooks.
495///
496/// `UpdateDraft<T>` holds the original (`before`) and proposed (`after`)
497/// state of a model. The `after` copy starts as a clone of `before` and
498/// can be mutated via [`after_mut`](UpdateDraft::after_mut) or through
499/// per-field [`DraftField`] accessors generated by `#[model]`.
500///
501/// # Usage
502///
503/// ```rust,ignore
504/// let mut draft = UpdateDraft::new(existing_article);
505/// // apply partial changes from the request …
506/// draft.after_mut().title = new_title;
507///
508/// // per-field inspection via DraftField:
509/// let title = DraftField::new(&draft.before().title, &mut draft.after_mut().title);
510/// if title.changed() { /* … */ }
511/// ```
512#[derive(Debug, Clone)]
513pub struct UpdateDraft<T: Clone> {
514    /// The original (pre-mutation) model state.
515    ///
516    /// Public so that `#[model]`-generated per-field `DraftField` accessors
517    /// can split-borrow `before` and `after` simultaneously.
518    pub before: T,
519    /// The proposed (post-mutation) model state.
520    ///
521    /// Public so that `#[model]`-generated per-field `DraftField` accessors
522    /// can split-borrow `before` and `after` simultaneously.
523    pub after: T,
524}
525
526impl<T: Clone> UpdateDraft<T> {
527    /// Create a new draft from the current model state.
528    ///
529    /// Clones `before` into `after` so that `after` starts as an
530    /// identical copy that can be selectively mutated.
531    #[must_use]
532    pub fn new(before: T) -> Self {
533        let after = before.clone();
534        Self { before, after }
535    }
536
537    /// Create a draft with explicit before and after values.
538    ///
539    /// Useful in tests or when changes have already been applied.
540    #[must_use]
541    pub const fn new_with_changes(before: T, after: T) -> Self {
542        Self { before, after }
543    }
544
545    /// Reference to the original (pre-mutation) model.
546    #[must_use]
547    pub const fn before(&self) -> &T {
548        &self.before
549    }
550
551    /// Reference to the proposed (post-mutation) model.
552    #[must_use]
553    pub const fn after(&self) -> &T {
554        &self.after
555    }
556
557    /// Mutable reference to the proposed (post-mutation) model.
558    ///
559    /// Use this to apply changes before the update is persisted.
560    #[must_use]
561    pub const fn after_mut(&mut self) -> &mut T {
562        &mut self.after
563    }
564
565    /// Consume the draft and return the proposed model.
566    #[must_use]
567    pub fn into_after(self) -> T {
568        self.after
569    }
570}
571
572/// Borrowing per-field accessor into an [`UpdateDraft`].
573///
574/// `DraftField` borrows `before` immutably and `after` mutably from the
575/// parent draft. Because each `DraftField` is a temporary that drops at
576/// statement end, you can inspect and mutate fields one at a time without
577/// running into overlapping borrow issues:
578///
579/// ```rust,ignore
580/// // generated by #[model]:
581/// draft.status().changed_to(&Status::Published);
582/// draft.title().set("New title".into());
583/// ```
584///
585/// # Lifetime
586///
587/// `'a` ties the field references back to the `UpdateDraft` they came
588/// from. The borrow is released when the `DraftField` is dropped.
589#[derive(Debug)]
590pub struct DraftField<'a, T> {
591    before: &'a T,
592    after: &'a mut T,
593}
594
595impl<'a, T> DraftField<'a, T> {
596    /// Create a new field accessor borrowing from a draft.
597    #[must_use]
598    pub const fn new(before: &'a T, after: &'a mut T) -> Self {
599        Self { before, after }
600    }
601
602    /// Reference to the original (pre-mutation) field value.
603    #[must_use]
604    pub const fn before(&self) -> &T {
605        self.before
606    }
607
608    /// Reference to the proposed (post-mutation) field value.
609    #[must_use]
610    pub const fn after(&self) -> &T {
611        self.after
612    }
613
614    /// Override the proposed value for this field.
615    pub fn set(&mut self, value: T) {
616        *self.after = value;
617    }
618}
619
620impl<T: PartialEq> DraftField<'_, T> {
621    /// Returns `true` if the field value changed.
622    #[must_use]
623    pub fn changed(&self) -> bool {
624        self.before != self.after
625    }
626
627    /// Returns `true` if the field value did not change.
628    #[must_use]
629    pub fn unchanged(&self) -> bool {
630        self.before == self.after
631    }
632
633    /// Returns `true` if the field changed **and** the new value equals `value`.
634    #[must_use]
635    pub fn changed_to(&self, value: &T) -> bool {
636        self.changed() && *self.after == *value
637    }
638
639    /// Returns `true` if the field changed **and** the old value equals `value`.
640    #[must_use]
641    pub fn changed_from(&self, value: &T) -> bool {
642        self.changed() && *self.before == *value
643    }
644}
645
646impl<T: PartialEq> DraftField<'_, Option<T>> {
647    /// Returns `true` if the field went from `None` to `Some`.
648    #[must_use]
649    pub const fn was_set(&self) -> bool {
650        self.before.is_none() && self.after.is_some()
651    }
652
653    /// Returns `true` if the field went from `Some` to `None`.
654    #[must_use]
655    pub const fn was_cleared(&self) -> bool {
656        self.before.is_some() && self.after.is_none()
657    }
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    // ── Patch tests ──────────────────────────────────────────────
665
666    #[test]
667    fn patch_unchanged_is_default() {
668        let p: Patch<String> = Patch::default();
669        assert!(p.is_unchanged());
670        assert!(!p.is_set());
671        assert!(!p.is_clear());
672    }
673
674    #[test]
675    fn patch_set_holds_value() {
676        let p = Patch::Set("hello");
677        assert!(p.is_set());
678        assert!(!p.is_unchanged());
679        assert!(!p.is_clear());
680        assert_eq!(p.as_set(), Some(&"hello"));
681    }
682
683    #[test]
684    fn patch_clear_is_clear() {
685        let p: Patch<i32> = Patch::Clear;
686        assert!(p.is_clear());
687        assert!(!p.is_set());
688        assert!(!p.is_unchanged());
689    }
690
691    #[test]
692    fn patch_into_option_set() {
693        assert_eq!(Patch::Set(42).into_option(), Some(Some(42)));
694    }
695
696    #[test]
697    fn patch_into_option_clear() {
698        assert_eq!(Patch::<i32>::Clear.into_option(), Some(None));
699    }
700
701    #[test]
702    fn patch_into_option_unchanged() {
703        assert_eq!(Patch::<i32>::Unchanged.into_option(), None);
704    }
705
706    // ── FieldDiff tests ──────────────────────────────────────────
707
708    #[test]
709    fn field_diff_unchanged() {
710        let diff = FieldDiff::new(1, 1);
711        assert!(diff.unchanged());
712        assert!(!diff.changed());
713    }
714
715    #[test]
716    fn field_diff_changed() {
717        let diff = FieldDiff::new(1, 2);
718        assert!(diff.changed());
719    }
720
721    #[test]
722    fn field_diff_changed_to() {
723        let diff = FieldDiff::new(1, 2);
724        assert!(diff.changed_to(&2));
725    }
726
727    #[test]
728    fn field_diff_changed_from() {
729        let diff = FieldDiff::new(1, 2);
730        assert!(diff.changed_from(&1));
731    }
732
733    #[test]
734    fn field_diff_set_updates_after() {
735        let mut diff = FieldDiff::new(1, 1);
736        assert!(diff.unchanged());
737        diff.set(5);
738        assert!(diff.changed());
739        assert_eq!(diff.after(), &5);
740        assert_eq!(diff.before(), &1);
741    }
742
743    #[test]
744    fn field_diff_option_was_set() {
745        let diff = FieldDiff::new(None, Some(42));
746        assert!(diff.was_set());
747    }
748
749    #[test]
750    fn field_diff_option_was_cleared() {
751        let diff = FieldDiff::new(Some(42), None);
752        assert!(diff.was_cleared());
753    }
754
755    // ── MutationOp tests ────────────────────────────────────────────
756
757    #[test]
758    fn mutation_op_as_str() {
759        assert_eq!(MutationOp::Create.as_str(), "create");
760        assert_eq!(MutationOp::Update.as_str(), "update");
761        assert_eq!(MutationOp::Delete.as_str(), "delete");
762    }
763
764    #[test]
765    fn mutation_op_display() {
766        assert_eq!(format!("{}", MutationOp::Create), "create");
767    }
768
769    // ── MutationContext tests ───────────────────────────────────────
770
771    #[test]
772    fn mutation_context_auto_populates() {
773        let ctx = MutationContext::new(MutationOp::Create);
774        assert!(ctx.actor.is_none());
775        assert!(ctx.request_id.is_some());
776        // UUID v4 format: 8-4-4-4-12 = 36 chars
777        assert_eq!(ctx.request_id.as_ref().unwrap().len(), 36);
778        assert!(matches!(ctx.op, MutationOp::Create));
779        assert!(ctx.invalidate_keys.is_empty());
780    }
781
782    #[test]
783    fn mutation_context_invalidate_pushes_key() {
784        let mut ctx = MutationContext::new(MutationOp::Create);
785        assert!(ctx.invalidate_keys.is_empty());
786        ctx.invalidate("cache:key");
787        assert_eq!(ctx.invalidate_keys, vec!["cache:key".to_string()]);
788    }
789
790    #[test]
791    fn mutation_context_with_actor() {
792        let mut ctx = MutationContext::new(MutationOp::Update);
793        ctx.actor = Some("user-123".into());
794        assert_eq!(ctx.actor.as_deref(), Some("user-123"));
795    }
796
797    #[test]
798    fn mutation_context_carries_scoped_idempotency_key() {
799        let mut ctx = MutationContext::new(MutationOp::Create);
800        assert!(ctx.idempotency_key.is_none());
801
802        ctx.set_idempotency_key("v2:scoped-http-key");
803
804        assert_eq!(ctx.idempotency_key.as_deref(), Some("v2:scoped-http-key"));
805    }
806
807    #[test]
808    fn mutation_context_deserializes_without_idempotency_key() {
809        let ctx: MutationContext = serde_json::from_value(serde_json::json!({
810            "op": "Create",
811            "actor": null,
812            "request_id": "request-1",
813            "now": "2026-05-17T00:00:00Z",
814            "invalidate_keys": []
815        }))
816        .expect("old durable hook payloads should deserialize");
817
818        assert!(ctx.idempotency_key.is_none());
819    }
820
821    // ── MutationHooks / NoHooks tests ───────────────────────────────
822
823    #[tokio::test]
824    async fn no_hooks_all_methods_are_noop() {
825        let hooks: NoHooks<(), (), ()> = NoHooks::default();
826        let mut ctx = MutationContext::new(MutationOp::Create);
827        let mut new_model = ();
828        let model = ();
829        let mut draft = UpdateDraft::new(());
830
831        assert!(hooks.before_create(&mut ctx, &mut new_model).await.is_ok());
832        assert!(hooks.before_update(&mut ctx, &mut draft).await.is_ok());
833        assert!(hooks.before_delete(&mut ctx, &model).await.is_ok());
834        assert!(hooks.after_create(&mut ctx, &model).await.is_ok());
835        assert!(hooks.after_update(&mut ctx, &model).await.is_ok());
836    }
837
838    #[tokio::test]
839    async fn no_hooks_commit_variants_are_noop() {
840        // after_*_commit variants must also be no-ops on NoHooks
841        let hooks: NoHooks<(), (), ()> = NoHooks::default();
842        let mut ctx = MutationContext::new(MutationOp::Create);
843        let model = ();
844
845        assert!(
846            hooks.after_create_commit(&mut ctx, &model).await.is_ok(),
847            "after_create_commit must default to Ok(())"
848        );
849        assert!(
850            hooks.after_update_commit(&mut ctx, &model).await.is_ok(),
851            "after_update_commit must default to Ok(())"
852        );
853        assert!(
854            hooks.after_delete_commit(&mut ctx, &model).await.is_ok(),
855            "after_delete_commit must default to Ok(())"
856        );
857    }
858
859    #[tokio::test]
860    async fn custom_hooks_can_override_commit_variants() {
861        use std::sync::Arc;
862        use std::sync::atomic::{AtomicU32, Ordering};
863
864        static CALLS: AtomicU32 = AtomicU32::new(0);
865
866        #[derive(Clone, Default)]
867        struct CountingHooks;
868
869        impl MutationHooks for CountingHooks {
870            type Model = ();
871            type NewModel = ();
872            type UpdateModel = ();
873
874            async fn after_create_commit(
875                &self,
876                _ctx: &mut MutationContext,
877                _record: &Self::Model,
878            ) -> AutumnResult<()> {
879                CALLS.fetch_add(1, Ordering::SeqCst);
880                Ok(())
881            }
882
883            async fn after_update_commit(
884                &self,
885                _ctx: &mut MutationContext,
886                _record: &Self::Model,
887            ) -> AutumnResult<()> {
888                CALLS.fetch_add(1, Ordering::SeqCst);
889                Ok(())
890            }
891
892            async fn after_delete_commit(
893                &self,
894                _ctx: &mut MutationContext,
895                _record: &Self::Model,
896            ) -> AutumnResult<()> {
897                CALLS.fetch_add(1, Ordering::SeqCst);
898                Ok(())
899            }
900        }
901
902        CALLS.store(0, Ordering::SeqCst);
903        let hooks = CountingHooks;
904        let mut ctx = MutationContext::new(MutationOp::Create);
905        let model = ();
906
907        hooks.after_create_commit(&mut ctx, &model).await.unwrap();
908        hooks.after_update_commit(&mut ctx, &model).await.unwrap();
909        hooks.after_delete_commit(&mut ctx, &model).await.unwrap();
910
911        assert_eq!(CALLS.load(Ordering::SeqCst), 3);
912        let _ = Arc::new(CountingHooks); // ensure Arc usage works (Clone check)
913    }
914
915    // ── Patch serde tests ──────────────────────────────────────────
916
917    #[test]
918    fn patch_serde_set_roundtrip() {
919        let p = Patch::Set(42);
920        let json = serde_json::to_string(&p).unwrap();
921        assert_eq!(json, "42");
922        let back: Patch<i32> = serde_json::from_str(&json).unwrap();
923        assert_eq!(back, Patch::Set(42));
924    }
925
926    #[test]
927    fn patch_serde_clear_serializes_as_null() {
928        let p: Patch<i32> = Patch::Clear;
929        let json = serde_json::to_string(&p).unwrap();
930        assert_eq!(json, "null");
931    }
932
933    #[test]
934    fn patch_serde_null_deserializes_as_clear() {
935        let p: Patch<i32> = serde_json::from_str("null").unwrap();
936        assert_eq!(p, Patch::Clear);
937    }
938
939    #[test]
940    fn patch_serde_absent_field_is_unchanged() {
941        #[derive(Deserialize, PartialEq, Debug)]
942        struct Payload {
943            #[serde(default)]
944            name: Patch<String>,
945            #[serde(default)]
946            age: Patch<i32>,
947        }
948        let p: Payload = serde_json::from_str(r#"{"name": "Alice"}"#).unwrap();
949        assert_eq!(p.name, Patch::Set("Alice".to_string()));
950        assert_eq!(p.age, Patch::Unchanged);
951    }
952
953    #[test]
954    fn patch_serde_explicit_null_is_clear() {
955        #[derive(Deserialize, PartialEq, Debug)]
956        struct Payload {
957            #[serde(default)]
958            name: Patch<String>,
959        }
960        let p: Payload = serde_json::from_str(r#"{"name": null}"#).unwrap();
961        assert_eq!(p.name, Patch::Clear);
962    }
963
964    // ── UpdateDraft tests ───────────────────────────────────────────
965
966    #[test]
967    fn update_draft_before_after() {
968        let draft = UpdateDraft::new_with_changes("old".to_string(), "new".to_string());
969        assert_eq!(draft.before(), "old");
970        assert_eq!(draft.after(), "new");
971    }
972
973    #[test]
974    fn update_draft_into_after() {
975        let draft = UpdateDraft::new_with_changes(1, 2);
976        assert_eq!(draft.into_after(), 2);
977    }
978
979    #[test]
980    fn update_draft_new_clones() {
981        let draft = UpdateDraft::new(42);
982        assert_eq!(draft.before(), &42);
983        assert_eq!(draft.after(), &42);
984    }
985
986    #[test]
987    fn update_draft_after_mut() {
988        let mut draft = UpdateDraft::new_with_changes(1, 2);
989        *draft.after_mut() = 3;
990        assert_eq!(draft.after(), &3);
991    }
992
993    // ── DraftField tests ────────────────────────────────────────────
994
995    #[test]
996    fn draft_field_before_after() {
997        let before = 1;
998        let mut after = 2;
999        let field = DraftField::new(&before, &mut after);
1000        assert_eq!(field.before(), &1);
1001        assert_eq!(field.after(), &2);
1002    }
1003
1004    #[test]
1005    fn draft_field_changed() {
1006        let before = 1;
1007        let mut after = 2;
1008        let field = DraftField::new(&before, &mut after);
1009        assert!(field.changed());
1010        assert!(!field.unchanged());
1011    }
1012
1013    #[test]
1014    fn draft_field_unchanged() {
1015        let before = 1;
1016        let mut after = 1;
1017        let field = DraftField::new(&before, &mut after);
1018        assert!(field.unchanged());
1019        assert!(!field.changed());
1020    }
1021
1022    #[test]
1023    fn draft_field_changed_to() {
1024        let before = "draft".to_string();
1025        let mut after = "published".to_string();
1026        let field = DraftField::new(&before, &mut after);
1027        assert!(field.changed_to(&"published".to_string()));
1028        assert!(!field.changed_to(&"draft".to_string()));
1029    }
1030
1031    #[test]
1032    fn draft_field_changed_from() {
1033        let before = "draft".to_string();
1034        let mut after = "published".to_string();
1035        let field = DraftField::new(&before, &mut after);
1036        assert!(field.changed_from(&"draft".to_string()));
1037        assert!(!field.changed_from(&"published".to_string()));
1038    }
1039
1040    #[test]
1041    fn draft_field_set_mutates_after() {
1042        let before = 10;
1043        let mut after = 10;
1044        {
1045            let mut field = DraftField::new(&before, &mut after);
1046            assert!(field.unchanged());
1047            field.set(20);
1048            assert!(field.changed());
1049            assert_eq!(field.after(), &20);
1050        }
1051        // Verify mutation propagated to the original variable
1052        assert_eq!(after, 20);
1053    }
1054
1055    #[test]
1056    fn draft_field_option_was_set() {
1057        let before: Option<i32> = None;
1058        let mut after: Option<i32> = Some(42);
1059        let field = DraftField::new(&before, &mut after);
1060        assert!(field.was_set());
1061        assert!(!field.was_cleared());
1062    }
1063
1064    #[test]
1065    fn draft_field_option_was_cleared() {
1066        let before: Option<i32> = Some(42);
1067        let mut after: Option<i32> = None;
1068        let field = DraftField::new(&before, &mut after);
1069        assert!(field.was_cleared());
1070        assert!(!field.was_set());
1071    }
1072}