Skip to main content

net/adapter/net/cortex/tasks/
query.rs

1//! Prisma-style query builder over `TasksState`.
2//!
3//! Fluent filters compose in any order. `collect` / `first` / `count`
4//! terminate the chain against the live state.
5//!
6//! ```ignore
7//! let state_handle = tasks.state();
8//! let state = state_handle.read();
9//!
10//! let results = state.query()
11//!     .where_status(TaskStatus::Pending)
12//!     .created_after(cutoff_ns)
13//!     .order_by(OrderBy::CreatedDesc)
14//!     .limit(10)
15//!     .collect();
16//! ```
17//!
18//! The builder borrows the state read guard, so iteration sees a
19//! consistent snapshot.
20
21use std::collections::HashSet;
22
23pub(super) use super::super::needle::AsciiInsensitiveNeedle as TitleNeedle;
24use super::state::TasksState;
25use super::types::{Task, TaskId, TaskStatus};
26
27/// Filter / order / limit configuration. Shared by
28/// [`TasksQuery`] (immediate execution over a borrowed state snapshot)
29/// and [`super::watch::TasksWatcher`] (repeated execution driven by
30/// the adapter's change stream).
31#[derive(Debug, Clone, Default)]
32pub(super) struct TasksFilterSpec {
33    pub status: Option<TaskStatus>,
34    pub id_in: Option<HashSet<TaskId>>,
35    pub created_after_ns: Option<u64>,
36    pub created_before_ns: Option<u64>,
37    pub updated_after_ns: Option<u64>,
38    pub updated_before_ns: Option<u64>,
39    pub title_contains: Option<TitleNeedle>,
40    pub order_by: Option<OrderBy>,
41    pub limit: Option<usize>,
42}
43
44impl TasksFilterSpec {
45    /// Apply all filter predicates to a single task.
46    pub(super) fn matches(&self, t: &Task) -> bool {
47        if let Some(s) = self.status {
48            if t.status != s {
49                return false;
50            }
51        }
52        if let Some(ids) = &self.id_in {
53            if !ids.contains(&t.id) {
54                return false;
55            }
56        }
57        // Inclusive bounds (rejection via `<` / `>`). Strict `>` /
58        // `<` (rejection via `<=` / `>=`) would drop an event
59        // with `created_ns == cutoff` from both
60        // `created_after(cutoff)` and `created_before(cutoff)` —
61        // events would fall through holes between paginations
62        // using "last sync ns" as cutoff. Inclusive bounds also
63        // handle two events written in the same ns (achievable on
64        // Windows where wall-clock granularity is ~15ms).
65        if let Some(ns) = self.created_after_ns {
66            if t.created_ns < ns {
67                return false;
68            }
69        }
70        if let Some(ns) = self.created_before_ns {
71            if t.created_ns > ns {
72                return false;
73            }
74        }
75        if let Some(ns) = self.updated_after_ns {
76            if t.updated_ns < ns {
77                return false;
78            }
79        }
80        if let Some(ns) = self.updated_before_ns {
81            if t.updated_ns > ns {
82                return false;
83            }
84        }
85        if let Some(needle) = &self.title_contains {
86            if !needle.matches(&t.title) {
87                return false;
88            }
89        }
90        true
91    }
92
93    /// Collect matching tasks from state, applying order + limit.
94    pub(super) fn execute(&self, state: &TasksState) -> Vec<Task> {
95        let mut out: Vec<Task> = state
96            .tasks
97            .values()
98            .filter(|t| self.matches(t))
99            .cloned()
100            .collect();
101        if let Some(order) = self.order_by {
102            sort_tasks(&mut out, order);
103        }
104        if let Some(limit) = self.limit {
105            out.truncate(limit);
106        }
107        out
108    }
109}
110
111/// Ordering for query results.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum OrderBy {
114    /// By `id`, ascending.
115    IdAsc,
116    /// By `id`, descending.
117    IdDesc,
118    /// By `created_ns`, ascending (oldest first).
119    CreatedAsc,
120    /// By `created_ns`, descending (newest first).
121    CreatedDesc,
122    /// By `updated_ns`, ascending.
123    UpdatedAsc,
124    /// By `updated_ns`, descending.
125    UpdatedDesc,
126}
127
128/// Fluent query over `TasksState`.
129///
130/// Created via [`TasksState::query`].
131pub struct TasksQuery<'a> {
132    state: &'a TasksState,
133    spec: TasksFilterSpec,
134}
135
136impl TasksState {
137    /// Start a fluent query over this state snapshot.
138    pub fn query(&self) -> TasksQuery<'_> {
139        TasksQuery {
140            state: self,
141            spec: TasksFilterSpec::default(),
142        }
143    }
144}
145
146impl<'a> TasksQuery<'a> {
147    /// Restrict to tasks with the given status.
148    pub fn where_status(mut self, status: TaskStatus) -> Self {
149        self.spec.status = Some(status);
150        self
151    }
152
153    /// Restrict to tasks whose id is in the provided collection.
154    pub fn where_id_in(mut self, ids: impl IntoIterator<Item = TaskId>) -> Self {
155        self.spec.id_in = Some(ids.into_iter().collect());
156        self
157    }
158
159    /// Restrict to `created_ns >= ns` (inclusive).
160    pub fn created_after(mut self, ns: u64) -> Self {
161        self.spec.created_after_ns = Some(ns);
162        self
163    }
164
165    /// Restrict to `created_ns <= ns` (inclusive).
166    pub fn created_before(mut self, ns: u64) -> Self {
167        self.spec.created_before_ns = Some(ns);
168        self
169    }
170
171    /// Restrict to `updated_ns >= ns` (inclusive).
172    pub fn updated_after(mut self, ns: u64) -> Self {
173        self.spec.updated_after_ns = Some(ns);
174        self
175    }
176
177    /// Restrict to `updated_ns <= ns` (inclusive).
178    pub fn updated_before(mut self, ns: u64) -> Self {
179        self.spec.updated_before_ns = Some(ns);
180        self
181    }
182
183    /// Restrict to tasks whose title contains `needle` (case-insensitive).
184    pub fn title_contains(mut self, needle: impl Into<String>) -> Self {
185        self.spec.title_contains = Some(TitleNeedle::new(needle));
186        self
187    }
188
189    /// Order results. If unset, iteration order is unspecified (hash map).
190    pub fn order_by(mut self, order: OrderBy) -> Self {
191        self.spec.order_by = Some(order);
192        self
193    }
194
195    /// Truncate to `n` results after ordering.
196    pub fn limit(mut self, n: usize) -> Self {
197        self.spec.limit = Some(n);
198        self
199    }
200
201    /// Execute the query and collect matching tasks (cloned).
202    pub fn collect(self) -> Vec<Task> {
203        self.spec.execute(self.state)
204    }
205
206    /// Return the number of matches. Ignores `limit`.
207    pub fn count(self) -> usize {
208        self.state
209            .tasks
210            .values()
211            .filter(|t| self.spec.matches(t))
212            .count()
213    }
214
215    /// Return the first matching task in iteration order (after
216    /// applying `order_by` if set).
217    pub fn first(mut self) -> Option<Task> {
218        // Force a limit of 1 but still respect ordering.
219        self.spec.limit = Some(1);
220        self.collect().into_iter().next()
221    }
222
223    /// True if any task matches. Short-circuits on first hit.
224    pub fn exists(self) -> bool {
225        self.state.tasks.values().any(|t| self.spec.matches(t))
226    }
227}
228
229pub(super) fn sort_tasks(tasks: &mut [Task], order: OrderBy) {
230    match order {
231        OrderBy::IdAsc => tasks.sort_by_key(|t| t.id),
232        OrderBy::IdDesc => tasks.sort_by_key(|t| std::cmp::Reverse(t.id)),
233        OrderBy::CreatedAsc => tasks.sort_by_key(|t| t.created_ns),
234        OrderBy::CreatedDesc => tasks.sort_by_key(|t| std::cmp::Reverse(t.created_ns)),
235        OrderBy::UpdatedAsc => tasks.sort_by_key(|t| t.updated_ns),
236        OrderBy::UpdatedDesc => tasks.sort_by_key(|t| std::cmp::Reverse(t.updated_ns)),
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::super::types::{Task, TaskStatus};
243    use super::*;
244
245    fn mk(id: TaskId, title: &str, status: TaskStatus, created: u64, updated: u64) -> Task {
246        Task {
247            id,
248            title: title.to_string(),
249            status,
250            created_ns: created,
251            updated_ns: updated,
252        }
253    }
254
255    fn state_with(tasks: impl IntoIterator<Item = Task>) -> TasksState {
256        let mut s = TasksState::new();
257        for t in tasks {
258            s.tasks.insert(t.id, t);
259        }
260        s
261    }
262
263    fn sample() -> TasksState {
264        state_with([
265            mk(1, "Write plan", TaskStatus::Pending, 100, 100),
266            mk(2, "Ship adapter", TaskStatus::Completed, 200, 250),
267            mk(3, "Review PR", TaskStatus::Pending, 300, 310),
268            mk(4, "Update docs", TaskStatus::Pending, 400, 410),
269            mk(5, "Deploy v1", TaskStatus::Completed, 500, 520),
270        ])
271    }
272
273    #[test]
274    fn test_no_filters_returns_all() {
275        let s = sample();
276        assert_eq!(s.query().count(), 5);
277    }
278
279    #[test]
280    fn test_where_status_pending() {
281        let s = sample();
282        let mut ids: Vec<_> = s
283            .query()
284            .where_status(TaskStatus::Pending)
285            .collect()
286            .iter()
287            .map(|t| t.id)
288            .collect();
289        ids.sort();
290        assert_eq!(ids, vec![1, 3, 4]);
291    }
292
293    #[test]
294    fn test_where_id_in() {
295        let s = sample();
296        let mut ids: Vec<_> = s
297            .query()
298            .where_id_in([2, 4, 99])
299            .collect()
300            .iter()
301            .map(|t| t.id)
302            .collect();
303        ids.sort();
304        assert_eq!(ids, vec![2, 4]);
305    }
306
307    #[test]
308    fn test_created_after() {
309        let s = sample();
310        let mut ids: Vec<_> = s
311            .query()
312            .created_after(300)
313            .collect()
314            .iter()
315            .map(|t| t.id)
316            .collect();
317        ids.sort();
318        // Bounds are inclusive. Task 3 (created_ns=300)
319        // qualifies — pre-fix the comparator was strict `>`, which
320        // dropped boundary events that should belong to either side
321        // of a `last_sync_ns`-style cutoff.
322        assert_eq!(ids, vec![3, 4, 5]);
323    }
324
325    #[test]
326    fn test_created_before() {
327        let s = sample();
328        let mut ids: Vec<_> = s
329            .query()
330            .created_before(300)
331            .collect()
332            .iter()
333            .map(|t| t.id)
334            .collect();
335        ids.sort();
336        // Inclusive — task 3 (created_ns=300) qualifies.
337        assert_eq!(ids, vec![1, 2, 3]);
338    }
339
340    /// CR-20: pin the symmetric duplicate-delivery hazard the
341    /// inclusive-bound fix introduced. The fix made
342    /// `created_after(N)` inclusive (`>=`) so a `created_ns == N`
343    /// event isn't dropped by both halves of a `[after, before]`
344    /// pagination range. But the symmetric paginate-by-cutoff
345    /// case re-delivers the boundary:
346    ///
347    ///   poll_1: query.created_after(0) → returns events at ns=100, 200
348    ///           caller stores `last_seen_ns = 200`
349    ///   poll_2: query.created_after(200) → returns event at ns=200 AGAIN
350    ///                                       (because >= 200 is inclusive)
351    ///
352    /// Receiver-side dedup by `id` masks the duplicate today, but
353    /// a same-ns legitimate update (real secondary write at the
354    /// boundary timestamp) collides with the prior event under
355    /// the same id. This test pins the documented symmetric-
356    /// duplicate behavior so a future paginate-helper that uses
357    /// `last_seen_ns` as the next cutoff knows to advance by
358    /// `last_seen_ns + 1` (or to switch to id-based cursors).
359    #[test]
360    fn cr20_paginate_by_last_seen_ns_re_delivers_boundary_event() {
361        let s = sample();
362
363        // First "page": everything created at or after ns=100.
364        let page_1: Vec<_> = s.query().created_after(100).collect();
365        let last_seen = page_1
366            .iter()
367            .map(|t| t.created_ns)
368            .max()
369            .expect("non-empty result");
370
371        // Caller stores `last_seen` and uses it as the next
372        // page's cutoff — naive paginator pattern.
373        let page_2: Vec<_> = s.query().created_after(last_seen).collect();
374
375        // CR-20: the boundary event at `created_ns == last_seen`
376        // is RE-DELIVERED. This is the symmetric hazard the
377        // inclusive-bound fix introduced.
378        let boundary_count = page_2.iter().filter(|t| t.created_ns == last_seen).count();
379        assert!(
380            boundary_count >= 1,
381            "CR-20: with inclusive `created_after`, paginating by \
382             last_seen_ns re-delivers the boundary event. The naive \
383             paginator pattern (`cutoff = last_seen_ns`) MUST advance \
384             past the boundary explicitly (e.g. `cutoff = last_seen_ns + \
385             1`) or use an id-based cursor instead. This test pins the \
386             documented behavior — fix it only if you also update the \
387             paginate-helper docs and the receiver-side dedup expectations."
388        );
389    }
390
391    #[test]
392    fn test_updated_after_and_before() {
393        let s = sample();
394        let mut ids: Vec<_> = s
395            .query()
396            .updated_after(250)
397            .updated_before(500)
398            .collect()
399            .iter()
400            .map(|t| t.id)
401            .collect();
402        ids.sort();
403        // Inclusive bounds — task 2 (updated_ns=250) is
404        // included by `updated_after(250)`, and task 5 (520) is
405        // still excluded because it's strictly above 500.
406        assert_eq!(ids, vec![2, 3, 4]);
407    }
408
409    #[test]
410    fn test_title_contains_case_insensitive() {
411        let s = sample();
412        let mut ids: Vec<_> = s
413            .query()
414            .title_contains("DEPLOY")
415            .collect()
416            .iter()
417            .map(|t| t.id)
418            .collect();
419        ids.sort();
420        assert_eq!(ids, vec![5]);
421
422        let ids_plural: Vec<_> = s.query().title_contains("e").collect();
423        // All titles contain "e" (Write, adapter, Review, update, Deploy).
424        assert_eq!(ids_plural.len(), 5);
425    }
426
427    #[test]
428    fn test_order_by_id_asc_desc() {
429        let s = sample();
430        let asc: Vec<_> = s
431            .query()
432            .order_by(OrderBy::IdAsc)
433            .collect()
434            .iter()
435            .map(|t| t.id)
436            .collect();
437        assert_eq!(asc, vec![1, 2, 3, 4, 5]);
438
439        let desc: Vec<_> = s
440            .query()
441            .order_by(OrderBy::IdDesc)
442            .collect()
443            .iter()
444            .map(|t| t.id)
445            .collect();
446        assert_eq!(desc, vec![5, 4, 3, 2, 1]);
447    }
448
449    #[test]
450    fn test_order_by_created() {
451        let s = sample();
452        let asc: Vec<_> = s
453            .query()
454            .order_by(OrderBy::CreatedAsc)
455            .collect()
456            .iter()
457            .map(|t| t.id)
458            .collect();
459        assert_eq!(asc, vec![1, 2, 3, 4, 5]);
460    }
461
462    #[test]
463    fn test_order_by_updated_desc() {
464        let s = sample();
465        let desc: Vec<_> = s
466            .query()
467            .order_by(OrderBy::UpdatedDesc)
468            .collect()
469            .iter()
470            .map(|t| t.id)
471            .collect();
472        // updated_ns: 100, 250, 310, 410, 520 → desc order → ids 5, 4, 3, 2, 1
473        assert_eq!(desc, vec![5, 4, 3, 2, 1]);
474    }
475
476    #[test]
477    fn test_limit_truncates_after_order() {
478        let s = sample();
479        let top2: Vec<_> = s
480            .query()
481            .order_by(OrderBy::CreatedDesc)
482            .limit(2)
483            .collect()
484            .iter()
485            .map(|t| t.id)
486            .collect();
487        assert_eq!(top2, vec![5, 4]);
488    }
489
490    #[test]
491    fn test_composed_filters() {
492        let s = sample();
493        // Pending tasks created after 200, ordered by id ascending.
494        let ids: Vec<_> = s
495            .query()
496            .where_status(TaskStatus::Pending)
497            .created_after(200)
498            .order_by(OrderBy::IdAsc)
499            .collect()
500            .iter()
501            .map(|t| t.id)
502            .collect();
503        assert_eq!(ids, vec![3, 4]);
504    }
505
506    #[test]
507    fn test_first_returns_ordered_head() {
508        let s = sample();
509        let first = s
510            .query()
511            .where_status(TaskStatus::Pending)
512            .order_by(OrderBy::CreatedDesc)
513            .first()
514            .unwrap();
515        // Pending: 1, 3, 4 → created_ns 100, 300, 400 → desc head is id 4.
516        assert_eq!(first.id, 4);
517    }
518
519    #[test]
520    fn test_first_none_when_no_match() {
521        let s = sample();
522        assert!(s.query().title_contains("unicorn").first().is_none());
523    }
524
525    #[test]
526    fn test_count_ignores_limit() {
527        let s = sample();
528        let q = s.query().where_status(TaskStatus::Pending).limit(1);
529        // 3 pending tasks total; limit does not affect count.
530        assert_eq!(q.count(), 3);
531    }
532
533    #[test]
534    fn test_exists_short_circuits() {
535        let s = sample();
536        assert!(s.query().where_status(TaskStatus::Completed).exists());
537        assert!(!s.query().title_contains("unicorn").exists());
538    }
539
540    #[test]
541    fn test_empty_state_queries_return_empty() {
542        let s = TasksState::new();
543        assert_eq!(s.query().count(), 0);
544        assert!(s.query().first().is_none());
545        assert!(!s.query().exists());
546    }
547}