Skip to main content

type_bridge/
hooks.rs

1//! Generated-model CRUD lifecycle hooks.
2//!
3//! Hooks are attached to a schema-bound generated manager. Pre-hooks run in
4//! registration order and may reject an operation. Post-hooks run in reverse
5//! order after a successful commit; their errors are logged and do not change
6//! the committed result.
7
8use std::collections::BTreeMap;
9use std::future::Future;
10use std::pin::Pin;
11use std::sync::Arc;
12use std::time::SystemTime;
13
14use serde_json::Value;
15
16use crate::__codegen::EncodedCreate;
17use crate::Result;
18use crate::error::Error;
19
20/// Boxed future returned by an object-safe generated lifecycle hook.
21pub type HookFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
22
23/// Generated CRUD operation presented to lifecycle hooks.
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub enum CrudOperation {
26    /// Insert a new exact model.
27    Insert,
28    /// Replace an existing exact model.
29    Update,
30    /// Delete an exact model.
31    Delete,
32    /// Insert or replace by projected key.
33    Put,
34}
35
36/// Generated thing kind presented to lifecycle hooks.
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub enum ModelKind {
39    /// Generated entity model.
40    Entity,
41    /// Generated relation model.
42    Relation,
43}
44
45/// Result of a generated pre-operation hook.
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub enum PreHookResult {
48    /// Continue the operation.
49    Continue,
50    /// Reject the operation before database work begins.
51    Reject {
52        /// Application-owned rejection reason.
53        reason: String,
54    },
55}
56
57/// Failure returned by one generated lifecycle hook.
58#[derive(Debug, thiserror::Error)]
59pub enum HookError {
60    /// A pre-hook explicitly rejected the operation.
61    #[error("hook '{hook_name}' rejected {operation:?}: {reason}")]
62    Rejected {
63        /// Hook name.
64        hook_name: String,
65        /// Rejected operation.
66        operation: CrudOperation,
67        /// Application-owned reason.
68        reason: String,
69    },
70    /// A hook failed while executing application code.
71    #[error("hook '{hook_name}' failed: {source}")]
72    Internal {
73        /// Hook name.
74        hook_name: String,
75        /// Application-owned source error.
76        #[source]
77        source: Box<dyn std::error::Error + Send + Sync + 'static>,
78    },
79}
80
81/// Context shared with one generated lifecycle hook.
82///
83/// Pre-hooks receive a mutable context and may add metadata for post-hooks.
84/// The generated input is present for insert, put, and update. Delete supplies
85/// an IID but has no generated create value. The operation timestamp and
86/// metadata remain stable across its pre- and post-hook phases.
87pub struct HookContext<'a> {
88    type_id_json: &'static str,
89    type_name: &'a str,
90    model_kind: ModelKind,
91    operation: CrudOperation,
92    iid: Option<&'a str>,
93    input: Option<&'a EncodedCreate>,
94    timestamp: &'a SystemTime,
95    metadata: &'a mut BTreeMap<String, Value>,
96}
97
98impl<'a> HookContext<'a> {
99    /// Canonical generated type identity JSON.
100    #[must_use]
101    pub const fn type_id_json(&self) -> &'static str {
102        self.type_id_json
103    }
104
105    /// Provider type label resolved from the generated identity.
106    #[must_use]
107    pub const fn type_name(&self) -> &str {
108        self.type_name
109    }
110
111    /// Whether the model is an entity or relation.
112    #[must_use]
113    pub const fn model_kind(&self) -> ModelKind {
114        self.model_kind
115    }
116
117    /// CRUD operation being performed.
118    #[must_use]
119    pub const fn operation(&self) -> CrudOperation {
120        self.operation
121    }
122
123    /// Canonical target IID when the operation has one.
124    #[must_use]
125    pub const fn iid(&self) -> Option<&str> {
126        self.iid
127    }
128
129    /// Generated create value for insert, put, or update.
130    #[must_use]
131    pub const fn input(&self) -> Option<&EncodedCreate> {
132        self.input
133    }
134
135    /// Time at which pre-hook processing for this operation began.
136    #[must_use]
137    pub const fn timestamp(&self) -> &SystemTime {
138        self.timestamp
139    }
140
141    /// Read application metadata accumulated by earlier pre-hooks.
142    #[must_use]
143    pub fn metadata(&self) -> &BTreeMap<String, Value> {
144        self.metadata
145    }
146
147    /// Add or change application metadata passed to later and post hooks.
148    pub fn metadata_mut(&mut self) -> &mut BTreeMap<String, Value> {
149        self.metadata
150    }
151
152    /// Store JSON-compatible application metadata without requiring a direct
153    /// `serde_json` dependency for common scalar values.
154    pub fn set_metadata(&mut self, key: impl Into<String>, value: impl Into<Value>) {
155        self.metadata.insert(key.into(), value.into());
156    }
157}
158
159/// Object-safe lifecycle hook for an exact generated entity or relation.
160pub trait LifecycleHook: Send + Sync {
161    /// Stable application-owned hook name used in diagnostics.
162    fn name(&self) -> &str;
163
164    /// Run before database work. Return [`PreHookResult::Reject`] to cancel.
165    fn before_operation<'a>(
166        &'a self,
167        context: &'a mut HookContext<'_>,
168    ) -> HookFuture<'a, std::result::Result<PreHookResult, HookError>>;
169
170    /// Run after a successful commit. Errors are logged and not propagated.
171    fn after_operation<'a>(
172        &'a self,
173        context: &'a HookContext<'_>,
174    ) -> HookFuture<'a, std::result::Result<(), HookError>>;
175
176    /// Return `false` to skip this hook for one context.
177    fn should_run(&self, context: &HookContext<'_>) -> bool {
178        let _ = context;
179        true
180    }
181}
182
183#[derive(Default)]
184pub(crate) struct HookRunner {
185    hooks: Vec<Arc<dyn LifecycleHook>>,
186}
187
188pub(crate) struct HookState {
189    metadata: BTreeMap<String, Value>,
190    timestamp: SystemTime,
191}
192
193impl Clone for HookRunner {
194    fn clone(&self) -> Self {
195        Self {
196            hooks: self.hooks.clone(),
197        }
198    }
199}
200
201impl HookRunner {
202    pub(crate) fn add(&mut self, hook: Arc<dyn LifecycleHook>) {
203        self.hooks.push(hook);
204    }
205
206    pub(crate) fn has_hooks(&self) -> bool {
207        !self.hooks.is_empty()
208    }
209
210    pub(crate) async fn run_pre(
211        &self,
212        type_id_json: &'static str,
213        model_kind: ModelKind,
214        operation: CrudOperation,
215        iid: Option<&str>,
216        input: Option<&EncodedCreate>,
217    ) -> Result<HookState> {
218        let type_name = type_name(type_id_json);
219        let mut metadata = BTreeMap::new();
220        let timestamp = SystemTime::now();
221        for hook in &self.hooks {
222            let mut context = HookContext {
223                type_id_json,
224                type_name: &type_name,
225                model_kind,
226                operation,
227                iid,
228                input,
229                timestamp: &timestamp,
230                metadata: &mut metadata,
231            };
232            if !hook.should_run(&context) {
233                continue;
234            }
235            match hook.before_operation(&mut context).await {
236                Ok(PreHookResult::Continue) => {}
237                Ok(PreHookResult::Reject { reason }) => {
238                    return Err(Error::from_hook(HookError::Rejected {
239                        hook_name: hook.name().to_owned(),
240                        operation,
241                        reason,
242                    }));
243                }
244                Err(error) => return Err(Error::from_hook(error)),
245            }
246        }
247        Ok(HookState {
248            metadata,
249            timestamp,
250        })
251    }
252
253    pub(crate) async fn run_post(
254        &self,
255        type_id_json: &'static str,
256        model_kind: ModelKind,
257        operation: CrudOperation,
258        iid: Option<&str>,
259        input: Option<&EncodedCreate>,
260        mut state: HookState,
261    ) {
262        let type_name = type_name(type_id_json);
263        let context = HookContext {
264            type_id_json,
265            type_name: &type_name,
266            model_kind,
267            operation,
268            iid,
269            input,
270            timestamp: &state.timestamp,
271            metadata: &mut state.metadata,
272        };
273        for hook in self.hooks.iter().rev() {
274            if !hook.should_run(&context) {
275                continue;
276            }
277            if let Err(error) = hook.after_operation(&context).await {
278                tracing::warn!(hook = hook.name(), error = %error, "generated post-hook error");
279            }
280        }
281    }
282}
283
284fn type_name(type_id_json: &str) -> String {
285    serde_json::from_str::<Value>(type_id_json)
286        .ok()
287        .and_then(|value| value.get("label")?.as_str().map(str::to_owned))
288        .unwrap_or_else(|| type_id_json.to_owned())
289}