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