Skip to main content

laser_wire/
query.rs

1use crate::agent::wire_id;
2use crate::codes::QUERY_OP_VERSION;
3use crate::destination::{BackendResourceId, DestinationId};
4use crate::error::InvalidError;
5use crate::limits::{
6    MAX_PAGE_SIZE, MAX_QUERY_CURSOR_BYTES, MAX_QUERY_FIELDS, MAX_QUERY_NAME_BYTES,
7    MAX_QUERY_PARAMETERS, MAX_QUERY_PREDICATES, MAX_RAW_SQL_BYTES,
8};
9use crate::schema::{Digest32, LogicalField, TypedValue, UuidValue, validate_result_fields};
10use crate::validate::Validate;
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeSet;
13
14wire_id!(
15    /// Unguessable identity used for query status, paging, and cancellation.
16    QueryExecutionId
17);
18
19/// The resource against which a query is authorized and resolved.
20#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(tag = "kind", rename_all = "snake_case")]
22#[non_exhaustive]
23pub enum QueryTarget {
24    Operational {
25        index: String,
26    },
27    Lakehouse {
28        destination_id: DestinationId,
29        destination_generation: u64,
30        #[serde(default, skip_serializing_if = "Option::is_none")]
31        snapshot: Option<SnapshotSelector>,
32    },
33}
34
35impl QueryTarget {
36    pub fn operational(index: impl Into<String>) -> Self {
37        Self::Operational {
38            index: index.into(),
39        }
40    }
41}
42
43/// An optional time-travel selector resolved to one exact Iceberg snapshot.
44#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
46#[non_exhaustive]
47pub enum SnapshotSelector {
48    SnapshotId(i64),
49    TimestampMicros(i64),
50}
51
52/// SQL grammar accepted by the selected query backend.
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55#[non_exhaustive]
56pub enum SqlDialect {
57    DataFusion,
58    Postgres,
59    MySql,
60    Sqlite,
61}
62
63/// One exact-match constraint: the indexed `field` must equal `value`.
64#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
65pub struct KeyMatch {
66    pub field: String,
67    pub value: TypedValue,
68}
69
70impl KeyMatch {
71    /// An exact-match predicate, `field == value`.
72    pub fn new(field: impl Into<String>, value: impl Into<TypedValue>) -> Self {
73        Self {
74            field: field.into(),
75            value: value.into(),
76        }
77    }
78}
79
80/// A query against a materialized index. Build it fluently via the SDK's
81/// `Laser::query`, or directly through [`Query::builder`].
82#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
83#[cfg_attr(feature = "builders", derive(bon::Builder))]
84pub struct Query {
85    pub execution_id: QueryExecutionId,
86    pub target: QueryTarget,
87    // Absolute Unix deadline. A server never extends it while queueing or paging.
88    pub deadline_micros: u64,
89    #[cfg_attr(feature = "builders", builder(default))]
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    pub by_key: Vec<KeyMatch>,
92    #[cfg_attr(feature = "builders", builder(into))]
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub message_type: Option<String>,
95    // (start, end) in epoch microseconds, both bounds inclusive: the managed backend
96    // keeps rows with `start <= ts <= end`.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub time_range: Option<(u64, u64)>,
99    // Predicate tree. `None` plus empty sugar is an unfiltered scan. Build
100    // trees with `Filter::all`/`any`/`not`/`pred`.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub filter: Option<Filter>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub vector: Option<VectorQuery>,
105    // Lexical relevance search over the text-hinted indexed fields, additive
106    // like `vector`. An unaware server would silently drop it (the A8 additive
107    // hazard), so the client refuses an unadvertised `text` before sending.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub text: Option<TextQuery>,
110    #[cfg_attr(feature = "builders", builder(default))]
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub order: Vec<Sort>,
113    #[cfg_attr(feature = "builders", builder(default))]
114    #[serde(default)]
115    pub page: QueryPageRequest,
116    // Analytics, mutually exclusive with row selection.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub aggregate: Option<Aggregate>,
119    // Filter on aggregate output (predicate fields reference an alias or group
120    // key). Only meaningful with `aggregate`.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub having: Option<Filter>,
123    // DISTINCT over the selected fields.
124    #[cfg_attr(feature = "builders", builder(default))]
125    #[serde(default, skip_serializing_if = "is_false")]
126    pub distinct: bool,
127    #[cfg_attr(feature = "builders", builder(default))]
128    #[serde(default)]
129    pub select: Select,
130    // Resolve against a fork's copy-on-write view (trunk overlaid with the fork's
131    // speculative rows) instead of the trunk. Absent on the wire for a trunk
132    // query, so the pre-fork contract is unchanged.
133    #[cfg_attr(feature = "builders", builder(into))]
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub fork: Option<String>,
136    // Opt-in raw-SQL escape hatch. SQL backends only, read-only single SELECT.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub raw_sql: Option<RawSql>,
139    // Read-consistency level. Absent on the wire for the default (`Eventual`),
140    // so the pre-consistency contract is unchanged.
141    #[cfg_attr(feature = "builders", builder(default))]
142    #[serde(default, skip_serializing_if = "Consistency::is_eventual")]
143    pub consistency: Consistency,
144}
145
146impl Query {
147    /// Create a query for an explicit target and execution boundary.
148    pub fn new(execution_id: QueryExecutionId, target: QueryTarget, deadline_micros: u64) -> Self {
149        Self {
150            execution_id,
151            target,
152            deadline_micros,
153            by_key: Vec::new(),
154            message_type: None,
155            time_range: None,
156            filter: None,
157            vector: None,
158            text: None,
159            order: Vec::new(),
160            page: QueryPageRequest::default(),
161            aggregate: None,
162            having: None,
163            distinct: false,
164            select: Select::default(),
165            fork: None,
166            raw_sql: None,
167            consistency: Consistency::Eventual,
168        }
169    }
170
171    /// Create an operational query for one materialized index.
172    pub fn operational(
173        execution_id: QueryExecutionId,
174        index: impl Into<String>,
175        deadline_micros: u64,
176    ) -> Self {
177        Self::new(
178            execution_id,
179            QueryTarget::operational(index),
180            deadline_micros,
181        )
182    }
183}
184
185#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
186pub struct QueryPageRequest {
187    pub limit: u32,
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub offset: Option<u64>,
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub cursor: Option<String>,
192    #[serde(default, skip_serializing_if = "is_false")]
193    pub want_total: bool,
194}
195
196impl Default for QueryPageRequest {
197    fn default() -> Self {
198        Self {
199            limit: 50,
200            offset: Some(0),
201            cursor: None,
202            want_total: false,
203        }
204    }
205}
206
207impl Validate for Query {
208    fn validate(&self) -> Result<(), InvalidError> {
209        if self.execution_id.as_u128() == 0 {
210            return Err(InvalidError::new("query execution id must be nonzero"));
211        }
212        if self.deadline_micros == 0 {
213            return Err(InvalidError::new("query deadline must be nonzero"));
214        }
215        match &self.target {
216            QueryTarget::Operational { index } => validate_query_name("index", index)?,
217            QueryTarget::Lakehouse {
218                destination_id,
219                destination_generation,
220                snapshot,
221            } => {
222                if destination_id.as_u128() == 0 || *destination_generation == 0 {
223                    return Err(InvalidError::new(
224                        "lakehouse destination identity and generation must be nonzero",
225                    ));
226                }
227                if let Some(selector) = snapshot {
228                    match selector {
229                        SnapshotSelector::SnapshotId(value)
230                        | SnapshotSelector::TimestampMicros(value)
231                            if *value <= 0 =>
232                        {
233                            return Err(InvalidError::new(
234                                "snapshot selector must contain a positive value",
235                            ));
236                        }
237                        _ => {}
238                    }
239                }
240                if self.fork.is_some() {
241                    return Err(InvalidError::new(
242                        "a lakehouse query cannot resolve against an operational fork",
243                    ));
244                }
245            }
246        }
247        if self.page.limit == 0 || self.page.limit > MAX_PAGE_SIZE as u32 {
248            return Err(InvalidError::new(format!(
249                "query limit {} is outside 1..={MAX_PAGE_SIZE}",
250                self.page.limit
251            )));
252        }
253        for (label, count) in [
254            ("exact matches", self.by_key.len()),
255            ("sort fields", self.order.len()),
256            ("selected fields", self.select.fields.len()),
257        ] {
258            if count > MAX_QUERY_FIELDS {
259                return Err(InvalidError::new(format!(
260                    "query {label} count {count} exceeds cap {MAX_QUERY_FIELDS}"
261                )));
262            }
263        }
264        if self.page.offset.is_some() && self.page.cursor.is_some() {
265            return Err(InvalidError::new(
266                "query page cannot contain both an offset and a cursor",
267            ));
268        }
269        if let Some(cursor) = &self.page.cursor
270            && (cursor.is_empty()
271                || cursor.len() > MAX_QUERY_CURSOR_BYTES
272                || cursor.chars().any(char::is_control))
273        {
274            return Err(InvalidError::new(
275                "query cursor must contain 1..=512 bytes without control characters",
276            ));
277        }
278        for key_match in &self.by_key {
279            validate_query_name("key-match field", &key_match.field)?;
280            key_match.value.validate()?;
281        }
282        if let Some(message_type) = &self.message_type {
283            validate_query_name("message type", message_type)?;
284        }
285        if let Some((start, end)) = self.time_range
286            && start >= end
287        {
288            return Err(InvalidError::new(
289                "query time range start must be before its end, and both bounds are inclusive",
290            ));
291        }
292        for sort in &self.order {
293            validate_query_name("sort field", &sort.field)?;
294        }
295        for field in &self.select.fields {
296            validate_query_name("selected field", field)?;
297        }
298        if let Some(text) = &self.text {
299            text.validate()?;
300        }
301        if let Some(vector) = &self.vector {
302            vector.validate()?;
303        }
304        validate_filter(self.filter.as_ref())?;
305        validate_filter(self.having.as_ref())?;
306        if self.having.is_some() && self.aggregate.is_none() {
307            return Err(InvalidError::new(
308                "query having filter requires an aggregate",
309            ));
310        }
311        if let Some(aggregate) = &self.aggregate {
312            aggregate.validate()?;
313        }
314        if self.aggregate.is_some() && (!self.select.fields.is_empty() || self.select.payload) {
315            return Err(InvalidError::new(
316                "aggregate queries cannot request row selection",
317            ));
318        }
319        if let Some(raw_sql) = &self.raw_sql {
320            raw_sql.validate()?;
321            if !self.by_key.is_empty()
322                || self.message_type.is_some()
323                || self.time_range.is_some()
324                || self.filter.is_some()
325                || self.vector.is_some()
326                || self.text.is_some()
327                || !self.order.is_empty()
328                || self.aggregate.is_some()
329                || self.having.is_some()
330                || self.distinct
331                || !self.select.fields.is_empty()
332                || self.select.payload
333            {
334                return Err(InvalidError::new(
335                    "raw SQL cannot be combined with the structured query expression",
336                ));
337            }
338        }
339        Ok(())
340    }
341}
342
343impl Validate for Aggregate {
344    fn validate(&self) -> Result<(), InvalidError> {
345        if self.funcs.is_empty() || self.funcs.len() > MAX_QUERY_PARAMETERS {
346            return Err(InvalidError::new(format!(
347                "aggregate function count must be in 1..={MAX_QUERY_PARAMETERS}"
348            )));
349        }
350        for field in &self.group_by {
351            validate_query_name("group field", field)?;
352        }
353        if self.group_by.len() > MAX_QUERY_FIELDS {
354            return Err(InvalidError::new(format!(
355                "aggregate group field count exceeds cap {MAX_QUERY_FIELDS}"
356            )));
357        }
358        let mut aliases = BTreeSet::new();
359        for call in &self.funcs {
360            validate_query_name("aggregate alias", &call.alias)?;
361            if !aliases.insert(&call.alias) {
362                return Err(InvalidError::new(format!(
363                    "aggregate repeats alias `{}`",
364                    call.alias
365                )));
366            }
367            if let Some(field) = &call.field {
368                validate_query_name("aggregate field", field)?;
369            }
370            match call.func {
371                AggFunc::Count if call.arg.is_none() => {}
372                AggFunc::Percentile
373                    if call.field.is_some()
374                        && call.arg.is_some_and(|value| {
375                            value.is_finite() && (0.0..=1.0).contains(&value)
376                        }) => {}
377                AggFunc::Percentile => {
378                    return Err(InvalidError::new(
379                        "percentile requires a field and a finite fraction in 0..=1",
380                    ));
381                }
382                _ if call.field.is_some() && call.arg.is_none() => {}
383                _ => {
384                    return Err(InvalidError::new(
385                        "aggregate field and argument do not match the selected function",
386                    ));
387                }
388            }
389        }
390        if let Some(window) = &self.window {
391            validate_query_name("window field", &window.field)?;
392            if window.every_micros == 0 {
393                return Err(InvalidError::new("window duration must be nonzero"));
394            }
395        }
396        Ok(())
397    }
398}
399
400impl Validate for RawSql {
401    fn validate(&self) -> Result<(), InvalidError> {
402        if self.sql.trim().is_empty() || self.sql.len() > MAX_RAW_SQL_BYTES {
403            return Err(InvalidError::new(format!(
404                "raw SQL must contain 1..={MAX_RAW_SQL_BYTES} bytes"
405            )));
406        }
407        if self
408            .sql
409            .chars()
410            .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
411        {
412            return Err(InvalidError::new("raw SQL contains a control character"));
413        }
414        if self.params.len() > MAX_QUERY_PARAMETERS {
415            return Err(InvalidError::new(format!(
416                "raw SQL has {} parameters, exceeds cap {MAX_QUERY_PARAMETERS}",
417                self.params.len()
418            )));
419        }
420        for param in &self.params {
421            param.validate()?;
422        }
423        Ok(())
424    }
425}
426
427fn validate_query_name(label: &str, value: &str) -> Result<(), InvalidError> {
428    if value.is_empty() || value.len() > MAX_QUERY_NAME_BYTES {
429        return Err(InvalidError::new(format!(
430            "{label} must contain 1..={MAX_QUERY_NAME_BYTES} bytes"
431        )));
432    }
433    if value.trim() != value || value.chars().any(char::is_control) {
434        return Err(InvalidError::new(format!(
435            "{label} contains surrounding whitespace or a control character"
436        )));
437    }
438    Ok(())
439}
440
441fn validate_filter(filter: Option<&Filter>) -> Result<(), InvalidError> {
442    fn visit(filter: &Filter, depth: usize, count: &mut usize) -> Result<(), InvalidError> {
443        *count += 1;
444        if *count > MAX_QUERY_PREDICATES {
445            return Err(InvalidError::new(format!(
446                "query filter node count exceeds cap {MAX_QUERY_PREDICATES}"
447            )));
448        }
449        if depth > 64 {
450            return Err(InvalidError::new("query filter depth exceeds cap 64"));
451        }
452        match filter {
453            Filter::All(filters) | Filter::Any(filters) => {
454                if filters.is_empty() {
455                    return Err(InvalidError::new(
456                        "query filter conjunction must not be empty",
457                    ));
458                }
459                for filter in filters {
460                    visit(filter, depth + 1, count)?;
461                }
462                Ok(())
463            }
464            Filter::Not(filter) => visit(filter, depth + 1, count),
465            Filter::Pred(predicate) => {
466                validate_query_name("predicate field", &predicate.field)?;
467                predicate.value.validate()?;
468                if predicate.op == CmpOp::In
469                    && !matches!(&predicate.value, TypedValue::List(values) if !values.is_empty())
470                {
471                    return Err(InvalidError::new(
472                        "query IN predicate requires a nonempty typed list",
473                    ));
474                }
475                if predicate.op == CmpOp::Prefix
476                    && !matches!(&predicate.value, TypedValue::String(_))
477                {
478                    return Err(InvalidError::new(
479                        "query prefix predicate requires a string value",
480                    ));
481                }
482                Ok(())
483            }
484        }
485    }
486
487    let mut count = 0;
488    filter.map_or(Ok(()), |filter| visit(filter, 1, &mut count))
489}
490
491fn is_false(value: &bool) -> bool {
492    !*value
493}
494
495/// How fresh a query's view of the materialized index must be. A materialized
496/// view is a read model a projector builds by tailing the log, so it is
497/// eventually consistent: a record is queryable once the projector has applied
498/// it, not the instant it is appended. This level says what the query requires
499/// of that lag, and the contract is fail-not-downgrade: a level that cannot be
500/// met returns [`QueryError::Stale`] rather than silently serving older data.
501// `Ord` follows the declaration order, which is the strength ladder
502// (Eventual < ReadYourWrites < Strong), so a stronger level compares greater and
503// a capability check is `want <= served`. A new variant must be appended to keep
504// the order meaningful.
505#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
506#[serde(rename_all = "snake_case")]
507#[non_exhaustive]
508pub enum Consistency {
509    /// Serve from the index as-is, whatever the projector has applied so far.
510    /// The default and the cheapest: no wait, best for dashboards and scans
511    /// where a little lag is fine.
512    #[default]
513    Eventual,
514    /// Wait until the projector has applied the source log up to its current
515    /// head before serving, so a query issued after a publish sees that write
516    /// (read-your-writes). Bounded: if the projector cannot catch up within the
517    /// managed deadline the query returns [`QueryError::Stale`] instead of
518    /// downgrading to a stale read. Backend-gated by `read_your_writes`.
519    ReadYourWrites,
520    /// The strongest level: a linearizable read across replicas. Backend-gated
521    /// by `strong_consistency`. Where unavailable the query returns a clean
522    /// unsupported error. Semantics past read-your-writes are still being
523    /// pinned, so treat it as read-your-writes plus cross-replica agreement.
524    Strong,
525}
526
527impl Consistency {
528    /// Whether this is the default `Eventual` level (omitted on the wire).
529    pub fn is_eventual(&self) -> bool {
530        matches!(self, Consistency::Eventual)
531    }
532}
533
534/// The server-side gate that enforces a [`Consistency`] level the same way on
535/// every backend. The client refuses an unadvertised level before sending,
536/// but a backend that does advertise `read_your_writes` or `strong_consistency`
537/// still has to honor the level, and the rule is fail-not-downgrade: serve only
538/// when the projector's `applied` offset for the queried source has reached the
539/// `required` offset (the source log head at query time), else return
540/// [`QueryError::Stale`] rather than a silently older read.
541///
542/// This is the offset obligation common to both non-`Eventual` levels.
543/// `Strong` is read-your-writes plus cross-replica agreement, so a backend
544/// serving `Strong` layers its own cross-replica check on top of a passing
545/// gate. `Eventual` always passes.
546#[derive(Clone, Copy, Debug, PartialEq, Eq)]
547pub struct ConsistencyGate {
548    /// The projector's applied offset for the queried source.
549    pub applied: u64,
550    /// The offset the read must reach before serving (the source log head).
551    pub required: u64,
552}
553
554impl ConsistencyGate {
555    /// A gate for a source whose projector has applied up to `applied` against a
556    /// head of `required`.
557    pub fn new(applied: u64, required: u64) -> Self {
558        Self { applied, required }
559    }
560
561    /// Whether the projector has caught up to the required offset.
562    pub fn is_caught_up(&self) -> bool {
563        self.applied >= self.required
564    }
565
566    /// Enforce `level` for the source named `what`. `Eventual` always passes. A
567    /// non-`Eventual` level passes only when [`is_caught_up`](Self::is_caught_up),
568    /// else returns [`QueryError::Stale`] carrying the offsets so the caller can
569    /// retry while the projector catches up.
570    pub fn check(&self, level: Consistency, what: impl Into<String>) -> Result<(), QueryError> {
571        if level.is_eventual() || self.is_caught_up() {
572            return Ok(());
573        }
574        Err(QueryError::Stale {
575            what: what.into(),
576            applied: self.applied,
577            required: self.required,
578        })
579    }
580}
581
582/// A predicate tree. `All`/`Any` are n-ary, `Not` negates, `Pred` is a single
583/// comparison leaf. Externally tagged on the wire:
584/// `{"all":[{"pred":{...}}]}`.
585#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
586#[serde(rename_all = "snake_case")]
587pub enum Filter {
588    All(Vec<Filter>),
589    Any(Vec<Filter>),
590    Not(Box<Filter>),
591    Pred(Predicate),
592}
593
594impl Filter {
595    /// AND of `filters`.
596    pub fn all(filters: impl IntoIterator<Item = Filter>) -> Self {
597        Filter::All(filters.into_iter().collect())
598    }
599
600    /// OR of `filters`.
601    pub fn any(filters: impl IntoIterator<Item = Filter>) -> Self {
602        Filter::Any(filters.into_iter().collect())
603    }
604
605    /// Negate `filter`.
606    pub fn negate(filter: Filter) -> Self {
607        Filter::Not(Box::new(filter))
608    }
609
610    /// A single comparison leaf, `field op value`.
611    pub fn pred(field: impl Into<String>, op: CmpOp, value: impl Into<TypedValue>) -> Self {
612        Filter::Pred(Predicate {
613            field: field.into(),
614            op,
615            value: value.into(),
616        })
617    }
618}
619
620/// A filter leaf: a field, a comparison op, and a value.
621#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
622pub struct Predicate {
623    pub field: String,
624    pub op: CmpOp,
625    pub value: TypedValue,
626}
627
628/// Raw-SQL escape hatch scoped to [`Query::target`]. The server validates both
629/// the parsed statement and the resulting scan plan against that target.
630#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
631pub struct RawSql {
632    pub dialect: SqlDialect,
633    pub sql: String,
634    #[serde(default, skip_serializing_if = "Vec::is_empty")]
635    pub params: Vec<TypedValue>,
636}
637
638/// A comparison operator for a `Predicate`.
639#[derive(
640    Clone,
641    Copy,
642    Debug,
643    PartialEq,
644    Eq,
645    Serialize,
646    Deserialize,
647    strum::Display,
648    strum::EnumString,
649    strum::VariantArray,
650)]
651#[serde(rename_all = "snake_case")]
652#[strum(serialize_all = "snake_case")]
653pub enum CmpOp {
654    Eq,
655    Ne,
656    Lt,
657    Lte,
658    Gt,
659    Gte,
660    In,
661    Contains,
662    Prefix,
663}
664
665/// An order-by clause: a field and a direction.
666#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
667pub struct Sort {
668    pub field: String,
669    #[serde(default)]
670    pub dir: Dir,
671}
672
673/// Sort direction (ascending or descending).
674#[derive(
675    Clone,
676    Copy,
677    Debug,
678    Default,
679    PartialEq,
680    Eq,
681    Serialize,
682    Deserialize,
683    strum::Display,
684    strum::EnumString,
685    strum::VariantArray,
686)]
687#[serde(rename_all = "snake_case")]
688#[strum(serialize_all = "snake_case")]
689pub enum Dir {
690    #[default]
691    Asc,
692    Desc,
693}
694
695/// A lexical relevance search: the text to match and, optionally, the one
696/// indexed field to match it in (`None` searches every text-hinted field).
697/// Relevance is returned as an ordinary typed result field. Capability-gated by
698/// `KEYWORD_SEARCH`: a backend without a lexical index answers unsupported,
699/// never a contains approximation.
700#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
701pub struct TextQuery {
702    #[serde(default, skip_serializing_if = "Option::is_none")]
703    pub field: Option<String>,
704    pub query: String,
705}
706
707/// A nearest-neighbour search: the query embedding and how many rows to return.
708#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
709pub struct VectorQuery {
710    pub field: String,
711    pub embedding: Vec<f32>,
712    pub top_k: u32,
713}
714
715/// A grouped aggregation carrying one or more [`AggCall`]s, so a single query
716/// can return several aggregates grouped by the same keys. An optional `window`
717/// adds a time-bucket key.
718#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
719pub struct Aggregate {
720    #[serde(default, skip_serializing_if = "Vec::is_empty")]
721    pub group_by: Vec<String>,
722    pub funcs: Vec<AggCall>,
723    #[serde(default, skip_serializing_if = "Option::is_none")]
724    pub window: Option<Window>,
725}
726
727/// One aggregate in an [`Aggregate`]. `field` is `None` only for `Count`, and `arg`
728/// is the fraction for `Percentile` (e.g. 0.95). `alias` names the output field
729/// in each result row.
730#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
731pub struct AggCall {
732    pub func: AggFunc,
733    #[serde(default, skip_serializing_if = "Option::is_none")]
734    pub field: Option<String>,
735    #[serde(default, skip_serializing_if = "Option::is_none")]
736    pub arg: Option<f64>,
737    pub alias: String,
738}
739
740/// An aggregate function. `Percentile` and `StdDev` are backend-gated (the
741/// embedded engine does not provide them, a columnar backend does).
742#[derive(
743    Clone,
744    Copy,
745    Debug,
746    PartialEq,
747    Eq,
748    Serialize,
749    Deserialize,
750    strum::Display,
751    strum::EnumString,
752    strum::VariantArray,
753)]
754#[serde(rename_all = "snake_case")]
755#[strum(serialize_all = "snake_case")]
756pub enum AggFunc {
757    Count,
758    CountDistinct,
759    Sum,
760    Avg,
761    Min,
762    Max,
763    Percentile,
764    StdDev,
765}
766
767/// A tumbling window of `every_micros` over the timestamp `field`.
768#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
769pub struct Window {
770    pub field: String,
771    pub every_micros: u64,
772}
773
774/// Which columns and payload a query returns.
775#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
776pub struct Select {
777    // Empty selects every indexed field.
778    #[serde(default, skip_serializing_if = "Vec::is_empty")]
779    pub fields: Vec<String>,
780    // Return the opaque payload bytes alongside the indexed fields.
781    #[serde(default)]
782    pub payload: bool,
783}
784
785/// A scalar value used by the graph and older non-query document surfaces. Query
786/// predicates and results use the tagged [`TypedValue`] contract. This remains
787/// untagged because it is part of the separate graph wire surface.
788///
789/// `#[serde(untagged)]`, riding the
790/// wire as a bare scalar. Variant order matters for untagged decode: `Int`
791/// before `Uint` keeps small/negative integers as `i64`, and a value past
792/// `i64::MAX` falls through to `Uint` before `Float` (never a lossy `f64`).
793/// `Null` is a unit variant matching a bare `null`.
794#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
795#[serde(untagged)]
796pub enum Value {
797    Str(String),
798    Int(i64),
799    Uint(u64),
800    Float(f64),
801    Bool(bool),
802    Null,
803    List(Vec<Value>),
804}
805
806impl From<&str> for Value {
807    fn from(value: &str) -> Self {
808        Self::Str(value.to_owned())
809    }
810}
811
812impl From<String> for Value {
813    fn from(value: String) -> Self {
814        Self::Str(value)
815    }
816}
817
818impl From<&String> for Value {
819    fn from(value: &String) -> Self {
820        Self::Str(value.clone())
821    }
822}
823
824impl From<i64> for Value {
825    fn from(value: i64) -> Self {
826        Self::Int(value)
827    }
828}
829
830impl From<u64> for Value {
831    fn from(value: u64) -> Self {
832        Self::Uint(value)
833    }
834}
835
836impl From<i32> for Value {
837    fn from(value: i32) -> Self {
838        Self::Int(value as i64)
839    }
840}
841
842impl From<u32> for Value {
843    fn from(value: u32) -> Self {
844        Self::Int(value as i64)
845    }
846}
847
848impl From<f64> for Value {
849    fn from(value: f64) -> Self {
850        Self::Float(value)
851    }
852}
853
854impl From<f32> for Value {
855    fn from(value: f32) -> Self {
856        Self::Float(value as f64)
857    }
858}
859
860impl From<bool> for Value {
861    fn from(value: bool) -> Self {
862        Self::Bool(value)
863    }
864}
865
866impl<T: Into<Value>> From<Vec<T>> for Value {
867    fn from(values: Vec<T>) -> Self {
868        Self::List(values.into_iter().map(Into::into).collect())
869    }
870}
871
872impl Value {
873    /// Infer a scalar from a user-typed string, the inverse of [`std::fmt::Display`] for a
874    /// UI input box. The narrowest type wins: `"null"` is [`Value::Null`],
875    /// `"true"`/`"false"` are [`Value::Bool`], a bare integer is
876    /// [`Value::Int`] (or [`Value::Uint`] past `i64::MAX`), a digits-and-dot
877    /// decimal is [`Value::Float`], and everything else is [`Value::Str`].
878    /// Lists are built structurally (e.g. for [`CmpOp::In`]), never inferred
879    /// here, so this never fails. Round-trips for every non-string scalar. A
880    /// string that happens to look like a number narrows to that number (so
881    /// `Display` then `from_input` is not the identity for a [`Value::Str`] of
882    /// numeric text, by design).
883    pub fn from_input(input: &str) -> Self {
884        match input {
885            "null" => return Value::Null,
886            "true" => return Value::Bool(true),
887            "false" => return Value::Bool(false),
888            _ => {}
889        }
890        if let Ok(int) = input.parse::<i64>() {
891            return Value::Int(int);
892        }
893        if let Ok(uint) = input.parse::<u64>() {
894            return Value::Uint(uint);
895        }
896        // Only digit-and-dot decimals narrow to a float. This rejects the
897        // float parser's `inf`/`nan`/`1e9` surprises a plain word would hit.
898        if !input.is_empty()
899            && input
900                .bytes()
901                .all(|b| b.is_ascii_digit() || b == b'.' || b == b'-' || b == b'+')
902            && let Ok(float) = input.parse::<f64>()
903        {
904            return Value::Float(float);
905        }
906        Value::Str(input.to_owned())
907    }
908}
909
910impl std::fmt::Display for Value {
911    /// Renders a scalar as its bare form (no quotes), so it reads naturally in a
912    /// UI cell or a predicate echo. A [`Value::List`] renders as `[a, b, c]`
913    /// over its elements' own `Display`.
914    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
915        match self {
916            Value::Str(value) => f.write_str(value),
917            Value::Int(value) => write!(f, "{value}"),
918            Value::Uint(value) => write!(f, "{value}"),
919            Value::Float(value) => write!(f, "{value}"),
920            Value::Bool(value) => write!(f, "{value}"),
921            Value::Null => f.write_str("null"),
922            Value::List(values) => {
923                f.write_str("[")?;
924                for (index, value) in values.iter().enumerate() {
925                    if index > 0 {
926                        f.write_str(", ")?;
927                    }
928                    write!(f, "{value}")?;
929                }
930                f.write_str("]")
931            }
932        }
933    }
934}
935
936impl std::str::FromStr for Value {
937    type Err = std::convert::Infallible;
938
939    fn from_str(s: &str) -> Result<Self, Self::Err> {
940        Ok(Value::from_input(s))
941    }
942}
943
944/// A typed page. Each row is positionally aligned with `fields`.
945#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
946pub struct QueryResult {
947    pub fields: Vec<LogicalField>,
948    pub rows: Vec<Row>,
949    #[serde(default)]
950    pub page: Page,
951    pub context: QueryContext,
952}
953
954/// One typed query row. Values are ordered exactly like [`QueryResult::fields`].
955#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
956pub struct Row {
957    pub values: Vec<TypedValue>,
958    /// Backend-native relevance for vector or lexical search. Vector backends
959    /// return distance and lexical backends return their declared rank.
960    #[serde(default, skip_serializing_if = "Option::is_none")]
961    pub score: Option<f32>,
962}
963
964impl Validate for QueryResult {
965    fn validate(&self) -> Result<(), InvalidError> {
966        validate_result_fields(&self.fields)?;
967        for row in &self.rows {
968            if row.values.len() != self.fields.len() {
969                return Err(InvalidError::new(
970                    "query row value count does not match the result schema",
971                ));
972            }
973            for (value, field) in row.values.iter().zip(&self.fields) {
974                value.validate_against(&field.field_type, field.required)?;
975            }
976            if row.score.is_some_and(|score| !score.is_finite()) {
977                return Err(InvalidError::new("query row score must be finite"));
978            }
979        }
980        if self.context.execution_id.as_u128() == 0 {
981            return Err(InvalidError::new(
982                "query result execution id must be nonzero",
983            ));
984        }
985        if self.context.row_count != self.rows.len() as u64 {
986            return Err(InvalidError::new(
987                "query result row count does not match the page rows",
988            ));
989        }
990        self.context.validate()?;
991        self.page.validate()?;
992        if self.page.has_more != self.page.next_cursor.is_some() {
993            return Err(InvalidError::new(
994                "query page has_more and next_cursor must agree",
995            ));
996        }
997        Ok(())
998    }
999}
1000
1001impl Validate for QueryContext {
1002    fn validate(&self) -> Result<(), InvalidError> {
1003        if self.execution_id.as_u128() == 0 {
1004            return Err(InvalidError::new(
1005                "query context execution id must be nonzero",
1006            ));
1007        }
1008        validate_query_name("query engine name", &self.engine.name)?;
1009        validate_query_name("query engine version", &self.engine.version)?;
1010        if self.delivered_consistency < self.requested_consistency {
1011            return Err(InvalidError::new(
1012                "delivered consistency is weaker than requested consistency",
1013            ));
1014        }
1015        match &self.resolved_target {
1016            ResolvedQueryTarget::Operational {
1017                index,
1018                backend_resource_id,
1019                backend_generation,
1020                runtime_configuration_revision,
1021            } => {
1022                validate_query_name("resolved index", index)?;
1023                if backend_resource_id.as_u128() == 0
1024                    || *backend_generation == 0
1025                    || *runtime_configuration_revision == 0
1026                {
1027                    return Err(InvalidError::new(
1028                        "operational query context is missing backend evidence",
1029                    ));
1030                }
1031                if self.boundary.is_some()
1032                    || self.checkpoint_revision.is_some()
1033                    || self.global_state_revision.is_some()
1034                {
1035                    return Err(InvalidError::new(
1036                        "operational query context cannot carry lakehouse checkpoint evidence",
1037                    ));
1038                }
1039            }
1040            ResolvedQueryTarget::Lakehouse {
1041                destination_id,
1042                destination_generation,
1043                backend_resource_id,
1044                backend_generation,
1045                runtime_configuration_revision,
1046                table_uuid,
1047                namespace,
1048                table,
1049                snapshot_id,
1050                schema_id,
1051                partition_spec_id,
1052            } => {
1053                if destination_id.as_u128() == 0
1054                    || *destination_generation == 0
1055                    || backend_resource_id.as_u128() == 0
1056                    || *backend_generation == 0
1057                    || *runtime_configuration_revision == 0
1058                    || table_uuid.as_bytes().len() != UuidValue::BYTES
1059                    || namespace.is_empty()
1060                    || *snapshot_id <= 0
1061                    || *schema_id < 0
1062                    || *partition_spec_id < 0
1063                    || self.boundary.is_none()
1064                    || self.checkpoint_revision.is_none()
1065                    || self.global_state_revision.is_none()
1066                {
1067                    return Err(InvalidError::new(
1068                        "lakehouse query context is missing resolved target evidence",
1069                    ));
1070                }
1071                for part in namespace {
1072                    validate_query_name("resolved table namespace", part)?;
1073                }
1074                validate_query_name("resolved table", table)?;
1075            }
1076        }
1077        if let Some(boundary) = &self.boundary {
1078            boundary.digest.validate()?;
1079        }
1080        Ok(())
1081    }
1082}
1083
1084impl QueryResult {
1085    pub fn field_index(&self, name: &str) -> Option<usize> {
1086        self.fields.iter().position(|field| field.name == name)
1087    }
1088
1089    pub fn value<'a>(&self, row: &'a Row, field: &str) -> Option<&'a TypedValue> {
1090        self.field_index(field)
1091            .and_then(|index| row.values.get(index))
1092    }
1093
1094    pub fn value_text(&self, row: &Row, field: &str) -> Option<String> {
1095        self.value(row, field).map(TypedValue::diagnostic_text)
1096    }
1097
1098    pub fn value_u64(&self, row: &Row, field: &str) -> Option<u64> {
1099        self.value(row, field).and_then(TypedValue::as_u64)
1100    }
1101
1102    pub fn value_i64(&self, row: &Row, field: &str) -> Option<i64> {
1103        self.value(row, field).and_then(TypedValue::as_i64)
1104    }
1105}
1106
1107/// Target and execution evidence returned with every page.
1108#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1109pub struct QueryContext {
1110    pub execution_id: QueryExecutionId,
1111    pub engine: QueryEngine,
1112    pub resolved_target: ResolvedQueryTarget,
1113    pub requested_consistency: Consistency,
1114    pub delivered_consistency: Consistency,
1115    #[serde(default, skip_serializing_if = "Option::is_none")]
1116    pub boundary: Option<MaterializationBoundary>,
1117    #[serde(default, skip_serializing_if = "Option::is_none")]
1118    pub checkpoint_revision: Option<u64>,
1119    #[serde(default, skip_serializing_if = "Option::is_none")]
1120    pub global_state_revision: Option<u64>,
1121    pub truncated: bool,
1122    pub elapsed_micros: u64,
1123    pub scanned_bytes: u64,
1124    pub produced_bytes: u64,
1125    pub row_count: u64,
1126    /// How far the store that served the query had applied each source
1127    /// partition of the index. A reply from another node can sit at another
1128    /// point of the same log, so a caller compares positions, not wall time.
1129    /// Empty when the store reports no positions.
1130    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1131    pub frontier: Vec<QueryFrontier>,
1132}
1133
1134/// One source partition's applied position in the store that served a query.
1135#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1136pub struct QueryFrontier {
1137    pub stream: String,
1138    pub topic: String,
1139    pub partition_id: u32,
1140    /// Creation time of the topic incarnation the rows come from, when the
1141    /// serving node knows it. Offsets of different incarnations do not
1142    /// compare.
1143    #[serde(default, skip_serializing_if = "Option::is_none")]
1144    pub topic_created_at_micros: Option<u64>,
1145    /// The last offset the store applied.
1146    pub applied_offset: u64,
1147}
1148
1149#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1150pub struct QueryEngine {
1151    pub name: String,
1152    pub version: String,
1153    #[serde(default, skip_serializing_if = "Option::is_none")]
1154    pub dialect: Option<SqlDialect>,
1155}
1156
1157#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1158#[serde(tag = "kind", rename_all = "snake_case")]
1159#[non_exhaustive]
1160pub enum ResolvedQueryTarget {
1161    Operational {
1162        index: String,
1163        backend_resource_id: BackendResourceId,
1164        backend_generation: u64,
1165        runtime_configuration_revision: u64,
1166    },
1167    Lakehouse {
1168        destination_id: DestinationId,
1169        destination_generation: u64,
1170        backend_resource_id: BackendResourceId,
1171        backend_generation: u64,
1172        runtime_configuration_revision: u64,
1173        table_uuid: UuidValue,
1174        namespace: Vec<String>,
1175        table: String,
1176        snapshot_id: i64,
1177        schema_id: i32,
1178        partition_spec_id: i32,
1179    },
1180}
1181
1182#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1183pub struct MaterializationBoundary {
1184    pub digest: Digest32,
1185    pub relation_to_current: BoundaryRelation,
1186}
1187
1188#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1189#[serde(rename_all = "snake_case")]
1190#[non_exhaustive]
1191pub enum BoundaryRelation {
1192    Current,
1193    Historical,
1194    AheadOfObservedCheckpoint,
1195}
1196
1197/// Pagination info for a query result.
1198///
1199/// The default reply costs one page: the server fetches `limit + 1` rows and
1200/// answers `has_more` exactly from the probe row, never counting the rest.
1201/// `total` is present only when the request set `want_total`, which runs a
1202/// real `COUNT(*)` over the filter (unbounded work on a large index). A
1203/// caller that never asked cannot misread a page-bounded number as a count:
1204/// there is no number.
1205#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1206pub struct Page {
1207    #[serde(default, skip_serializing_if = "Option::is_none")]
1208    pub offset: Option<u64>,
1209    // The effective limit applied after validation against the page cap.
1210    pub limit: u32,
1211    // Exact match count, present only when the request set `want_total`.
1212    #[serde(default, skip_serializing_if = "Option::is_none")]
1213    pub total: Option<u64>,
1214    // Whether rows beyond this page exist. Always exact, always free.
1215    pub has_more: bool,
1216    #[serde(default, skip_serializing_if = "Option::is_none")]
1217    pub next_cursor: Option<String>,
1218}
1219
1220impl Page {
1221    /// Rows known to exist so far: the position one past this page's last
1222    /// row. A cheap display lower bound (`"1234+ rows"`), paired with
1223    /// [`Self::has_more`] for the trailing `+`.
1224    pub fn at_least(&self, rows_on_page: usize) -> Option<u64> {
1225        self.offset
1226            .and_then(|offset| offset.checked_add(rows_on_page as u64))
1227    }
1228
1229    /// Pages implied by the exact total, when one was requested. `None`
1230    /// without `want_total` or with a zero limit (page math is undefined
1231    /// without a page size).
1232    pub fn total_pages(&self) -> Option<u64> {
1233        match (self.total, self.limit) {
1234            (Some(total), limit) if limit > 0 => Some(total.div_ceil(u64::from(limit))),
1235            _ => None,
1236        }
1237    }
1238}
1239
1240impl Validate for Page {
1241    fn validate(&self) -> Result<(), InvalidError> {
1242        if self.limit == 0 || self.limit > MAX_PAGE_SIZE as u32 {
1243            return Err(InvalidError::new(format!(
1244                "query result page limit exceeds cap {MAX_PAGE_SIZE}"
1245            )));
1246        }
1247        if self.has_more != self.next_cursor.is_some() {
1248            return Err(InvalidError::new(
1249                "query page has_more and next_cursor must agree",
1250            ));
1251        }
1252        if let Some(cursor) = &self.next_cursor
1253            && (cursor.is_empty()
1254                || cursor.len() > MAX_QUERY_CURSOR_BYTES
1255                || cursor.chars().any(char::is_control))
1256        {
1257            return Err(InvalidError::new("query result cursor is invalid"));
1258        }
1259        Ok(())
1260    }
1261}
1262
1263/// Internal on-wire envelope: a versioned wrapper around `Query`. Workers and
1264/// clients use it, app code does not.
1265#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1266#[non_exhaustive]
1267pub struct QueryEnvelope {
1268    pub v: u32,
1269    pub query: Query,
1270}
1271
1272impl QueryEnvelope {
1273    /// Constructor for the non-exhaustive wire struct.
1274    pub fn new(query: Query) -> Self {
1275        Self {
1276            v: QUERY_OP_VERSION,
1277            query,
1278        }
1279    }
1280}
1281
1282impl Validate for QueryEnvelope {
1283    fn validate(&self) -> Result<(), InvalidError> {
1284        if self.v != QUERY_OP_VERSION {
1285            return Err(InvalidError::new(format!(
1286                "query version must be {QUERY_OP_VERSION}, got {}",
1287                self.v
1288            )));
1289        }
1290        self.query.validate()
1291    }
1292}
1293
1294#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1295pub struct QueryPageEnvelope {
1296    pub v: u32,
1297    pub execution_id: QueryExecutionId,
1298    pub cursor: String,
1299    pub deadline_micros: u64,
1300}
1301
1302#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1303pub struct QueryCancelEnvelope {
1304    pub v: u32,
1305    pub execution_id: QueryExecutionId,
1306}
1307
1308#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1309pub struct QueryStatusEnvelope {
1310    pub v: u32,
1311    pub execution_id: QueryExecutionId,
1312}
1313
1314impl QueryPageEnvelope {
1315    pub fn new(
1316        execution_id: QueryExecutionId,
1317        cursor: impl Into<String>,
1318        deadline_micros: u64,
1319    ) -> Self {
1320        Self {
1321            v: QUERY_OP_VERSION,
1322            execution_id,
1323            cursor: cursor.into(),
1324            deadline_micros,
1325        }
1326    }
1327}
1328
1329impl QueryCancelEnvelope {
1330    pub const fn new(execution_id: QueryExecutionId) -> Self {
1331        Self {
1332            v: QUERY_OP_VERSION,
1333            execution_id,
1334        }
1335    }
1336}
1337
1338impl QueryStatusEnvelope {
1339    pub const fn new(execution_id: QueryExecutionId) -> Self {
1340        Self {
1341            v: QUERY_OP_VERSION,
1342            execution_id,
1343        }
1344    }
1345}
1346
1347impl Validate for QueryPageEnvelope {
1348    fn validate(&self) -> Result<(), InvalidError> {
1349        validate_query_control(self.v, self.execution_id)?;
1350        if self.cursor.is_empty()
1351            || self.cursor.len() > MAX_QUERY_CURSOR_BYTES
1352            || self.cursor.chars().any(char::is_control)
1353            || self.deadline_micros == 0
1354        {
1355            return Err(InvalidError::new(
1356                "query page cursor or deadline is invalid",
1357            ));
1358        }
1359        Ok(())
1360    }
1361}
1362
1363impl Validate for QueryCancelEnvelope {
1364    fn validate(&self) -> Result<(), InvalidError> {
1365        validate_query_control(self.v, self.execution_id)
1366    }
1367}
1368
1369impl Validate for QueryStatusEnvelope {
1370    fn validate(&self) -> Result<(), InvalidError> {
1371        validate_query_control(self.v, self.execution_id)
1372    }
1373}
1374
1375fn validate_query_control(v: u32, execution_id: QueryExecutionId) -> Result<(), InvalidError> {
1376    if v != QUERY_OP_VERSION {
1377        return Err(InvalidError::new(format!(
1378            "query version must be {QUERY_OP_VERSION}, got {v}"
1379        )));
1380    }
1381    if execution_id.as_u128() == 0 {
1382        return Err(InvalidError::new("query execution id must be nonzero"));
1383    }
1384    Ok(())
1385}
1386
1387#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1388pub struct QueryExecutionStatus {
1389    pub execution_id: QueryExecutionId,
1390    pub state: QueryExecutionState,
1391    pub started_at_micros: u64,
1392    #[serde(default, skip_serializing_if = "Option::is_none")]
1393    pub finished_at_micros: Option<u64>,
1394    pub scanned_bytes: u64,
1395    pub produced_bytes: u64,
1396    pub row_count: u64,
1397    #[serde(default, skip_serializing_if = "Option::is_none")]
1398    pub error: Option<QueryError>,
1399}
1400
1401impl Validate for QueryExecutionStatus {
1402    fn validate(&self) -> Result<(), InvalidError> {
1403        if self.execution_id.as_u128() == 0 || self.started_at_micros == 0 {
1404            return Err(InvalidError::new(
1405                "query execution identity and start time must be nonzero",
1406            ));
1407        }
1408        let terminal = matches!(
1409            self.state,
1410            QueryExecutionState::Completed
1411                | QueryExecutionState::Cancelled
1412                | QueryExecutionState::Failed
1413                | QueryExecutionState::Expired
1414        );
1415        match (terminal, self.finished_at_micros) {
1416            (true, Some(finished)) if finished >= self.started_at_micros => {}
1417            (false, None) => {}
1418            _ => Err(InvalidError::new(
1419                "query execution finish time does not match its state",
1420            ))?,
1421        }
1422        if matches!(self.state, QueryExecutionState::Failed) != self.error.is_some() {
1423            return Err(InvalidError::new(
1424                "query execution error must be present exactly for failed state",
1425            ));
1426        }
1427        Ok(())
1428    }
1429}
1430
1431#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1432#[serde(rename_all = "snake_case")]
1433#[non_exhaustive]
1434pub enum QueryExecutionState {
1435    Queued,
1436    Planning,
1437    Running,
1438    Completed,
1439    Cancelled,
1440    Failed,
1441    Expired,
1442}
1443
1444#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1445#[non_exhaustive]
1446pub enum QueryStatusReply {
1447    Ok(QueryExecutionStatus),
1448    Err(QueryError),
1449}
1450
1451#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1452#[non_exhaustive]
1453pub enum QueryCancelReply {
1454    Ok(QueryExecutionStatus),
1455    Err(QueryError),
1456}
1457
1458/// A query reply: `Ok(QueryResult)` or `Err(QueryError)`.
1459#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1460#[non_exhaustive]
1461pub enum QueryReply {
1462    Ok(Box<QueryResult>),
1463    Err(QueryError),
1464}
1465
1466/// Why a query failed.
1467#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
1468#[non_exhaustive]
1469pub enum QueryError {
1470    #[error("query not supported: {0}")]
1471    Unsupported(String),
1472    #[error("unauthorized: {0}")]
1473    Unauthorized(String),
1474    #[error("index not found: {0}")]
1475    IndexNotFound(String),
1476    #[error("fork not found: {0}")]
1477    ForkNotFound(String),
1478    #[error("backend error: {0}")]
1479    Backend(String),
1480    /// The request could not be served right now and the identical request may
1481    /// succeed later: the store was momentarily out of reach, a connection was
1482    /// refused or dropped, or a concurrency conflict was aborted. Retry with
1483    /// backoff rather than treating it as a fault.
1484    #[error("temporarily unavailable: {0}")]
1485    Unavailable(String),
1486    /// The query asked for more than a single reply may carry: a `limit`
1487    /// above the page cap, or a result whose inline payloads exceed the
1488    /// LaserData Cloud's reply-byte budget. `what` names the bound hit ("limit" /
1489    /// "reply bytes"), `size` is what was requested or reached, `cap` is the
1490    /// ceiling. Page with the returned cursor (or drop the payload request)
1491    /// rather than retrying unchanged.
1492    #[error("result too large: {what} {size} exceeds cap {cap}")]
1493    TooLarge { what: String, size: u64, cap: u64 },
1494    #[error("unsupported envelope version (expected {expected}, got {got})")]
1495    Version { expected: u32, got: u32 },
1496    /// A [`Consistency`] level could not be met within the managed deadline: the
1497    /// projector's applied offset for the queried source sits at `applied` while
1498    /// the level required `required`. Fail-not-downgrade, so the caller retries
1499    /// (the projector is catching up) rather than unknowingly reading stale
1500    /// data. `what` names the source (index or partition) that lagged.
1501    #[error("stale read: {what} applied {applied}, required {required}")]
1502    Stale {
1503        what: String,
1504        applied: u64,
1505        required: u64,
1506    },
1507    #[error("query {execution_id} was cancelled")]
1508    Cancelled { execution_id: QueryExecutionId },
1509    #[error("query {execution_id} exceeded its deadline")]
1510    DeadlineExceeded { execution_id: QueryExecutionId },
1511    #[error("snapshot {snapshot_id} is no longer available")]
1512    ExpiredSnapshot { snapshot_id: i64 },
1513    #[error("stale {what} generation: requested {requested}, observed {observed}")]
1514    StaleGeneration {
1515        what: String,
1516        requested: u64,
1517        observed: u64,
1518    },
1519    #[error("query target unavailable: {reason}")]
1520    TargetUnavailable { reason: String },
1521    #[error("query resource limit exceeded: {resource} used {observed}, limit {limit}")]
1522    ResourceLimit {
1523        resource: String,
1524        observed: u64,
1525        limit: u64,
1526    },
1527}
1528
1529/// Stable machine-readable classification for [`QueryError`].
1530#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1531#[serde(rename_all = "snake_case")]
1532#[non_exhaustive]
1533pub enum QueryErrorCode {
1534    Unsupported,
1535    Unauthorized,
1536    IndexNotFound,
1537    ForkNotFound,
1538    Backend,
1539    Unavailable,
1540    TooLarge,
1541    Version,
1542    Stale,
1543    Cancelled,
1544    DeadlineExceeded,
1545    ExpiredSnapshot,
1546    StaleGeneration,
1547    TargetUnavailable,
1548    ResourceLimit,
1549}
1550
1551impl QueryError {
1552    pub fn code(&self) -> QueryErrorCode {
1553        match self {
1554            Self::Unsupported(_) => QueryErrorCode::Unsupported,
1555            Self::Unauthorized(_) => QueryErrorCode::Unauthorized,
1556            Self::IndexNotFound(_) => QueryErrorCode::IndexNotFound,
1557            Self::ForkNotFound(_) => QueryErrorCode::ForkNotFound,
1558            Self::Backend(_) => QueryErrorCode::Backend,
1559            Self::Unavailable(_) => QueryErrorCode::Unavailable,
1560            Self::TooLarge { .. } => QueryErrorCode::TooLarge,
1561            Self::Version { .. } => QueryErrorCode::Version,
1562            Self::Stale { .. } => QueryErrorCode::Stale,
1563            Self::Cancelled { .. } => QueryErrorCode::Cancelled,
1564            Self::DeadlineExceeded { .. } => QueryErrorCode::DeadlineExceeded,
1565            Self::ExpiredSnapshot { .. } => QueryErrorCode::ExpiredSnapshot,
1566            Self::StaleGeneration { .. } => QueryErrorCode::StaleGeneration,
1567            Self::TargetUnavailable { .. } => QueryErrorCode::TargetUnavailable,
1568            Self::ResourceLimit { .. } => QueryErrorCode::ResourceLimit,
1569        }
1570    }
1571}
1572
1573#[cfg(test)]
1574mod tests {
1575    use super::*;
1576
1577    #[test]
1578    fn given_dsl_enums_when_displayed_then_should_be_snake_case() {
1579        assert_eq!(CmpOp::Gte.to_string(), "gte");
1580        assert_eq!(CmpOp::Prefix.to_string(), "prefix");
1581        assert_eq!("ne".parse::<CmpOp>().expect("ne parses"), CmpOp::Ne);
1582        assert_eq!(Dir::Desc.to_string(), "desc");
1583        assert_eq!(AggFunc::Count.to_string(), "count");
1584    }
1585
1586    #[test]
1587    fn given_a_consistency_gate_when_checked_then_should_fail_not_downgrade() {
1588        // Eventual always passes, regardless of lag.
1589        assert!(
1590            ConsistencyGate::new(0, 100)
1591                .check(Consistency::Eventual, "readings")
1592                .is_ok()
1593        );
1594        // A non-Eventual level passes only once caught up.
1595        assert!(
1596            ConsistencyGate::new(100, 100)
1597                .check(Consistency::ReadYourWrites, "readings")
1598                .is_ok()
1599        );
1600        let stale = ConsistencyGate::new(41, 57)
1601            .check(Consistency::Strong, "readings")
1602            .expect_err("a lagging projector must fail, never downgrade");
1603        assert!(matches!(
1604            stale,
1605            QueryError::Stale {
1606                applied: 41,
1607                required: 57,
1608                ..
1609            }
1610        ));
1611    }
1612
1613    #[test]
1614    fn given_a_page_when_computing_total_pages_then_should_divide_by_limit() {
1615        let page = Page {
1616            offset: Some(0),
1617            limit: 3,
1618            total: Some(10),
1619            has_more: true,
1620            next_cursor: Some("next".to_owned()),
1621        };
1622        assert_eq!(page.total_pages(), Some(4));
1623        assert_eq!(page.at_least(3), Some(3), "offset 0 plus this page's rows");
1624        // Without a requested exact total there is no number to misread.
1625        assert_eq!(Page::default().total_pages(), None);
1626    }
1627}
1628
1629#[cfg(all(test, feature = "codecs"))]
1630mod serde_tests {
1631    use super::*;
1632    use crate::codes::QUERY_OP_VERSION;
1633    use crate::framing::{decode_named, encode_named};
1634
1635    fn query() -> Query {
1636        Query {
1637            execution_id: QueryExecutionId::from_u128(1),
1638            target: QueryTarget::operational("readings"),
1639            deadline_micros: 1_000_000,
1640            by_key: Vec::new(),
1641            message_type: None,
1642            time_range: None,
1643            filter: None,
1644            vector: None,
1645            text: None,
1646            order: Vec::new(),
1647            page: QueryPageRequest::default(),
1648            aggregate: None,
1649            having: None,
1650            distinct: false,
1651            select: Select::default(),
1652            fork: None,
1653            raw_sql: None,
1654            consistency: Consistency::Eventual,
1655        }
1656    }
1657
1658    #[test]
1659    fn given_dsl_enums_when_serialized_then_serde_should_match_display() {
1660        assert_eq!(
1661            serde_json::to_string(&CmpOp::Lte).expect("CmpOp serializes"),
1662            "\"lte\""
1663        );
1664        assert_eq!(
1665            serde_json::from_str::<CmpOp>("\"in\"").expect("CmpOp deserializes"),
1666            CmpOp::In
1667        );
1668        assert_eq!(
1669            serde_json::to_string(&Dir::Asc).expect("Dir serializes"),
1670            "\"asc\""
1671        );
1672    }
1673
1674    #[test]
1675    fn given_a_query_when_round_tripped_through_the_envelope_then_should_be_unchanged() {
1676        let mut query = query();
1677        query.by_key = vec![KeyMatch::new("host_id", "abc")];
1678        query.filter = Some(Filter::pred("status", CmpOp::Eq, "degraded"));
1679        query.order = vec![Sort {
1680            field: "ts".to_owned(),
1681            dir: Dir::Desc,
1682        }];
1683        query.page.limit = 20;
1684        let request = QueryEnvelope::new(query);
1685
1686        let json = serde_json::to_string(&request).expect("the request serializes");
1687        let back: QueryEnvelope = serde_json::from_str(&json).expect("the request deserializes");
1688        assert_eq!(back.v, QUERY_OP_VERSION);
1689        assert_eq!(back.query.target, QueryTarget::operational("readings"));
1690        assert_eq!(back.query.page.limit, 20);
1691        assert_eq!(back.query.by_key, vec![KeyMatch::new("host_id", "abc")]);
1692        let Some(Filter::Pred(predicate)) = &back.query.filter else {
1693            panic!("expected a single predicate filter");
1694        };
1695        assert_eq!(predicate.value, TypedValue::String("degraded".to_owned()));
1696        assert_eq!(back.query.order[0].dir, Dir::Desc);
1697    }
1698
1699    #[test]
1700    fn given_each_consistency_level_when_round_tripped_then_should_preserve_it_and_skip_eventual() {
1701        for level in [
1702            Consistency::Eventual,
1703            Consistency::ReadYourWrites,
1704            Consistency::Strong,
1705        ] {
1706            let mut query = query();
1707            query.consistency = level;
1708            let bytes = encode_named(&QueryEnvelope::new(query)).expect("serializes");
1709            let back: QueryEnvelope = decode_named(&bytes).expect("deserializes");
1710            assert_eq!(back.query.consistency, level);
1711        }
1712        // The default `Eventual` is omitted on the wire so the pre-consistency
1713        // contract stays byte-identical.
1714        let default = query();
1715        assert_eq!(default.consistency, Consistency::Eventual);
1716        let json = serde_json::to_string(&default).expect("json");
1717        assert!(
1718            !json.contains("consistency"),
1719            "default Eventual must be omitted: {json}"
1720        );
1721    }
1722
1723    #[test]
1724    fn given_a_stale_reply_when_round_tripped_then_should_preserve_the_offsets() {
1725        let reply = QueryReply::Err(QueryError::Stale {
1726            what: "readings".to_owned(),
1727            applied: 41,
1728            required: 57,
1729        });
1730        let bytes = encode_named(&reply).expect("serializes");
1731        let back: QueryReply = decode_named(&bytes).expect("deserializes");
1732        let QueryReply::Err(QueryError::Stale {
1733            what,
1734            applied,
1735            required,
1736        }) = back
1737        else {
1738            panic!("expected a Stale error");
1739        };
1740        assert_eq!((what.as_str(), applied, required), ("readings", 41, 57));
1741    }
1742
1743    #[test]
1744    fn given_a_vector_query_when_round_tripped_then_should_preserve_the_embedding() {
1745        let mut query = query();
1746        query.target = QueryTarget::operational("mem:conv-1");
1747        query.vector = Some(VectorQuery {
1748            field: "embedding".to_owned(),
1749            embedding: vec![0.1, 0.2, 0.3],
1750            top_k: 5,
1751        });
1752        let json = serde_json::to_string(&query).expect("the query serializes");
1753        let back: Query = serde_json::from_str(&json).expect("the query deserializes");
1754        let vector = back.vector.expect("the vector survives the round-trip");
1755        assert_eq!(vector.embedding, vec![0.1, 0.2, 0.3]);
1756        assert_eq!(vector.top_k, 5);
1757    }
1758
1759    #[test]
1760    fn given_a_typed_reply_when_round_tripped_then_should_preserve_the_values() {
1761        let reply = QueryReply::Ok(Box::new(QueryResult {
1762            fields: vec![LogicalField {
1763                id: 1,
1764                name: "reading_id".to_owned(),
1765                required: true,
1766                field_type: crate::schema::LogicalType::String,
1767                doc: None,
1768            }],
1769            rows: vec![Row {
1770                values: vec![TypedValue::String("123".to_owned())],
1771                score: Some(0.25),
1772            }],
1773            page: Page {
1774                offset: Some(0),
1775                limit: 50,
1776                total: Some(1),
1777                has_more: false,
1778                next_cursor: None,
1779            },
1780            context: QueryContext {
1781                execution_id: QueryExecutionId::from_u128(1),
1782                engine: QueryEngine {
1783                    name: "datafusion".to_owned(),
1784                    version: "50.0.0".to_owned(),
1785                    dialect: Some(SqlDialect::DataFusion),
1786                },
1787                resolved_target: ResolvedQueryTarget::Operational {
1788                    index: "readings".to_owned(),
1789                    backend_resource_id: BackendResourceId::from_u128(2),
1790                    backend_generation: 1,
1791                    runtime_configuration_revision: 1,
1792                },
1793                requested_consistency: Consistency::Eventual,
1794                delivered_consistency: Consistency::Eventual,
1795                boundary: None,
1796                checkpoint_revision: None,
1797                global_state_revision: None,
1798                truncated: false,
1799                elapsed_micros: 10,
1800                scanned_bytes: 128,
1801                produced_bytes: 32,
1802                row_count: 1,
1803                frontier: Vec::new(),
1804            },
1805        }));
1806        let bytes = encode_named(&reply).expect("the reply serializes");
1807        let back: QueryReply = decode_named(&bytes).expect("the reply deserializes");
1808        let QueryReply::Ok(result) = back else {
1809            panic!("the reply should decode as Ok");
1810        };
1811        assert_eq!(
1812            result.rows[0].values,
1813            vec![TypedValue::String("123".to_owned())]
1814        );
1815        assert_eq!(result.page.total, Some(1));
1816        assert!(!result.page.has_more);
1817    }
1818}