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}