fraiseql_core/cache/invalidation.rs
1//! Cache invalidation context and API.
2//!
3//! Provides structured invalidation contexts for different scenarios:
4//! - Mutation-triggered invalidation
5//! - Manual/administrative invalidation
6//! - Schema change invalidation
7//!
8//! # Current Scope
9//!
10//! - View-based invalidation (not entity-level)
11//! - Simple context types
12//! - Structured logging support
13//!
14//! # Future Enhancements
15//!
16//! - Entity-level invalidation with cascade metadata
17//! - Selective invalidation (by ID)
18//! - Invalidation batching
19
20/// Reason for cache invalidation.
21///
22/// Used for structured logging and debugging to understand why cache entries
23/// were invalidated.
24#[derive(Debug, Clone)]
25#[non_exhaustive]
26pub enum InvalidationReason {
27 /// Invalidation triggered by a GraphQL mutation.
28 ///
29 /// Contains the mutation name that modified the data.
30 Mutation {
31 /// Name of the mutation (e.g., "createUser", "updatePost")
32 mutation_name: String,
33 },
34
35 /// Manual invalidation by administrator or system.
36 ///
37 /// Contains a custom reason string for audit logging.
38 Manual {
39 /// Human-readable reason (e.g., "maintenance", "data import")
40 reason: String,
41 },
42
43 /// Schema change requiring cache flush.
44 ///
45 /// Triggered when the schema version changes (e.g., after deployment).
46 SchemaChange {
47 /// Old schema version
48 old_version: String,
49 /// New schema version
50 new_version: String,
51 },
52}
53
54impl InvalidationReason {
55 /// Format reason as a log-friendly string.
56 ///
57 /// # Example
58 ///
59 /// ```rust
60 /// use fraiseql_core::cache::InvalidationReason;
61 ///
62 /// let reason = InvalidationReason::Mutation {
63 /// mutation_name: "createUser".to_string()
64 /// };
65 ///
66 /// assert_eq!(
67 /// reason.to_log_string(),
68 /// "mutation:createUser"
69 /// );
70 /// ```
71 #[must_use]
72 pub fn to_log_string(&self) -> String {
73 match self {
74 Self::Mutation { mutation_name } => format!("mutation:{mutation_name}"),
75 Self::Manual { reason } => format!("manual:{reason}"),
76 Self::SchemaChange {
77 old_version,
78 new_version,
79 } => {
80 format!("schema_change:{old_version}->{new_version}")
81 },
82 }
83 }
84}
85
86/// Context for cache invalidation operations.
87///
88/// Encapsulates which views/tables were modified and why, providing
89/// structured information for logging and debugging.
90///
91/// # Example
92///
93/// ```rust
94/// use fraiseql_core::cache::InvalidationContext;
95///
96/// // Invalidate after mutation
97/// let ctx = InvalidationContext::for_mutation(
98/// "createUser",
99/// vec!["v_user".to_string()]
100/// );
101///
102/// // Invalidate manually
103/// let ctx = InvalidationContext::manual(
104/// vec!["v_user".to_string(), "v_post".to_string()],
105/// "data import completed"
106/// );
107///
108/// // Invalidate on schema change
109/// let ctx = InvalidationContext::schema_change(
110/// vec!["v_user".to_string()],
111/// "1.0.0",
112/// "1.1.0"
113/// );
114/// ```
115#[derive(Debug, Clone)]
116pub struct InvalidationContext {
117 /// List of views/tables that were modified.
118 ///
119 /// All cache entries accessing these views will be invalidated.
120 pub modified_views: Vec<String>,
121
122 /// Reason for invalidation.
123 ///
124 /// Used for structured logging and debugging.
125 pub reason: InvalidationReason,
126}
127
128impl InvalidationContext {
129 /// Create invalidation context for a mutation.
130 ///
131 /// Used by mutation handlers to invalidate cache entries after
132 /// modifying data.
133 ///
134 /// # Arguments
135 ///
136 /// * `mutation_name` - Name of the mutation (e.g., "createUser")
137 /// * `modified_views` - List of views/tables modified by the mutation
138 ///
139 /// # Example
140 ///
141 /// ```rust
142 /// use fraiseql_core::cache::InvalidationContext;
143 ///
144 /// let ctx = InvalidationContext::for_mutation(
145 /// "createUser",
146 /// vec!["v_user".to_string()]
147 /// );
148 ///
149 /// assert_eq!(ctx.modified_views, vec!["v_user"]);
150 /// ```
151 #[must_use]
152 pub fn for_mutation(mutation_name: &str, modified_views: Vec<String>) -> Self {
153 Self {
154 modified_views,
155 reason: InvalidationReason::Mutation {
156 mutation_name: mutation_name.to_string(),
157 },
158 }
159 }
160
161 /// Create invalidation context for manual invalidation.
162 ///
163 /// Used by administrators or background jobs to manually invalidate
164 /// cache entries (e.g., after data import, during maintenance).
165 ///
166 /// # Arguments
167 ///
168 /// * `modified_views` - List of views/tables to invalidate
169 /// * `reason` - Human-readable reason for audit logging
170 ///
171 /// # Example
172 ///
173 /// ```rust
174 /// use fraiseql_core::cache::InvalidationContext;
175 ///
176 /// let ctx = InvalidationContext::manual(
177 /// vec!["v_user".to_string(), "v_post".to_string()],
178 /// "maintenance: rebuilding indexes"
179 /// );
180 ///
181 /// assert_eq!(ctx.modified_views.len(), 2);
182 /// ```
183 #[must_use]
184 pub fn manual(modified_views: Vec<String>, reason: &str) -> Self {
185 Self {
186 modified_views,
187 reason: InvalidationReason::Manual {
188 reason: reason.to_string(),
189 },
190 }
191 }
192
193 /// Create invalidation context for schema change.
194 ///
195 /// Used during deployments when the schema version changes to flush
196 /// all cached entries that depend on the old schema structure.
197 ///
198 /// # Arguments
199 ///
200 /// * `affected_views` - All views in the schema (typically all views)
201 /// * `old_version` - Previous schema version
202 /// * `new_version` - New schema version
203 ///
204 /// # Example
205 ///
206 /// ```rust
207 /// use fraiseql_core::cache::InvalidationContext;
208 ///
209 /// let ctx = InvalidationContext::schema_change(
210 /// vec!["v_user".to_string(), "v_post".to_string()],
211 /// "1.0.0",
212 /// "1.1.0"
213 /// );
214 ///
215 /// assert_eq!(ctx.modified_views.len(), 2);
216 /// ```
217 #[must_use]
218 pub fn schema_change(
219 affected_views: Vec<String>,
220 old_version: &str,
221 new_version: &str,
222 ) -> Self {
223 Self {
224 modified_views: affected_views,
225 reason: InvalidationReason::SchemaChange {
226 old_version: old_version.to_string(),
227 new_version: new_version.to_string(),
228 },
229 }
230 }
231
232 /// Get a log-friendly description of this invalidation.
233 ///
234 /// # Example
235 ///
236 /// ```rust
237 /// use fraiseql_core::cache::InvalidationContext;
238 ///
239 /// let ctx = InvalidationContext::for_mutation(
240 /// "createUser",
241 /// vec!["v_user".to_string()]
242 /// );
243 ///
244 /// assert_eq!(
245 /// ctx.to_log_string(),
246 /// "mutation:createUser affecting 1 view(s)"
247 /// );
248 /// ```
249 #[must_use]
250 pub fn to_log_string(&self) -> String {
251 format!(
252 "{} affecting {} view(s)",
253 self.reason.to_log_string(),
254 self.modified_views.len()
255 )
256 }
257
258 /// Get the number of views affected by this invalidation.
259 ///
260 /// # Example
261 ///
262 /// ```rust
263 /// use fraiseql_core::cache::InvalidationContext;
264 ///
265 /// let ctx = InvalidationContext::manual(
266 /// vec!["v_user".to_string(), "v_post".to_string()],
267 /// "maintenance"
268 /// );
269 ///
270 /// assert_eq!(ctx.view_count(), 2);
271 /// ```
272 #[must_use]
273 pub const fn view_count(&self) -> usize {
274 self.modified_views.len()
275 }
276
277 /// Check if this invalidation affects a specific view.
278 ///
279 /// # Arguments
280 ///
281 /// * `view` - View name to check
282 ///
283 /// # Example
284 ///
285 /// ```rust
286 /// use fraiseql_core::cache::InvalidationContext;
287 ///
288 /// let ctx = InvalidationContext::for_mutation(
289 /// "createUser",
290 /// vec!["v_user".to_string(), "v_post".to_string()]
291 /// );
292 ///
293 /// assert!(ctx.affects_view("v_user"));
294 /// assert!(ctx.affects_view("v_post"));
295 /// assert!(!ctx.affects_view("v_comment"));
296 /// ```
297 #[must_use]
298 pub fn affects_view(&self, view: &str) -> bool {
299 self.modified_views.iter().any(|v| v == view)
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn test_for_mutation() {
309 let ctx = InvalidationContext::for_mutation("createUser", vec!["v_user".to_string()]);
310
311 assert_eq!(ctx.modified_views, vec!["v_user"]);
312 assert!(matches!(ctx.reason, InvalidationReason::Mutation { .. }));
313 }
314
315 #[test]
316 fn test_manual() {
317 let ctx = InvalidationContext::manual(
318 vec!["v_user".to_string(), "v_post".to_string()],
319 "maintenance",
320 );
321
322 assert_eq!(ctx.modified_views.len(), 2);
323 assert!(matches!(ctx.reason, InvalidationReason::Manual { .. }));
324 }
325
326 #[test]
327 fn test_schema_change() {
328 let ctx = InvalidationContext::schema_change(vec!["v_user".to_string()], "1.0.0", "1.1.0");
329
330 assert_eq!(ctx.modified_views, vec!["v_user"]);
331 assert!(matches!(ctx.reason, InvalidationReason::SchemaChange { .. }));
332 }
333
334 #[test]
335 fn test_mutation_log_string() {
336 let ctx = InvalidationContext::for_mutation("createUser", vec!["v_user".to_string()]);
337
338 assert_eq!(ctx.to_log_string(), "mutation:createUser affecting 1 view(s)");
339 }
340
341 #[test]
342 fn test_manual_log_string() {
343 let ctx = InvalidationContext::manual(vec!["v_user".to_string()], "data import");
344
345 assert_eq!(ctx.to_log_string(), "manual:data import affecting 1 view(s)");
346 }
347
348 #[test]
349 fn test_schema_change_log_string() {
350 let ctx = InvalidationContext::schema_change(vec!["v_user".to_string()], "1.0.0", "1.1.0");
351
352 assert_eq!(ctx.to_log_string(), "schema_change:1.0.0->1.1.0 affecting 1 view(s)");
353 }
354
355 #[test]
356 fn test_view_count() {
357 let ctx = InvalidationContext::for_mutation(
358 "createUser",
359 vec!["v_user".to_string(), "v_post".to_string()],
360 );
361
362 assert_eq!(ctx.view_count(), 2);
363 }
364
365 #[test]
366 fn test_affects_view() {
367 let ctx = InvalidationContext::for_mutation(
368 "createUser",
369 vec!["v_user".to_string(), "v_post".to_string()],
370 );
371
372 assert!(ctx.affects_view("v_user"));
373 assert!(ctx.affects_view("v_post"));
374 assert!(!ctx.affects_view("v_comment"));
375 }
376
377 #[test]
378 fn test_empty_views() {
379 let ctx = InvalidationContext::manual(vec![], "testing empty invalidation");
380
381 assert_eq!(ctx.view_count(), 0);
382 assert!(!ctx.affects_view("v_user"));
383 }
384
385 #[test]
386 fn test_reason_to_log_string_mutation() {
387 let reason = InvalidationReason::Mutation {
388 mutation_name: "updatePost".to_string(),
389 };
390
391 assert_eq!(reason.to_log_string(), "mutation:updatePost");
392 }
393
394 #[test]
395 fn test_reason_to_log_string_manual() {
396 let reason = InvalidationReason::Manual {
397 reason: "cache warmup".to_string(),
398 };
399
400 assert_eq!(reason.to_log_string(), "manual:cache warmup");
401 }
402
403 #[test]
404 fn test_reason_to_log_string_schema_change() {
405 let reason = InvalidationReason::SchemaChange {
406 old_version: "2.0.0".to_string(),
407 new_version: "2.1.0".to_string(),
408 };
409
410 assert_eq!(reason.to_log_string(), "schema_change:2.0.0->2.1.0");
411 }
412
413 #[test]
414 fn test_multiple_views() {
415 let views = vec![
416 "v_user".to_string(),
417 "v_post".to_string(),
418 "v_comment".to_string(),
419 "v_like".to_string(),
420 ];
421
422 let ctx = InvalidationContext::for_mutation("deleteUser", views);
423
424 assert_eq!(ctx.view_count(), 4);
425 assert!(ctx.affects_view("v_user"));
426 assert!(ctx.affects_view("v_post"));
427 assert!(ctx.affects_view("v_comment"));
428 assert!(ctx.affects_view("v_like"));
429 assert!(!ctx.affects_view("v_notification"));
430 }
431}