Skip to main content

data_connector/
hooks.rs

1//! Storage hook trait and supporting types.
2//!
3//! Hooks let teams inject custom logic (audit, tenancy, field population,
4//! validation) before and after storage operations without forking the
5//! codebase.  A hook receives a [`StorageOperation`] discriminant plus a
6//! JSON-serialised payload and returns either a continuation signal (with
7//! optional [`ExtraColumns`]) or a rejection.
8//!
9//! The hook trait is intentionally coarse-grained: a single `before`/`after`
10//! pair dispatched by operation enum, rather than one method per storage
11//! operation.  This keeps the trait small, avoids a combinatorial explosion
12//! of default methods, and maps directly to WASM guest exports (Phase 2b).
13
14use std::collections::HashMap;
15
16use async_trait::async_trait;
17use serde_json::Value;
18
19use crate::context::RequestContext;
20
21// ────────────────────────────────────────────────────────────────────────────
22// Types
23// ────────────────────────────────────────────────────────────────────────────
24
25/// Key-value bag for extra columns that hooks can read/write.
26///
27/// On writes, the hook populates values (e.g. `"EXPIRES_AT" → "2099-01-01"`)
28/// and the backend persists them to the extra columns declared in schema config.
29/// On reads, the backend fills the bag from stored extra column values so the
30/// hook can inspect them.
31pub type ExtraColumns = HashMap<String, Value>;
32
33/// Identifies which storage operation is being hooked.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub enum StorageOperation {
36    // ── ConversationStorage ──────────────────────────────────────────────
37    CreateConversation,
38    GetConversation,
39    UpdateConversation,
40    DeleteConversation,
41
42    // ── ConversationItemStorage ──────────────────────────────────────────
43    CreateItem,
44    LinkItem,
45    LinkItems,
46    ListItems,
47    GetItem,
48    IsItemLinked,
49    DeleteItem,
50
51    // ── ResponseStorage ──────────────────────────────────────────────────
52    StoreResponse,
53    GetResponse,
54    DeleteResponse,
55    GetResponseChain,
56    ListIdentifierResponses,
57    DeleteIdentifierResponses,
58}
59
60/// Result from a before-hook. Controls whether the backend operation proceeds.
61#[derive(Debug)]
62pub enum BeforeHookResult {
63    /// Proceed with the operation.  The [`ExtraColumns`] bag is forwarded to
64    /// the backend so it can persist hook-provided values alongside core data.
65    Continue(ExtraColumns),
66
67    /// Abort the operation and return an error to the caller.
68    Reject(String),
69}
70
71impl Default for BeforeHookResult {
72    fn default() -> Self {
73        Self::Continue(ExtraColumns::new())
74    }
75}
76
77/// Errors returned by hook implementations.
78#[derive(Debug, thiserror::Error)]
79pub enum HookError {
80    /// The hook explicitly rejected the operation.
81    #[error("hook rejected: {0}")]
82    Rejected(String),
83
84    /// An internal hook error (logged, operation continues by default).
85    #[error("hook error: {0}")]
86    Internal(String),
87}
88
89// ────────────────────────────────────────────────────────────────────────────
90// Trait
91// ────────────────────────────────────────────────────────────────────────────
92
93/// Trait for storage operation hooks.
94///
95/// Implementors intercept storage operations to inject custom logic such as
96/// audit logging, field population, PII redaction, or multi-tenancy filtering.
97///
98/// # Error Handling
99///
100/// - `before()` returning `Ok(BeforeHookResult::Reject(_))` aborts the operation.
101/// - `before()` returning `Err(_)` logs a warning and **continues** (non-fatal).
102/// - `after()` returning `Err(_)` logs a warning and **continues** (non-fatal).
103///
104/// This ensures hooks cannot accidentally break storage operations unless they
105/// explicitly intend to via `Reject`.
106#[async_trait]
107pub trait StorageHook: Send + Sync + 'static {
108    /// Called before a storage operation executes.
109    ///
110    /// `payload` is a JSON-serialised representation of the operation arguments
111    /// (e.g. a `NewConversation` for `CreateConversation`).
112    async fn before(
113        &self,
114        operation: StorageOperation,
115        context: Option<&RequestContext>,
116        payload: &Value,
117    ) -> Result<BeforeHookResult, HookError>;
118
119    /// Called after a storage operation completes successfully.
120    ///
121    /// `payload` is the same JSON from `before`.  `result` is the
122    /// JSON-serialised operation result.  `extra` contains any extra column
123    /// values from `before`.  The returned `ExtraColumns` can be used by the
124    /// caller (e.g. to surface hook-produced data in API responses).
125    async fn after(
126        &self,
127        operation: StorageOperation,
128        context: Option<&RequestContext>,
129        payload: &Value,
130        result: &Value,
131        extra: &ExtraColumns,
132    ) -> Result<ExtraColumns, HookError>;
133}
134
135// ────────────────────────────────────────────────────────────────────────────
136// Tests
137// ────────────────────────────────────────────────────────────────────────────
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn before_hook_result_default_is_continue_with_empty_extra() {
145        let result = BeforeHookResult::default();
146        match result {
147            BeforeHookResult::Continue(extra) => assert!(extra.is_empty()),
148            BeforeHookResult::Reject(_) => panic!("expected Continue"),
149        }
150    }
151
152    #[test]
153    fn storage_operation_is_copy() {
154        let op = StorageOperation::CreateConversation;
155        let op2 = op; // Copy
156        assert_eq!(op, op2);
157    }
158
159    #[test]
160    fn hook_error_display() {
161        let err = HookError::Rejected("bad input".to_string());
162        assert_eq!(err.to_string(), "hook rejected: bad input");
163
164        let err = HookError::Internal("timeout".to_string());
165        assert_eq!(err.to_string(), "hook error: timeout");
166    }
167}