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